Handbooks / Python / Chapter 3

Classes & Objects

44 pages · ~61 min✓ Reviewed

Builds on Data Structures. Next up: Decorators & Closures.

Part 1 · Classes & Objects: Modelling Things in Python

Classes & Objects: Modelling Things in Python

Almost everything you touch in Python is an object: a string, a list, a file, even a function. A class is the blueprint that says what an object holds and what it can do. Once you can write your own classes, you stop passing five loose variables around and start bundling them with the code that works on them, so a User, an Order or a Timer becomes one tidy thing you can create, pass around and reason about.

This matters because nearly every real Python codebase is built from classes, and the libraries you use daily (datetime, pathlib, requests, Django, pandas) expose them everywhere. Reading that code, and writing code others can read, depends on a handful of ideas that this chapter takes one at a time: how __init__ and self work, where data lives, how inheritance and super() share behaviour, and how special methods and @property make your objects feel like built-in ones.

By the end you will be able to design and write small classes with clean attributes and methods, give them readable printing and comparison, choose between inheritance and composition, and cut boilerplate with dataclasses. You will also recognise the classic traps, such as a shared mutable class attribute or a forgotten super().__init__(), before they cost you an afternoon of debugging.

Where this chapter takes you
  1. 1Classes and instancesblueprint and objects
  2. 2Attributes and methodsdata and behaviour
  3. 3Inheritance and dundersreuse and built-in feel
  4. 4Dataclasses and designless code, better choices
Before you start

You need Python 3.11 or newer and a place to run snippets, such as a terminal with python or a notebook. Be comfortable with variables, functions, if and for, lists and dictionaries. No prior object-oriented experience is needed. Type each example yourself and change a value to see what breaks.

Part 2 · Classes and Instances

A blueprint and the things built from it

Python programs often need many objects of the same kind: a hundred dogs, a thousand orders, a handful of database connections. Writing each one by hand would be tedious. A class is the blueprint that describes what such an object looks like and what it can do. You create it once with the class statement.

A blueprint is not a house. To get something you can use, you call the class like a function. Each call builds one new object, called an instance. In d = Dog(), Dog is the class and d is an instance of it.

python
class Dog:
    pass

d = Dog()
print(type(d).__name__)
print(isinstance(d, Dog))

The smallest possible class, and one instance of it

output
Dog
True

The body of this class is just pass, so it does nothing yet. It is still a real class, and Dog() still gives you a real object. Later sections fill the blueprint with data and behaviour.

You can call the class as many times as you like. Every call gives a separate instance, even though all of them follow the same blueprint.

python
a = Dog()
b = Dog()
print(a is b)
print(a == b)
print(type(a) is type(b))

Two instances from the same class are still two different objects

output
False
False
True

a is b asks whether both names point at the very same object, and they do not. a == b is also False because a plain class has no idea how to compare its instances, so Python falls back to identity. Both objects do share one class, which is why the last line is True.

ClassInstance
Made byThe class statementCalling the class, like Dog()
RoleBlueprint shared by many objectsOne concrete object
How manyUsually one per kind of thingAs many as you create
ExampleDogd, a, b

Classes are objects too

In Python, nearly everything is an object, and that includes classes. When Python runs a class statement, it creates an object and binds it to the class name. You can pass Dog to a function, store it in a list or inspect it, just like a number or a string.

Every object has a type, and the built-in type() function tells you what it is. The type of an instance is its class. The type of a class is type, which is the thing that builds classes.

python
print(type(d))
print(type(Dog))
print(type(d) is Dog)
print(type(Dog) is type)
print(Dog.__name__)

type() of an instance, and type() of the class itself

output
<class '__main__.Dog'>
<class 'type'>
True
True
Dog

Read the first two lines as a chain. d is an instance of Dog, and Dog is an instance of type. That is why a class can have attributes of its own, such as Dog.__name__, which holds the name you wrote after the class keyword.

Who is an instance of what
  1. 1dyour object
  2. 2Dogtype(d) is Dog
  3. 3typetype(Dog) is type
Common mistake: mixing up Dog and Dog()

Dog is the class itself. Dog() calls it and returns a new instance. Writing d = Dog without the parentheses gives d another name for the blueprint, not a dog, and you will not see an error until you try to use it like an instance.

What an instance holds, and the interview question

Each instance keeps its own data. Python stores that data in a dictionary called __dict__, which maps attribute names to values. Setting rex.name = 'Rex' simply adds the key 'name' to the __dict__ of rex and to no other object.

python
rex = Dog()
rex.name = 'Rex'
rex.age = 3

fido = Dog()
fido.name = 'Fido'

print(rex.__dict__)
print(fido.__dict__)
rex.age = 4
print(rex.__dict__['age'])

Two instances of one class, each with its own attributes

output
{'name': 'Rex', 'age': 3}
{'name': 'Fido'}
4

rex and fido come from the same class, yet their dictionaries differ. fido never got an age, and changing rex.age did not affect anything else. Looking at __dict__ is a handy way to see exactly what an object currently holds.

The last tool for this section is isinstance(obj, cls). It answers whether obj was built from cls, or from a class that inherits from it. It is more flexible than type(obj) is cls, which accepts only the exact class.

python
print(isinstance(rex, Dog))
print(isinstance(Dog, type))
print(isinstance(rex, str))
print(isinstance(rex, object))

isinstance checks the relationship between an object and a class

output
True
True
False
True
Interview: What is the difference between a class and an instance? Is a class an object? What does isinstance(d, Dog) check?
  • A class is the blueprint created by the class statement. An instance is one object built by calling the class, such as d = Dog(). Many instances can share one class, and each keeps its own attributes in its own __dict__.
  • Yes, a class is an object. Python creates it when the class statement runs, so you can pass it around and inspect it. type(Dog) is type, and type(d) is Dog.
  • isinstance(d, Dog) checks whether d is an instance of Dog or of any subclass of Dog. It returns True or False.
d = Dog()
print(type(d) is Dog)
print(isinstance(d, Dog))
Remember

A class is a blueprint and an object in its own right. Calling it builds instances, and each instance keeps its own attributes in __dict__.

Part 3 · __init__ and self

What happens when you call Dog('Rex')

Writing Dog('Rex') looks like one step, but Python does two jobs behind the scenes. First it calls __new__, which builds a brand-new, empty object in memory. Then it calls __init__ on that object, which fills it in with the starting data you care about. You almost never write __new__ yourself; you write __init__.

Dog('Rex') step by step
  1. 1Dog.new(Dog, 'Rex')creates an empty Dog object
  2. 2Dog.init(obj, 'Rex')sets up the object's data
  3. 3Resultthe finished object is handed back to you

Notice that __init__ is not what makes the object. It receives an object that already exists and decorates it. That is why __init__ has no job of handing anything back, and why Python insists that it returns None. The finished object comes from the Dog(...) call itself, not from __init__.

python
class Dog:
    def __new__(cls, name):
        print('__new__ creating the object')
        return super().__new__(cls)

    def __init__(self, name):
        print('__init__ setting up', name)
        self.name = name

d = Dog('Rex')
print(d.name)

Both hooks announce themselves, so you can see the order.

output
__new__ creating the object
__init__ setting up Rex
Rex
newinit
JobCreates the objectSets up an existing object
First parametercls (the class)self (the new object)
RunsFirstSecond
ReturnsThe new objectMust return None
How often you write itRarelyAlmost every class

self is just the first parameter

Inside a class, every ordinary method receives the object it was called on as its first parameter. By convention that parameter is named self. There is nothing magical about it: when you write d.bark(), Python looks up bark on the class and passes d in automatically. So d.bark() is just friendly shorthand for Dog.bark(d).

python
class Dog:
    def __init__(self, name):
        self.name = name

    def bark(self):
        return self.name + ' says woof'

d = Dog('Rex')
print(d.bark())
print(Dog.bark(d))
print(d.bark() == Dog.bark(d))

The two calls are the same call written two ways.

output
Rex says woof
Rex says woof
True

This also explains a classic error. If you define def bark(): with no parameters, calling d.bark() quietly passes d as an argument the method has no room for, and Python complains about the argument count.

Common mistake: forgetting self

Writing def bark(): instead of def bark(self): gives TypeError: bark() takes 0 positional arguments but 1 was given. The 1 given is the object Python passed for you.

Storing state with self.name = name

Inside __init__, the parameter name is an ordinary local variable. It lives only while __init__ runs and vanishes when it ends. To keep the value on the object, attach it with self.name = name. The left side is an attribute on the object; the right side is the local parameter being copied in.

python
class Lost:
    def __init__(self, name):
        name = name          # only a local variable

class Kept:
    def __init__(self, name):
        self.name = name     # stored on the object

print(hasattr(Lost('Rex'), 'name'))
print(Kept('Rex').name)

The bare assignment disappears the moment __init__ finishes.

output
False
Rex
Common mistake: a bare name = name

name = name inside __init__ assigns the parameter to itself and stores nothing. Later, obj.name raises AttributeError. Always write self.name = name.

Remember

State lives on self. Anything assigned without self. is a local variable and is lost when the method ends.

Interview Point

Is self a keyword?
  • No. self is only a naming convention for the first parameter of a method.
  • Python would accept any valid name, such as this, and it would work.
  • Everyone uses self, so you should too; other names confuse readers and tools.
class A:
    def show(this):
        return 'works'

print(A().show())
What is the difference between new and init?
  • __new__ creates and returns the object; it receives the class (cls).
  • __init__ initializes the already-created object; it receives the instance (self).
  • __new__ runs first, then __init__ runs on what __new__ returned.
What happens if init returns a value?
  • Python raises TypeError: __init__() should return None, not 'int' (the type name varies).
  • The error appears when the object is created, because Dog(...) checks the result of __init__.
  • The object you get from Dog(...) comes from __new__, so __init__ has nothing to return.
class Bad:
    def __init__(self):
        return 5

Bad()   # TypeError
QuestionShort answer
Is self a keyword?No, just a convention
__new__ vs __init__Create vs set up
__init__ returns a value?TypeError; it must return None

Part 4 · Attributes vs Methods

Data and behaviour on an object

An object bundles two kinds of things: the data it remembers and the things it can do. In Python the data lives in attributes and the actions live in methods. Both are looked up with a dot, which is why they are easy to mix up at first.

An attribute is a named value stored on an object, such as self.age. A method is a function defined inside the class body whose first parameter is self, so it can read and change the attributes of the object it was called on.

AttributeMethod
What it isA value (number, string, list, any object)A function defined in the class
Exampled.aged.bark()
Needs parentheses to useNo, you just read itYes, parentheses run it
Typical jobRemember stateRead or change state, do work
First parameterNoneself

In the example below, name and age are attributes set in __init__. bark reads an attribute and builds a message, while birthday changes an attribute. Notice that self is the way a method reaches the data of its own object.

python
class Dog:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def bark(self):
        return f"{self.name} says woof"

    def birthday(self):
        self.age += 1

d = Dog("Rex", 3)
d.birthday()
print(d.age)
print(d.bark())
output
4
Rex says woof
Rule of thumb

If it holds a value, it is an attribute. If it is defined with def in the class and takes self, it is a method.

Bound methods and how self gets filled in

You never write d.bark(d), yet bark has a self parameter. The reason is that Python treats a function differently depending on how you reach it. When you look the function up through an instance, Python wraps it in a bound method: a small object that remembers the instance and the original function, and passes that instance as the first argument when it is called.

Looked up on the class instead, there is no instance to remember, so Dog.bark is just the plain function. It is not bound to anything, which means you have to supply self yourself.

Dog.bark (on the class)d.bark (on an instance)
TypePlain functionBound method
Knows its instanceNoYes, in __self__
Who supplies selfYou, as the first argumentPython, automatically
Call it likeDog.bark(d)d.bark()

The following example shows each of those facts. It prints only things that are the same on every run, because the memory addresses inside a printed method change from run to run and from machine to machine.

python
print(type(Dog.bark).__name__)
print(type(d.bark).__name__)
print(d.bark.__self__ is d)
print(d.bark.__func__ is Dog.bark)
print(Dog.bark(d))
print(d.bark() == Dog.bark(d))

Both spellings run the very same function

output
function
method
True
True
Rex says woof
True

The bound method keeps the instance in __self__ and the original function in __func__. Calling d.bark() is therefore just a shorter way to write Dog.bark(d).

Parentheses matter: running vs referring

Writing d.bark without parentheses does not run anything. It gives you the bound method object itself, which you can store in a variable, pass to another function, and call later. Only the parentheses in d.bark() actually execute the body.

python
ref = d.bark
print(callable(ref))
print(repr(ref).startswith("<bound method Dog.bark of"))
print(ref())

ref remembers d, so calling it later still works

output
True
True
Rex says woof

The printed form of a bound method contains the words bound method, the qualified name Dog.bark, and the instance it is attached to. Seeing text like that in your output is a strong hint that you forgot the parentheses.

Common mistake: forgetting the parentheses

print(d.bark) shows <bound method Dog.bark of ...> instead of running bark. The same slip hides in conditions: if d.bark: is always true because a method object is never empty. Add () when you want the work done.

Common mistake: adding parentheses to an attribute

d.age() fails with TypeError: 'int' object is not callable, because age holds a number, not a function. Parentheses belong only after things you can call.

Python also lets you attach new attributes to an instance after it has been created, because an instance keeps its attributes in its own dictionary. That is handy, but it means a typo in an attribute name quietly creates a new attribute instead of raising an error.

python
d.color = "brown"
print(d.color)
print(sorted(vars(d)))
other = Dog("Fido", 1)
print(hasattr(other, "color"))

The new attribute belongs to d only

output
brown
['age', 'color', 'name']
False
Keep attributes predictable

Create every attribute you rely on inside __init__. Instances then all have the same shape, and readers do not have to hunt for attributes that appear later.

Interview point

What is a bound method?
  • It is a method looked up on an instance, packaged with that instance.
  • It stores the instance in __self__ and the underlying function in __func__.
  • Calling it passes the stored instance as the first argument.
Why does d.bark() pass self automatically?
  • A function defined in a class is turned into a bound method whenever you look it up on an instance.
  • That bound method already holds d, so d.bark() runs as Dog.bark(d).
  • Looked up on the class, the same function stays plain and needs self passed by hand.
Dog.bark(d)   # same call, self passed by hand
d.bark()      # self filled in for you
Can you add attributes to an instance after creation?
  • Yes, assigning d.color = "brown" creates the attribute on that one instance.
  • Other instances of the class do not get it.
  • This is flexible but can hide typos, so define attributes in __init__ by habit.
d.color = "brown"
print(hasattr(Dog('Fido', 1), 'color'))  # False

Part 5 · Class vs Instance Attributes

Shared and Per-Object Data

A Python object can hold data in two places. A class attribute is a name you assign directly in the class body, so it belongs to the class itself and every instance can see the same one. An instance attribute is a name you set on self, usually inside __init__, so each object gets its own private copy.

Think of a class attribute as a sign on the wall of a kennel that every dog can read, and an instance attribute as the name tag on each dog's collar. In the example below, species is shared, while name differs per dog.

python
class Dog:
    species = "Canis familiaris"   # class attribute: one copy, shared

    def __init__(self, name):
        self.name = name            # instance attribute: one per object

rex = Dog("Rex")
fido = Dog("Fido")

print(rex.name, fido.name)
print(rex.species, fido.species)
print(rex.species is fido.species)
print(Dog.species)

species lives on the class; name lives on each instance

output
Rex Fido
Canis familiaris Canis familiaris
True
Canis familiaris
Class attributeInstance attribute
DefinedIn the class bodyOn self, usually in __init__
CopiesOne, shared by all instancesOne per instance
Reached throughDog.species or rex.speciesrex.name only
Good forConstants and shared defaultsData that differs per object

Notice that rex.species and fido.species are the very same object, which is why is reports True. Neither dog stores it. Both simply find it on the class.

How Python Finds an Attribute

When you write rex.species, Python does not look in just one place. It searches in a fixed order and stops at the first match. First it checks the instance's own __dict__, which holds the instance attributes. If the name is missing there, it checks the class. If it is still missing, it walks up through the parent classes. Only after all of that fails does it raise AttributeError.

Looking up rex.species
  1. 1Instance dictrex.dict holds name only
  2. 2ClassDog.dict has species
  3. 3Parent classeschecked only if still not found
  4. 4AttributeErrornothing matched

You can see this for yourself by printing the dictionaries. The instance dictionary contains only what was set on self, so species is absent from it even though rex.species works.

python
class Animal:
    legs = 4

class Dog(Animal):
    species = "Canis familiaris"

    def __init__(self, name):
        self.name = name

rex = Dog("Rex")
print(rex.__dict__)
print("species" in rex.__dict__)
print(rex.species)   # found on Dog
print(rex.legs)      # found on Animal, the parent

legs is found by walking up from Dog to Animal

output
{'name': 'Rex'}
False
Canis familiaris
4

Assigning Through an Instance Shadows the Class

Reading and writing are not symmetric. Reading d.count follows the lookup chain, but writing d.count = 5 always stores the value in the instance's __dict__. It never reaches up and changes the class. The new instance attribute then shadows the class one, because the instance is checked first. Other instances, and the class itself, are untouched.

python
class Dog:
    count = 0

d = Dog()
e = Dog()

d.count = 5          # creates an instance attribute on d

print(d.count)       # 5, found in d.__dict__ first
print(e.count)       # 0, still reading the class
print(Dog.count)     # 0, the class is unchanged
print(d.__dict__)

del d.count          # remove the shadow
print(d.count)       # 0 again, back to the class value

Deleting the instance attribute reveals the class attribute again

output
5
0
0
{'count': 5}
0
Common mistake: counting with self

Writing self.count += 1 to track how many objects exist does not update the shared counter. It reads Dog.count, adds one, then stores the result as a new instance attribute on self. To change the shared value, assign through the class: Dog.count += 1.

python
class Dog:
    count = 0

    def __init__(self):
        Dog.count += 1   # writes to the class, shared by everyone

Dog(); Dog(); Dog()
print(Dog.count)

Use the class name to update shared state

output
3

Interview Point: Mutable Class Attributes

What happens when you assign to a class attribute through an instance?
  • Python creates a new instance attribute with that name in the instance's __dict__.
  • It shadows the class attribute for that one object only.
  • The class attribute and all other instances are unchanged.
d.count = 5
print(Dog.count)  # still the old value
Why is a mutable class attribute like tags = [] dangerous?
  • Every instance shares the same single list, so mutating it from one object shows up in all of them.
  • Calling d.tags.append(...) does not assign, so no shadowing happens. It changes the shared list in place.
  • The fix is to create the list per object in __init__ with self.tags = [].
class Post:
    tags = []              # shared by every Post

a, b = Post(), Post()
a.tags.append("python")
print(b.tags)              # ['python']
python
class Safe:
    def __init__(self):
        self.tags = []     # a fresh list for each instance

a, b = Safe(), Safe()
a.tags.append("python")
print(a.tags, b.tags)

The corrected version

output
['python'] []
What is a good use of class attributes?
  • Constants and fixed configuration that all instances share, such as MAX_RETRIES = 3 or a default species name.
  • Immutable defaults like numbers, strings and tuples, which are safe because they cannot be changed in place.
  • Shared bookkeeping, such as an instance counter updated through the class name.
Rule of thumb

Put constants and immutable shared values on the class. Put anything mutable or specific to one object on self inside __init__.

Part 6 · Inheritance and super()

Inheriting and overriding

Sometimes a new class is just a more specific version of one you already have. A puppy is a dog, so it should do everything a dog does without you copying the code. Inheritance handles this: you name the existing class in parentheses, and the new class gets all of its attributes and methods.

The class you inherit from is the parent (or base class). The new class is the child (or subclass). In class Puppy(Dog), Dog is the parent and Puppy is the child. Python builds the child's __init__, sleep and speak from the parent unless the child says otherwise.

To change a behaviour, override it by defining a method with the same name in the child. When you call the method on a Puppy, Python finds the child's version first and uses it. Methods you don't redefine are still inherited.

python
class Dog:
    def __init__(self, name):
        self.name = name

    def speak(self):
        return f'{self.name} says woof'

    def sleep(self):
        return f'{self.name} is asleep'


class Puppy(Dog):
    def speak(self):
        return f'{self.name} says yip'


p = Puppy('Bit')
print(p.speak())
print(p.sleep())

Puppy defines no __init__ and no sleep, yet both work.

output
Bit says yip
Bit is asleep

speak came from Puppy because it was overridden. sleep and __init__ came from Dog, which is why Puppy('Bit') worked and set name.

Where Python looks for p.speak()
  1. 1Puppyhas speak? yes, use it
  2. 2Dogonly checked if Puppy lacks it
  3. 3objectthe base of every class

Running parent setup with super()

Overriding __init__ needs more care than overriding other methods. If a child defines its own __init__, Python uses only that one and never calls the parent's automatically. Anything the parent's __init__ would have set up is then missing.

The fix is super().__init__(...). The call super() gives you access to the parent's version of a method, so you can run the parent's setup first and then add what is specific to the child. The same trick works for any overridden method when you want to extend the parent's behaviour instead of replacing it.

python
class Puppy(Dog):
    def __init__(self, name, age_months):
        super().__init__(name)
        self.age_months = age_months

    def speak(self):
        return super().speak() + ' (squeaky)'


p = Puppy('Bit', 3)
print(p.name, p.age_months)
print(p.speak())

A fuller Puppy: parent setup, one new attribute, and an extended method.

output
Bit 3
Bit says woof (squeaky)

super().__init__(name) let Dog store name, and Puppy stored only age_months itself. In speak, super().speak() reused the dog's sentence and the puppy added to it.

Now see what happens when the call is skipped. The child's __init__ runs, but the parent's never does, so name is never created.

python
class BadPuppy(Dog):
    def __init__(self, name, age_months):
        self.age_months = age_months


b = BadPuppy('Bit', 3)
print(b.age_months)
try:
    print(b.name)
except AttributeError as e:
    print('AttributeError:', e)
output
3
AttributeError: 'BadPuppy' object has no attribute 'name'
Common mistake: forgetting super().init()

Creating the object works fine, so you see no error at first. The crash comes later, when some method such as speak reads self.name that was never set. If a child defines __init__, call super().__init__(...) with the arguments the parent needs, usually as the first line.

Checking types with isinstance and issubclass

Because a puppy is a dog, code that expects a Dog should accept a Puppy too. isinstance(obj, Class) asks whether an object is an instance of a class or of any class derived from it. issubclass(Child, Parent) asks the same question about two classes, with no object involved.

python
p = Puppy('Bit', 3)
d = Dog('Rex')

print(isinstance(p, Puppy))
print(isinstance(p, Dog))
print(isinstance(d, Puppy))
print(issubclass(Puppy, Dog))
print(issubclass(Dog, Puppy))
print(type(p) == Dog)
print(type(p) == Puppy)
output
True
True
False
True
False
False
True

The relationship only goes one way. A puppy counts as a dog, but a plain dog does not count as a puppy. The last two lines show that comparing type(p) with a class demands an exact match, which is much stricter than isinstance.

CheckTakesTrue for subclasses?Example
isinstance(x, Dog)an object and a classYesisinstance(p, Dog) is True
issubclass(A, Dog)two classesYesissubclass(Puppy, Dog) is True
type(x) == Dogan object and a classNo, exact class onlytype(p) == Dog is False
Common mistake: passing an object to issubclass

issubclass(p, Dog) raises a TypeError because p is an instance, not a class. Use isinstance(p, Dog) for objects and issubclass(Puppy, Dog) for classes.

Interview point: super() and type checks

What does super() return?
  • A proxy object that stands in for the parent part of the current object, not the parent class itself or a new instance.
  • When you call a method on it, Python looks for that method in the classes that come after the current class in the lookup order, and runs the first match with the same self.
  • That is why super().__init__(name) sets attributes on the object you are already building, instead of creating a second one.
class Puppy(Dog):
    def speak(self):
        return super().speak()  # Dog's speak, same self
What happens if a subclass forgets super().init()?
  • The parent's __init__ never runs, so the attributes it sets are never created.
  • Object creation succeeds silently; an AttributeError appears later when a method reads the missing attribute.
  • The exception is a child with no __init__ at all, which inherits the parent's automatically.
isinstance vs type(x) == Dog?
  • isinstance(x, Dog) is True for Dog and every subclass, so it respects inheritance.
  • type(x) == Dog is True only when the object's exact class is Dog, so a Puppy fails it.
  • Prefer isinstance in normal code; use type(x) == Dog only when you truly need the exact class.
Which check should I use?
Remember

A child inherits everything, overrides by redefining, and must call super().__init__(...) when it defines its own __init__. Use isinstance and issubclass to respect inheritance in your checks.

Part 7 · MRO and Multiple Inheritance

Inheriting from several classes

So far each class had one parent. Python also lets a class list several parents in its parentheses, as in class C(A, B). The new class gets the methods and attributes of all of them, which is handy when you want to mix small, separate abilities into one class.

The obvious question is what happens when two parents define a method with the same name. Python answers with a fixed lookup sequence called the method resolution order, or MRO. When you call obj.method(), Python walks the MRO from left to right and uses the first class that defines the name.

python
class Swimmer:
    def move(self):
        return 'swim'

class Walker:
    def move(self):
        return 'walk'

    def rest(self):
        return 'sit down'

class Duck(Swimmer, Walker):
    pass

class Robot(Walker, Swimmer):
    pass

d = Duck()
print(d.move())
print(d.rest())
print(Robot().move())

Both parents define move(); the order in the parentheses decides the winner.

output
swim
sit down
walk

Duck finds move in Swimmer first, so it swims. rest exists only in Walker, so the search continues past Swimmer and finds it there. Robot lists the same parents in the opposite order, so it walks. The left-to-right order you write is part of the class design.

Looking up d.move() on a Duck
  1. 1Duckno move() here
  2. 2Swimmermove() found, stop
  3. 3Walkernever reached
  4. 4objectnever reached

Seeing the MRO yourself

You do not have to guess the order. Every class stores it in __mro__, a tuple, and ClassName.mro() returns the same thing as a list. Python builds it with an algorithm called C3 linearization, which keeps each class before its parents and keeps the order of the bases you wrote. Every class ends with object.

python
print([c.__name__ for c in Duck.mro()])
print([c.__name__ for c in Robot.__mro__])
output
['Duck', 'Swimmer', 'Walker', 'object']
['Robot', 'Walker', 'Swimmer', 'object']
C.__mro__C.mro()
ReturnsA tuple of classesA list of classes
Typical useQuick look in a script or REPLWhen you want to loop or slice the result
Starts withThe class itselfThe class itself
Ends withobjectobject

The diamond problem and super()

The trickiest shape in multiple inheritance is the diamond. Two classes inherit from the same base, and a fourth class inherits from both of those. Drawn out, the four classes form a diamond with the shared base at the top.

The problem is that Bottom reaches Base along two paths. If each middle class calls Base.__init__(self) by name, the base setup runs twice. That can mean duplicate work, double-registered resources or a counter that goes up twice. Here is that naive version.

python
class Base:
    def __init__(self):
        print('Base init')

class Left2(Base):
    def __init__(self):
        print('Left2 init')
        Base.__init__(self)

class Right2(Base):
    def __init__(self):
        print('Right2 init')
        Base.__init__(self)

class Bottom2(Left2, Right2):
    def __init__(self):
        print('Bottom2 init')
        Left2.__init__(self)
        Right2.__init__(self)

Bottom2()

Calling parents by name: Base runs twice.

output
Bottom2 init
Left2 init
Base init
Right2 init
Base init

The fix is super(). It does not mean "my one parent". It means "the next class after me in the MRO of the object I am working on". Since the MRO lists each class exactly once, a chain of super() calls visits every class once and Base runs a single time.

python
class Left(Base):
    def __init__(self):
        print('Left init')
        super().__init__()

class Right(Base):
    def __init__(self):
        print('Right init')
        super().__init__()

class Bottom(Left, Right):
    def __init__(self):
        print('Bottom init')
        super().__init__()

Bottom()
print([c.__name__ for c in Bottom.mro()])
output
Bottom init
Left init
Right init
Base init
['Bottom', 'Left', 'Right', 'Base', 'object']

Look at what happened inside Left. Its super().__init__() ran Right.__init__, not Base.__init__, because Right comes next in the MRO of a Bottom object. Right then passed control on to Base. The call chain follows the MRO, so every class runs once and Base runs last.

super() hops along the MRO of Bottom
  1. 1Bottomsuper() goes to Left
  2. 2Leftsuper() goes to Right
  3. 3Rightsuper() goes to Base
  4. 4Baseruns once, ends the chain
Direct call Base.__init__(self)super().__init__()
Who runs nextThe class you namedThe next class in the MRO
Diamond resultShared base can run twiceEach class runs once
Adding a new parent laterYou must edit the callsFollows the MRO automatically
Common mistake: assuming super() means the direct parent

Inside Left, super() does not always reach Base. Which class it reaches depends on the type of the object, not on where the code is written. Used alone, Left has Base next, but inside a Bottom object it has Right next. Every class in the chain should call super() so none gets skipped.

Interview point: MRO, diamonds and super()

What is the MRO and how do you see it?
  • The method resolution order is the list of classes Python searches, in order, when you look up an attribute or method.
  • It is computed with C3 linearization: children before parents, and the left-to-right order of the bases is kept.
  • View it with C.__mro__ (a tuple) or C.mro() (a list). It always ends with object.
class A: pass
class B: pass
class C(A, B): pass
print([k.__name__ for k in C.mro()])
What is the diamond problem?
  • It happens when a class inherits from two classes that share a common base, so the base can be reached by two paths.
  • Without care, the base's code runs twice or the choice of which method wins becomes ambiguous.
  • Python settles the ambiguity with the MRO, and super() makes each class run once.
Why does super() not always mean the parent?
  • super() returns the next class in the MRO of the instance's type, not the class written in the class line.
  • In a single-inheritance chain that next class happens to be the parent.
  • In a diamond it can be a sibling, such as Left calling into Right, which is what lets every class run exactly once.
Remember

Order in the parentheses matters: class C(A, B) searches A before B. Check the order with C.mro(), and use super() everywhere in a multiple-inheritance chain so the MRO is followed end to end.

Common mistake: an impossible order

Python raises a TypeError if no consistent MRO exists. For example class D(object, A) fails because object would have to come before its own subclass A. List the more specific class first and object last, or just leave object out.

Part 8 · Dunder Methods: __repr__, __eq__, __len__

repr and str: how an object shows itself

Methods whose names start and end with double underscores are called dunder methods. You almost never call them directly. Python calls them for you when you use built-in syntax such as print(), == or len(). By defining them, you teach your own class to behave like a built-in type.

The first pair controls how an object turns into text. __repr__ is meant for developers: it should be unambiguous and, ideally, look like the code that would rebuild the object. __str__ is meant for readers: it should be friendly and short. print() and str() use __str__, while the REPL, debuggers and repr() use __repr__.

Without either method, Python prints something like <__main__.Point object at 0x...>, which tells you nothing about the data inside. Here a class defines only __repr__:

python
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"Point({self.x}, {self.y})"

p = Point(2, 3)
print(p)
print([p])
print(str(p), repr(p))

Only __repr__ is defined, and print still uses it

output
Point(2, 3)
[Point(2, 3)]
Point(2, 3) Point(2, 3)

Notice that print(p) worked even though we never wrote __str__. When __str__ is missing, Python falls back to __repr__. The reverse is not true: a missing __repr__ does not borrow from __str__. Containers such as lists always use repr() for the items inside them, which is why the list above shows Point(2, 3).

When you define both, each does its own job:

python
class Temp:
    def __init__(self, deg):
        self.deg = deg

    def __repr__(self):
        return f"Temp({self.deg})"

    def __str__(self):
        return f"{self.deg} degrees"

t = Temp(21)
print(t)
print(repr(t))
print([t])
print(f"{t!r} and {t}")
output
21 degrees
Temp(21)
[Temp(21)]
Temp(21) and 21 degrees
reprstr
AudienceDevelopers, logs, debuggingEnd users, readable output
Used byrepr(), REPL, items inside listsprint(), str(), f-strings
GoalUnambiguous, often rebuildableReadable and friendly
If missingDefault <object at 0x...> textFalls back to __repr__
If you write only one

Write __repr__. You get a useful print output for free through the fallback, and your debugging output improves everywhere.

eq and hashing: when two objects count as equal

The == operator calls __eq__. If your class does not define it, Python inherits the default from object, which compares identity: two names are equal only if they point to the very same object. So two objects holding identical data are still unequal.

python
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

a = Point(1, 2)
b = Point(1, 2)
print(a == b, a is b)
print(a == a)

No __eq__ yet, so == falls back to identity

output
False False
True

To say that equal-looking points should be equal, define __eq__. It receives the other object. If that object is not something you know how to compare, return NotImplemented instead of False, so Python can try the other side or fall back gracefully.

python
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __eq__(self, other):
        if not isinstance(other, Point):
            return NotImplemented
        return (self.x, self.y) == (other.x, other.y)

a = Point(1, 2)
b = Point(1, 2)
print(a == b, a is b)
print(a == (1, 2))
print(a != Point(5, 5))
output
True False
False
True

== now compares values, while is still asks whether both names refer to one object. Python derives != from __eq__ automatically, so you do not need to write it.

==is
QuestionDo the values count as equal?Is it the exact same object?
Calls__eq__, which you can customiseNothing; it cannot be overridden
Default for your classSame as isCompares identity
Typical useComparing dataChecking for None: x is None

The catch: defining eq removes hashing

Sets and dict keys rely on __hash__, and the rule is that objects which are equal must have the same hash. So when you define __eq__ without __hash__, Python sets __hash__ to None and the class becomes unhashable. The class above cannot go into a set or be used as a dict key:

python
try:
    {a, b}
except TypeError as e:
    print(e)

Uses the Point with __eq__ but no __hash__

output
unhashable type: 'Point'

The fix is to add a __hash__ built from the same fields that __eq__ compares. Hashing a tuple of those fields is the usual way:

python
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __eq__(self, other):
        if not isinstance(other, Point):
            return NotImplemented
        return (self.x, self.y) == (other.x, other.y)

    def __hash__(self):
        return hash((self.x, self.y))

a = Point(1, 2)
b = Point(1, 2)
print(len({a, b}))
prices = {a: "first"}
print(prices[b])
output
1
first
Common mistake

Defining __eq__ and forgetting __hash__. Your objects then raise TypeError: unhashable type the first time they meet a set or a dict key. Also keep the hash fields stable: if you mutate x after using the object as a key, lookups can fail.

len and the interview recap

The built-in len(obj) simply calls obj.__len__(). Any class that defines it works with len(). As a bonus, Python uses __len__ for truth testing when there is no __bool__: an object whose length is 0 counts as false in an if.

python
class Playlist:
    def __init__(self, songs):
        self.songs = list(songs)

    def __len__(self):
        return len(self.songs)

    def __repr__(self):
        return f"Playlist({self.songs!r})"

mix = Playlist(["intro", "verse", "outro"])
empty = Playlist([])
print(len(mix))
print(mix)
print(bool(mix), bool(empty))
output
3
Playlist(['intro', 'verse', 'outro'])
True False
What happens on len(mix)
  1. 1You write len(mix)built-in function
  2. 2Python looks up len on the classnot on the instance
  3. 3mix.len() runsyour code
  4. 4The integer comes backmust be 0 or more
Interview Point

Four questions come up again and again. Have these answers ready.

What is the difference between repr and str?
  • __repr__ is for developers: unambiguous, ideally rebuilds the object.
  • __str__ is for readers and is what print() uses.
  • If __str__ is missing, Python falls back to __repr__; the reverse does not happen.
print(Temp(21))        # 21 degrees
print(repr(Temp(21)))  # Temp(21)
What does len(obj) call?
  • It calls obj.__len__() and returns the result.
  • The result must be a non-negative integer.
How do == and is differ?
  • == calls __eq__ and compares values; you can customise it.
  • is compares identity and cannot be overridden.
  • Without __eq__, == behaves exactly like is.
What does defining eq do to hashing?
  • Python sets __hash__ to None, so instances become unhashable.
  • They cannot go in sets or serve as dict keys until you define __hash__.
  • Equal objects must have equal hashes, so hash the same fields __eq__ compares.

Part 9 · @property

A method that reads like an attribute

Some values are best computed from other data instead of stored. A rectangle's area is always width times height, so keeping a separate area field means you must remember to update it whenever the size changes. The @property decorator solves this: you write an ordinary method, and Python lets callers read it as if it were a plain attribute, with no parentheses.

python
class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    @property
    def area(self):
        return self.width * self.height

r = Rectangle(3, 4)
print(r.area)
r.width = 5
print(r.area)
try:
    r.area = 99
except AttributeError:
    print('area is read-only')

area is recomputed on every read, so it never goes stale

output
12
20
area is read-only

Two things happened here. Reading r.area ran the method and returned its result, and after width changed the next read gave the new answer. Trying to assign to r.area failed with AttributeError, because a property with only a getter has no way to accept a value. That is how you get a read-only attribute in Python.

Remember

Put @property above a method and call it without parentheses: r.area, not r.area(). With no setter defined, assigning to it raises AttributeError.

Validating with a setter

A read-only property is only half the feature. If you also want assignments to be checked, add a method named the same as the property and decorate it with @name.setter. Python calls that method every time someone writes obj.name = value, so it is the right place to reject bad input by raising ValueError.

When the value is fine, the setter stores it. Where it stores it matters: the real data must live under a different name from the property, by convention the same name with a leading underscore. The property price is the public door, and self._price is the room behind it.

python
class Product:
    def __init__(self, name, price):
        self.name = name
        self.price = price  # goes through the setter

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if value < 0:
            raise ValueError('price cannot be negative')
        self._price = value

p = Product('pen', 10)
p.price = 12
print(p.price)
try:
    p.price = -5
except ValueError as e:
    print('ValueError:', e)
try:
    Product('bad', -1)
except ValueError as e:
    print('ValueError:', e)
print(p.price)

the check also runs inside __init__, because it assigns through self.price

output
12
ValueError: price cannot be negative
ValueError: price cannot be negative
12

Notice that __init__ assigns to self.price, not self._price. That sends the starting value through the setter too, so an object can never be created with an invalid price. After the failed assignment, the old value of 12 is still there.

Common mistake: the setter calls itself

Inside the property, writing self.price = value calls the setter again, which assigns again, forever. Python stops it with RecursionError. Always store into self._price, never into the property's own name.

python
class Broken:
    def __init__(self, v):
        self.v = v

    @property
    def v(self):
        return self.v  # reads itself

    @v.setter
    def v(self, value):
        self.v = value  # assigns itself

try:
    Broken(1)
except RecursionError:
    print('RecursionError: setter called itself')

the fix is self._v in both the getter and the setter

output
RecursionError: setter called itself

Interview point: why properties?

Interviewers like this topic because it tests whether you understand Python's attitude to encapsulation. Java-style code writes getPrice() and setPrice() from day one. Python code starts with a plain attribute and upgrades it to a property only when logic is needed.

Getter/setter methods@property
Readingp.get_price()p.price
Writingp.set_price(5)p.price = 5
ValidationYesYes, in the setter
Read-onlyLeave out the setter methodLeave out @name.setter
Adding it laterEvery caller must changeNo caller changes
Why use a property instead of a getter method?
  • Callers use plain attribute syntax, which is shorter and reads naturally.
  • You still get a function underneath, so you can validate, compute or log.
  • It is the Pythonic default: no boilerplate getters on classes that do not need them.
How do you make an attribute read-only?
  • Define it with @property and do not define a setter.
  • Assigning to it raises AttributeError.
  • The value is usually computed, or stored privately as self._name.
Why can properties be added later without breaking callers?
  • A plain attribute and a property are used with identical syntax: obj.email and obj.email = x.
  • So you can begin with a simple attribute and swap in a property when rules appear.
  • Existing code that reads or writes the attribute keeps working, and now gets the new checks.
class User:
    def __init__(self, email):
        self.email = email

    @property
    def email(self):
        return self._email

    @email.setter
    def email(self, value):
        if '@' not in value:
            raise ValueError('invalid email')
        self._email = value.lower()

u = User('Sam@Example.com')
print(u.email)
u.email = 'SAM@Work.com'
print(u.email)
Say this in the interview

Start with a public attribute. When you need validation or a computed value, turn it into a property. The callers never notice, which is why Python does not need getters and setters up front.

Part 10 · Dataclasses

Let the decorator write the boilerplate

Many classes exist only to hold a few values. You write __init__ to store them, __repr__ so printing is readable, and __eq__ so two objects with the same values compare equal. That is the same code every time. The @dataclass decorator from the dataclasses module writes it for you.

It reads the annotated fields in the class body, in order, and builds the methods from them. A field with a value after it, like y: int = 0, becomes an optional argument. Only names with an annotation count as fields.

What @dataclass does when the class is created
  1. 1Read annotationsx: int, y: int = 0
  2. 2Build methodsinit, repr, eq
  3. 3Attach to classyou write none of them
python
from dataclasses import dataclass

@dataclass
class Point:
    x: int
    y: int = 0

p = Point(3)
print(p)
print(p == Point(3, 0))
print(p is Point(3, 0))

__init__ takes x and y, __repr__ shows the fields, __eq__ compares them

output
Point(x=3, y=0)
True
False

The repr shows every field by name, so debugging output is useful for free. == compares the fields as a tuple, so two different objects with equal values are equal. is still asks whether they are the same object, and here they are not.

Generated methodWhat it gives youWithout @dataclass
__init__Point(3) assigns x and yYou write every assignment by hand
__repr__Point(x=3, y=0)Default text like <Point object at 0x...>
__eq__Equal when all fields are equalEqual only if it is the same object
Common mistake: a field without an annotation

Writing y = 0 instead of y: int = 0 makes a plain class attribute, not a field. It will not appear in __init__ or in the repr. Annotate every field.

Mutable defaults need default_factory

A default value in a class body is created once and shared. For an int or a string that is harmless, because you cannot change it in place. A list or dict is different: if every instance shared one list, appending to it in one object would change it in all of them. This is the same trap as mutable default arguments in functions.

Dataclasses refuse to let you fall into it. A plain tags: list = [] raises a ValueError the moment the class is defined, not later when the bug would show up.

python
from dataclasses import dataclass, field

try:
    @dataclass
    class Bad:
        tags: list = []
except ValueError as e:
    print(e)

The error happens at class creation

output
mutable default <class 'list'> for field tags is not allowed: use default_factory

The fix is field(default_factory=list). Instead of one stored list, you give a factory, a function with no arguments that the generated __init__ calls each time an object is made without that argument. Every instance gets its own fresh list.

python
@dataclass
class Post:
    title: str
    tags: list[str] = field(default_factory=list)

a = Post('A')
b = Post('B')
a.tags.append('python')
print(a.tags, b.tags)

Each Post builds its own list

output
['python'] []
Common mistake: default_factory=[]

The argument must be the callable list, not a list value. Pass list, dict, set, or your own function or lambda. Do not call it yourself with list().

Frozen, hashable and ordered

The decorator takes options. frozen=True makes instances immutable: assigning to a field afterwards raises FrozenInstanceError. Because eq is on and the fields can no longer change, Python also generates __hash__, so frozen instances can be dict keys or set members. A normal dataclass is not hashable, because it has __eq__ but its values can change.

order=True adds <, <=, > and >=. They compare the fields as tuples in the order you declared them, so put the most important field first.

python
@dataclass(frozen=True, order=True)
class Version:
    major: int
    minor: int

v1 = Version(1, 2)
v2 = Version(1, 10)
print(v1 < v2)
print(sorted([v2, v1]))
print({v1: 'old'}[Version(1, 2)])
try:
    v1.minor = 5
except Exception as e:
    print(type(e).__name__, e)

Comparison, sorting, use as a key, and blocked assignment

output
True
[Version(major=1, minor=2), Version(major=1, minor=10)]
old
FrozenInstanceError cannot assign to field 'minor'

Note that 1.2 < 1.10 works here because the comparison is on the integer pair (1, 2) against (1, 10), not on text.

OptionEffectTypical use
@dataclass__init__, __repr__, __eq__Mutable records
frozen=TrueAssignment raises error, __hash__ addedDict keys, shared constants
order=TrueAdds <, <=, >, >= by field orderSorting records
Remember

Annotated fields drive everything. Mutable defaults need default_factory. Add frozen=True for safety and hashing, and order=True when you need sorting.

Interview point: dataclasses in questions

What methods does @dataclass generate?
  • By default __init__, __repr__ and __eq__, built from the annotated fields.
  • order=True adds __lt__, __le__, __gt__ and __ge__.
  • frozen=True makes fields read-only and, with eq, adds __hash__.
Why is default_factory needed?
  • A class-level default is created once, so a list would be shared by all instances.
  • @dataclass raises ValueError for list, dict and set defaults to stop that.
  • field(default_factory=list) calls list() for each new object, so each gets its own.
tags: list[str] = field(default_factory=list)
Dataclass vs namedtuple vs plain class?
  • Dataclass: a normal class with named fields, mutable by default, supports defaults, factories, methods and inheritance.
  • namedtuple: immutable tuple, so it can be unpacked and indexed, and is small, but has no real type annotations.
  • Plain class: full control, but you write __init__, __repr__ and __eq__ yourself.
DataclassnamedtuplePlain class
BoilerplateGeneratedGeneratedWritten by hand
MutableYes, unless frozen=TrueNoYes
Unpack or indexNoYesNo
Mutable defaultsdefault_factoryNot supported directlyHandle in __init__
HashableOnly if frozenYesBy identity
How to choose

Use a dataclass for a record with behaviour or changing values. Use a frozen dataclass or a namedtuple for a fixed value. Use a plain class when you need custom construction logic.

Part 11 · Composition vs Inheritance

Is-a or has-a?

Once you know how to write a subclass, it is tempting to use inheritance every time two classes are related. A better habit is to ask what the relationship actually is. If one thing is a kind of another, inheritance fits. If one thing has another thing inside it, you want composition.

InheritanceComposition
Relationshipis-a: a Dog is an Animalhas-a: a Car has an Engine
How it is writtenclass Dog(Animal)self.engine = engine
What you getEvery attribute and method of the parent, automaticallyOnly what you choose to forward
CouplingTight: the child depends on the parent's internalsLoose: depends only on the part's public methods

Here is a genuine is-a relationship. A Dog really is an Animal, so it inherits name and eat() and only adds what is special to dogs.

python
class Animal:
    def __init__(self, name):
        self.name = name

    def eat(self):
        return f"{self.name} eats"

class Dog(Animal):
    def bark(self):
        return f"{self.name} says woof"

d = Dog("Rex")
print(d.eat())
print(d.bark())
print(isinstance(d, Animal))
output
Rex eats
Rex says woof
True

A car is not a kind of engine, so inheritance would be wrong there. With composition, the Car stores an Engine object as an attribute. When the car is asked to start, it delegates the real work to that object by calling self.engine.start().

python
class Engine:
    def __init__(self, hp):
        self.hp = hp

    def start(self):
        return f"Engine ({self.hp} hp) running"

class Car:
    def __init__(self, engine):
        self.engine = engine

    def start(self):
        return "Car: " + self.engine.start()

car = Car(Engine(120))
print(car.start())
print(isinstance(car, Engine))

The Car holds an Engine; it is not one.

output
Car: Engine (120 hp) running
False
A quick test

Say the sentence out loud: "A Dog is an Animal" sounds right, "A Car is an Engine" sounds wrong. "A Car has an Engine" sounds right. If only the has-a sentence works, use composition.

Why composition bends better

The practical difference shows up when requirements change. Every level in an inheritance tree is tied to the level above it, so a change near the top can ripple down through every subclass. Consider a small tower where each class builds on its parent with super().

python
class Vehicle:
    def start(self):
        return "vehicle ready"

class MotorVehicle(Vehicle):
    def start(self):
        return super().start() + " > fuel pumped"

class Truck(MotorVehicle):
    def start(self):
        return super().start() + " > key turned"

print(Truck().start())
output
vehicle ready > fuel pumped > key turned

Now imagine you need an electric truck. It has no fuel to pump, but Truck inherits that step from MotorVehicle and cannot simply opt out. You end up overriding methods, adding flags, or rebuilding the tree. The subclass is coupled to every decision made above it.

Inheritance: the child is stuck with the whole chain
  1. 1Vehiclebase behaviour
  2. 2MotorVehicleadds fuel
  3. 3Truckinherits both layers

Composition avoids this because the Car only knows that its engine has a start() method. Any object with that method will do, and since the engine is just an attribute, you can replace it while the program is running. This is the same car from the previous page.

python
class ElectricMotor:
    def start(self):
        return "Motor hums silently"

car.engine = ElectricMotor()
print(car.start())

Swapped at runtime, and Car itself did not change.

output
Car: Motor hums silently
QuestionDeep inheritanceComposition
Change one behaviourEdit or override somewhere in the chainPass in a different part
Swap at runtimeNot possible; the class is fixed at creationReassign the attribute
Test in isolationNeeds the whole parent chainGive it a simple stand-in part

Interview point: favor composition

Interviewers often ask you to explain the rule "favor composition over inheritance". It does not mean inheritance is bad. It means that when both options would work, composition is usually the safer default, because it creates fewer hidden dependencies.

Which one should I use?

A classic misuse is a Stack that inherits from list just to reuse append and pop. A stack is not a list, because a stack must only allow changes at one end. The inherited class still exposes insert, so anyone can break the rule. The composed version hides the list and exposes only what a stack should offer.

python
class Stack(list):
    def push(self, item):
        self.append(item)

s = Stack()
s.push(1)
s.push(2)
s.insert(0, 99)
print("inherited:", s)

class SafeStack:
    def __init__(self):
        self._items = []

    def push(self, item):
        self._items.append(item)

    def pop(self):
        return self._items.pop()

    def __len__(self):
        return len(self._items)

t = SafeStack()
t.push(1)
t.push(2)
print("composed:", t.pop(), len(t), hasattr(t, "insert"))
output
inherited: [99, 1, 2]
composed: 2 1 False
Common mistake

Inheriting just to reuse code. If the only reason for class B(A) is that A has a handy method, B probably is not an A. Hold an A instead and call it.

When is inheritance the right choice?
  • When the relationship is truly is-a, and every parent method makes sense for the child (the Liskov idea: a child can stand in anywhere the parent is used).
  • When you want polymorphism: code that accepts any Animal and calls eat() works with every subclass.
  • When extending a base class designed for it, such as Exception for custom errors.
  • Keep the tree shallow, usually one or two levels.
Remember

Inheritance says is-a, composition says has-a and delegates. Prefer composition when you only want to reuse behaviour or need to swap parts, and save inheritance for real, shallow is-a hierarchies.

Part 12 · Common OOP Mistakes

Missing self and shared defaults

Most beginner bugs in Python classes come from a few repeated slips. The first is the easiest to make: leaving self out of a method definition. When you call obj.method(), Python quietly passes the object as the first argument. If the method has no parameter to receive it, the call fails before your code even runs.

python
class Dog:
    def bark():
        return "Woof"

try:
    Dog().bark()
except TypeError as e:
    print(e)

The method takes zero parameters, but Python passes one

output
Dog.bark() takes 0 positional arguments but 1 was given

The message is confusing because you passed no arguments. The one that was "given" is the instance itself. The fix is to add self as the first parameter, and to use self.name whenever the method needs data stored on the object.

python
class Dog:
    def bark(self):
        return "Woof"

print(Dog().bark())

With self, the instance has somewhere to land

output
Woof
Common mistake

Forgetting self in the signature gives TypeError: takes 0 positional arguments but 1 was given. The count in the message is always one more than what you typed, so read it as a hint that self is missing.

Mutable default arguments

The second trap is a default value that is a list, dict or set. Python builds a default once, when the def line runs, not each time you call the function. Every call that skips the argument receives that same object, so one instance can change what another instance sees.

python
class Basket:
    def __init__(self, items=[]):
        self.items = items

    def add(self, x):
        self.items.append(x)

a = Basket()
b = Basket()
a.add("apple")
print(b.items)
print(a.items is b.items)
print(Basket.__init__.__defaults__)

b never received an apple, yet it has one

output
['apple']
True
(['apple'],)

The last line shows where the list lives: inside the function's __defaults__, shared by every call. The standard fix is to use None as the default and create a fresh list inside the body.

python
class Basket:
    def __init__(self, items=None):
        self.items = [] if items is None else list(items)

    def add(self, x):
        self.items.append(x)

a = Basket()
b = Basket()
a.add("apple")
print(b.items)
print(a.items is b.items)

A new list is built on every call

output
[]
False

Shared class attributes, super() and over-using classes

The same sharing problem appears with class attributes. A list written directly in the class body belongs to the class, and every instance reads it through the class unless it has its own attribute of the same name. Mutating it with append changes the one shared object.

python
class Player:
    tags = []

    def __init__(self, name):
        self.name = name

p1 = Player("Ana")
p2 = Player("Bo")
p1.tags.append("admin")
print(p2.tags)
print(Player.tags is p2.tags)

One list on the class, seen by everyone

output
['admin']
True

Per-object data belongs in __init__, assigned to self. Keep class attributes for values that really are shared and are not changed in place, such as constants or counters you update on the class itself.

python
class Player:
    def __init__(self, name):
        self.name = name
        self.tags = []

p1, p2 = Player("Ana"), Player("Bo")
p1.tags.append("admin")
print(p2.tags)

Each player now owns its list

output
[]
Mutable default argumentMutable class attribute
Where it livesIn the function's __defaults__In the class body
CreatedOnce, when def runsOnce, when the class is built
Shared byEvery call that skips the argumentEvery instance without its own copy
FixUse None, build the object insideAssign self.x = [] in __init__

Forgetting super().init()

When a child class defines its own __init__, it replaces the parent's one completely. The parent's setup code never runs unless you call it, so attributes the parent would have created simply do not exist.

python
class Animal:
    def __init__(self, name):
        self.name = name

class Cat(Animal):
    def __init__(self, name, indoor):
        self.indoor = indoor

c = Cat("Tom", True)
try:
    print(c.name)
except AttributeError as e:
    print(e)

Animal.__init__ was never called

output
'Cat' object has no attribute 'name'
python
class Cat(Animal):
    def __init__(self, name, indoor):
        super().__init__(name)
        self.indoor = indoor

c = Cat("Tom", True)
print(c.name, c.indoor)

Call the parent first, then add your own attributes

output
Tom True
Common mistake

Overriding __init__ in a subclass without calling super().__init__(...) leaves the parent's attributes missing, and the error only shows up later as an AttributeError.

Do you need a class at all?

Another common mistake is writing a class that has no real state or behaviour to bundle. If a class has only __init__ and one method, a plain function is shorter. If it only holds a few fields, a dataclass writes the boilerplate for you.

python
from dataclasses import dataclass

def apply_tax(price, rate):
    return round(price * (1 + rate), 2)

@dataclass
class Point:
    x: int
    y: int

print(apply_tax(100, 0.18))
print(Point(1, 2))

A function for an action, a dataclass for plain data

output
118.0
Point(x=1, y=2)
Which one should I write?

Interview point: the questions behind these bugs

Interviewers like these mistakes because each one tests whether you understand when Python runs things. Here are the three questions that come up most often, with answers you can say out loud.

Why is a mutable default argument a bug?
  • The default is evaluated once, when the function is defined, not on each call.
  • Every call that omits the argument gets the same list or dict object.
  • Changes made in one call are therefore visible in later calls.
  • Fix: default to None and create the new object inside the function.
def add(x, items=None):
    if items is None:
        items = []
    items.append(x)
    return items
Why does changing one instance affect the others?
  • The attribute is probably defined on the class, or came from a shared default, so all instances point to one object.
  • obj.attr.append(...) mutates that shared object, while obj.attr = ... creates a separate attribute on that instance.
  • Check with a.items is b.items, and fix by assigning self.items = [] in __init__.
How do you debug an AttributeError on self?
  • Read the name in the message: is it a typo, or an attribute that was never set?
  • Look at what the object really holds with vars(self) or dir(self).
  • Check that __init__ assigns it on every path, and that subclasses call super().__init__().
  • Check you wrote self.x and not a bare x, which would be a local variable.
python
class Cart:
    def __init__(self):
        self.items = []

c = Cart()
print(vars(c))
print(hasattr(c, "total"))
try:
    c.total
except AttributeError as e:
    print(e)

vars() shows exactly which attributes exist

output
{'items': []}
False
'Cart' object has no attribute 'total'
Remember

Defaults and class bodies run once; __init__ runs for every object. Anything that must belong to a single instance should be created inside __init__ and stored on self.

Part 13 · Python OOP Cheat Sheet

The syntax at a glance

Almost every Python class is built from the same few pieces. The table below lists each one with what it does, so you can use it as a lookup when you are writing or reading a class.

PieceWhat it doesTiny example
classDefines a new type, the blueprint for objectsclass Puppy:
__init__(self)Runs when an object is created and sets up its starting statedef __init__(self, name):
self.attrStores a value on this one instanceself.name = name
super().__init__()Calls the parent class setup so inherited attributes get setsuper().__init__(name)
@propertyMakes a method read like an attribute, so a setter can validatedef age(self):
@dataclassGenerates __init__, __repr__ and __eq__ for a plain data holder@dataclass class Toy:

The program below uses most of these pieces together. Puppy inherits from Animal, calls super().__init__() to set the name, and keeps its age behind a @property whose setter rejects negative values. @dataclass is the one piece not shown here, because the class below is not plain data: it has behavior and a rule to enforce.

python
class Animal:
    def __init__(self, name):
        self.name = name

class Puppy(Animal):
    def __init__(self, name, toy):
        super().__init__(name)
        self.toy = toy
        self._age = 1

    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, value):
        if value < 0:
            raise ValueError('age cannot be negative')
        self._age = value

    def speak(self):
        return f'{self.name} says woof'

p = Puppy('Rex', 'ball')
print(p.speak())
print(p.age, p.toy)
try:
    p.age = -1
except ValueError as e:
    print('error:', e)

Inheritance, super() and a validating property in one class

output
Rex says woof
1 ball
error: age cannot be negative
Common mistake: forgetting super().init()

If a child class defines its own __init__ and never calls super().__init__(), the parent's attributes such as self.name are never created. The first access fails with an AttributeError.

Dunder methods you will use most

Dunder methods (short for double underscore) are hooks Python calls for you when an object meets built-in syntax. You rarely call them directly. Instead, print(obj) calls __str__, obj == other calls __eq__, and so on. The five below cover most day-to-day needs.

MethodTriggered byPurpose
__repr__repr(obj), the REPL, debuggers, lists of objectsA debug string for developers, ideally one that looks like the code that rebuilds the object
__str__print(obj), str(obj), f-stringsA friendly string for users; falls back to __repr__ if missing
__eq__a == bDecides when two different objects count as equal; the default compares identity
__len__len(obj)Reports how many items the object holds; also makes an empty object falsy
__hash__hash(obj), using the object in a set or as a dict keyReturns an integer so the object can be stored in hash-based containers

Hashing deserves a closer look. Sets and dict keys find items by hash first and then confirm with ==. That is why the two methods must agree: if two objects are equal, they must produce the same hash. In the example below, two separate Box objects with the same contents are equal, hash the same, and collapse into one set entry.

python
class Box:
    def __init__(self, items):
        self.items = list(items)

    def __repr__(self):
        return f'Box({self.items!r})'

    def __str__(self):
        return f'a box with {len(self.items)} items'

    def __eq__(self, other):
        return isinstance(other, Box) and self.items == other.items

    def __len__(self):
        return len(self.items)

    def __hash__(self):
        return hash(tuple(self.items))

a = Box([1, 2])
b = Box([1, 2])
print(repr(a))
print(a)
print(a == b)
print(len(a))
print(len({a, b}))

All five dunders on one small class

output
Box([1, 2])
a box with 2 items
True
2
1
Common mistake: eq without hash

When you define __eq__ on a class, Python sets __hash__ to None, so the object can no longer go in a set or be a dict key. Define __hash__ too, and only hash values that will not change while the object sits in a set.

Decision rules and interview recap

Most design choices in Python OOP come down to four short rules. Match your situation to the left column and use the tool on the right.

SituationReach forWhy
It is a kind of the parent (a Puppy is an Animal)InheritIs-a means the child can stand in anywhere the parent is expected
It has or uses another object (a Car has an Engine)ComposeHas-a means store the other object as an attribute and delegate to it
It is plain data with little behavior@dataclassYou get __init__, __repr__ and __eq__ without writing them
A value must obey a rule when it is set@propertyThe setter can validate every assignment while callers still write obj.attr
Choosing a design
Common mistake: inheriting just to reuse code

If the honest sentence is 'a Stack has a list' rather than 'a Stack is a list', compose. Inheriting for convenience exposes methods that break your class's rules.

Interview Point

Summarize class vs instance attributes in one sentence.
  • A class attribute is defined once on the class and shared by every instance, while an instance attribute is set on self and belongs to that one object.
When do you pick a dataclass?
  • When the class mainly holds data and you would otherwise write a repetitive __init__, __repr__ and __eq__ by hand.
  • Add frozen=True if you also want it immutable and hashable.
from dataclasses import dataclass

@dataclass
class Point:
    x: int
    y: int
Name two dunder methods and their use.
  • __repr__ returns an unambiguous debug string for developers.
  • __eq__ defines what == means for your objects, and it needs a matching __hash__ if the object goes in a set or dict.

Part 14 · Check yourself

Quiz

Try to answer each question in your head before you open the answer. They ask you to predict what Python does or to find the bug, so you can't pass by remembering wording.

What does this program print, and why?
  • It prints 0 5 0.
  • a.count = 5 does not change the class attribute. It creates a new instance attribute in a.__dict__ that shadows Dog.count.
  • b has no count of its own, so lookup falls through to the class and finds 0. Dog.count was never touched.
class Dog:
    count = 0

a = Dog()
b = Dog()
a.count = 5
print(Dog.count, a.count, b.count)
A learner expects each Dog to start with its own empty list of tricks. What actually happens, and how do you fix it?
  • It prints ['sit']. append changes the one list stored on the class, and every instance reaches that same list.
  • Nothing was assigned on a, so no instance attribute was created. The mutation leaked to everyone.
  • Fix: create the list per instance with self.tricks = [] inside __init__. In a dataclass, use field(default_factory=list).
class Dog:
    tricks = []

a = Dog()
b = Dog()
a.tricks.append('sit')
print(b.tricks)
What goes wrong when you run this, and which line is the cause?
  • It raises AttributeError: 'Puppy' object has no attribute 'name'.
  • Puppy.__init__ overrides the parent one and never calls it, so self.name = name never runs.
  • Fix: add super().__init__(name) as the first line of Puppy.__init__.
class Dog:
    def __init__(self, name):
        self.name = name

class Puppy(Dog):
    def __init__(self, name, age):
        self.age = age

p = Puppy('Rex', 1)
print(p.name)
Predict the output. Which classes run, and in what order?
  • It prints D, B, C, A on separate lines.
  • super() follows the MRO of the instance's class, which is D, B, C, A, object. It does not go to the literal parent.
  • So B's super().hi() calls C, not A, and A runs only once at the end. That is how the diamond problem is solved.
class A:
    def hi(self):
        print('A')

class B(A):
    def hi(self):
        print('B')
        super().hi()

class C(A):
    def hi(self):
        print('C')
        super().hi()

class D(B, C):
    def hi(self):
        print('D')
        super().hi()

D().hi()
This class defines eq. Why does the last line fail, and what should the class do?
  • It raises TypeError: unhashable type: 'Point'.
  • Defining __eq__ without __hash__ sets __hash__ to None, so instances can't go in sets or be dict keys.
  • Fix: also define __hash__, for example return hash((self.x, self.y)). Or use @dataclass(frozen=True), which generates both.
class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __eq__(self, other):
        return self.x == other.x and self.y == other.y

seen = {Point(1, 2)}

Summary

  • A class is a blueprint and an object too. An instance is one object built from it, with its own __dict__.
  • self is just the first parameter, and d.bark() is sugar for Dog.bark(d). State only sticks if you assign it with self.name = name.
  • Lookup goes instance, then class, then parents. Assigning through an instance shadows the class attribute, and a mutable class attribute is shared by all instances.
  • Call super().__init__() in subclasses. super() follows the MRO, so it is not always the direct parent.
  • Define __repr__ for debugging and __eq__ for ==. If you define __eq__, define __hash__ too, or the class becomes unhashable.
  • Use @property for validation and read-only values with a _name backing field, and @dataclass for plain data with default_factory for mutable defaults.
  • Inherit for is-a, compose for has-a, and never use a mutable default argument.