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.
- 1Classes and instancesblueprint and objects
- 2Attributes and methodsdata and behaviour
- 3Inheritance and dundersreuse and built-in feel
- 4Dataclasses and designless code, better choices
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.
class Dog:
pass
d = Dog()
print(type(d).__name__)
print(isinstance(d, Dog))The smallest possible class, and one instance of it
Dog
TrueThe 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.
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
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.
| Class | Instance | |
|---|---|---|
| Made by | The class statement | Calling the class, like Dog() |
| Role | Blueprint shared by many objects | One concrete object |
| How many | Usually one per kind of thing | As many as you create |
| Example | Dog | d, 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.
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
<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.
- 1dyour object
- 2Dogtype(d) is Dog
- 3typetype(Dog) is type
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.
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
{'name': 'Rex', 'age': 3}
{'name': 'Fido'}
4rex 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.
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
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
classstatement. An instance is one object built by calling the class, such asd = 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
classstatement runs, so you can pass it around and inspect it.type(Dog)istype, andtype(d)isDog. isinstance(d, Dog)checks whetherdis an instance ofDogor of any subclass ofDog. It returnsTrueorFalse.
d = Dog() print(type(d) is Dog) print(isinstance(d, Dog))
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__.
- 1Dog.new(Dog, 'Rex')creates an empty Dog object
- 2Dog.init(obj, 'Rex')sets up the object's data
- 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__.
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.
__new__ creating the object __init__ setting up Rex Rex
| new | init | |
|---|---|---|
| Job | Creates the object | Sets up an existing object |
| First parameter | cls (the class) | self (the new object) |
| Runs | First | Second |
| Returns | The new object | Must return None |
| How often you write it | Rarely | Almost 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).
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.
Rex says woof
Rex says woof
TrueThis 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.
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.
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.
False
Rexname = name inside __init__ assigns the parameter to itself and stores nothing. Later, obj.name raises AttributeError. Always write self.name = name.
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.
selfis 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
| Question | Short 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.
| Attribute | Method | |
|---|---|---|
| What it is | A value (number, string, list, any object) | A function defined in the class |
| Example | d.age | d.bark() |
| Needs parentheses to use | No, you just read it | Yes, parentheses run it |
| Typical job | Remember state | Read or change state, do work |
| First parameter | None | self |
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.
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())
4
Rex says woofIf 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) | |
|---|---|---|
| Type | Plain function | Bound method |
| Knows its instance | No | Yes, in __self__ |
| Who supplies self | You, as the first argument | Python, automatically |
| Call it like | Dog.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.
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
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.
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
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.
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.
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.
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
brown ['age', 'color', 'name'] False
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, sod.bark()runs asDog.bark(d). - Looked up on the class, the same function stays plain and needs
selfpassed 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.
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
Rex Fido
Canis familiaris Canis familiaris
True
Canis familiaris| Class attribute | Instance attribute | |
|---|---|---|
| Defined | In the class body | On self, usually in __init__ |
| Copies | One, shared by all instances | One per instance |
| Reached through | Dog.species or rex.species | rex.name only |
| Good for | Constants and shared defaults | Data 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.
- 1Instance dictrex.dict holds name only
- 2ClassDog.dict has species
- 3Parent classeschecked only if still not found
- 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.
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
{'name': 'Rex'}
False
Canis familiaris
4Assigning 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.
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
5 0 0 {'count': 5} 0
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.
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
3Interview 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__withself.tags = [].
class Post: tags = [] # shared by every Post a, b = Post(), Post() a.tags.append("python") print(b.tags) # ['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
['python'] []What is a good use of class attributes?
- Constants and fixed configuration that all instances share, such as
MAX_RETRIES = 3or a defaultspeciesname. - 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.
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.
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.
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.
- 1Puppyhas speak? yes, use it
- 2Dogonly checked if Puppy lacks it
- 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.
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.
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.
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)
3 AttributeError: 'BadPuppy' object has no attribute 'name'
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.
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)
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.
| Check | Takes | True for subclasses? | Example |
|---|---|---|---|
| isinstance(x, Dog) | an object and a class | Yes | isinstance(p, Dog) is True |
| issubclass(A, Dog) | two classes | Yes | issubclass(Puppy, Dog) is True |
| type(x) == Dog | an object and a class | No, exact class only | type(p) == Dog is False |
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
AttributeErrorappears 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 forDogand every subclass, so it respects inheritance.type(x) == Dogis True only when the object's exact class isDog, so aPuppyfails it.- Prefer
isinstancein normal code; usetype(x) == Dogonly when you truly need the exact class.
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.
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.
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.
- 1Duckno move() here
- 2Swimmermove() found, stop
- 3Walkernever reached
- 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.
print([c.__name__ for c in Duck.mro()]) print([c.__name__ for c in Robot.__mro__])
['Duck', 'Swimmer', 'Walker', 'object'] ['Robot', 'Walker', 'Swimmer', 'object']
C.__mro__ | C.mro() | |
|---|---|---|
| Returns | A tuple of classes | A list of classes |
| Typical use | Quick look in a script or REPL | When you want to loop or slice the result |
| Starts with | The class itself | The class itself |
| Ends with | object | object |
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.
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.
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.
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()])
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.
- 1Bottomsuper() goes to Left
- 2Leftsuper() goes to Right
- 3Rightsuper() goes to Base
- 4Baseruns once, ends the chain
Direct call Base.__init__(self) | super().__init__() | |
|---|---|---|
| Who runs next | The class you named | The next class in the MRO |
| Diamond result | Shared base can run twice | Each class runs once |
| Adding a new parent later | You must edit the calls | Follows the MRO automatically |
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) orC.mro()(a list). It always ends withobject.
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 theclassline.- In a single-inheritance chain that next class happens to be the parent.
- In a diamond it can be a sibling, such as
Leftcalling intoRight, which is what lets every class run exactly once.
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.
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__:
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
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:
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}")
21 degrees Temp(21) [Temp(21)] Temp(21) and 21 degrees
| repr | str | |
|---|---|---|
| Audience | Developers, logs, debugging | End users, readable output |
| Used by | repr(), REPL, items inside lists | print(), str(), f-strings |
| Goal | Unambiguous, often rebuildable | Readable and friendly |
| If missing | Default <object at 0x...> text | Falls back to __repr__ |
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.
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
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.
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))
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 | |
|---|---|---|
| Question | Do the values count as equal? | Is it the exact same object? |
| Calls | __eq__, which you can customise | Nothing; it cannot be overridden |
| Default for your class | Same as is | Compares identity |
| Typical use | Comparing data | Checking 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:
try: {a, b} except TypeError as e: print(e)
Uses the Point with __eq__ but no __hash__
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:
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])
1
firstDefining __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.
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))
3 Playlist(['intro', 'verse', 'outro']) True False
- 1You write len(mix)built-in function
- 2Python looks up len on the classnot on the instance
- 3mix.len() runsyour code
- 4The integer comes backmust be 0 or more
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 whatprint()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.iscompares identity and cannot be overridden.- Without
__eq__,==behaves exactly likeis.
What does defining eq do to hashing?
- Python sets
__hash__toNone, 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.
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
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.
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.
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
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.
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.
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
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 | |
|---|---|---|
| Reading | p.get_price() | p.price |
| Writing | p.set_price(5) | p.price = 5 |
| Validation | Yes | Yes, in the setter |
| Read-only | Leave out the setter method | Leave out @name.setter |
| Adding it later | Every caller must change | No 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
@propertyand 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.emailandobj.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)
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.
- 1Read annotationsx: int, y: int = 0
- 2Build methodsinit, repr, eq
- 3Attach to classyou write none of them
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
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 method | What it gives you | Without @dataclass |
|---|---|---|
__init__ | Point(3) assigns x and y | You write every assignment by hand |
__repr__ | Point(x=3, y=0) | Default text like <Point object at 0x...> |
__eq__ | Equal when all fields are equal | Equal only if it is the same object |
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.
from dataclasses import dataclass, field try: @dataclass class Bad: tags: list = [] except ValueError as e: print(e)
The error happens at class creation
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.
@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
['python'] []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.
@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
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.
| Option | Effect | Typical use |
|---|---|---|
@dataclass | __init__, __repr__, __eq__ | Mutable records |
frozen=True | Assignment raises error, __hash__ added | Dict keys, shared constants |
order=True | Adds <, <=, >, >= by field order | Sorting records |
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=Trueadds__lt__,__le__,__gt__and__ge__.frozen=Truemakes fields read-only and, witheq, adds__hash__.
Why is default_factory needed?
- A class-level default is created once, so a list would be shared by all instances.
@dataclassraisesValueErrorfor list, dict and set defaults to stop that.field(default_factory=list)callslist()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.
| Dataclass | namedtuple | Plain class | |
|---|---|---|---|
| Boilerplate | Generated | Generated | Written by hand |
| Mutable | Yes, unless frozen=True | No | Yes |
| Unpack or index | No | Yes | No |
| Mutable defaults | default_factory | Not supported directly | Handle in __init__ |
| Hashable | Only if frozen | Yes | By identity |
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.
| Inheritance | Composition | |
|---|---|---|
| Relationship | is-a: a Dog is an Animal | has-a: a Car has an Engine |
| How it is written | class Dog(Animal) | self.engine = engine |
| What you get | Every attribute and method of the parent, automatically | Only what you choose to forward |
| Coupling | Tight: the child depends on the parent's internals | Loose: 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.
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))
Rex eats
Rex says woof
TrueA 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().
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.
Car: Engine (120 hp) running False
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().
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())
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.
- 1Vehiclebase behaviour
- 2MotorVehicleadds fuel
- 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.
class ElectricMotor: def start(self): return "Motor hums silently" car.engine = ElectricMotor() print(car.start())
Swapped at runtime, and Car itself did not change.
Car: Motor hums silently
| Question | Deep inheritance | Composition |
|---|---|---|
| Change one behaviour | Edit or override somewhere in the chain | Pass in a different part |
| Swap at runtime | Not possible; the class is fixed at creation | Reassign the attribute |
| Test in isolation | Needs the whole parent chain | Give 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.
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.
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"))
inherited: [99, 1, 2] composed: 2 1 False
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
Animaland callseat()works with every subclass. - When extending a base class designed for it, such as
Exceptionfor custom errors. - Keep the tree shallow, usually one or two levels.
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.
class Dog: def bark(): return "Woof" try: Dog().bark() except TypeError as e: print(e)
The method takes zero parameters, but Python passes one
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.
class Dog: def bark(self): return "Woof" print(Dog().bark())
With self, the instance has somewhere to land
Woof
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.
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
['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.
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
[]
FalseShared 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.
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
['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.
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
[]
| Mutable default argument | Mutable class attribute | |
|---|---|---|
| Where it lives | In the function's __defaults__ | In the class body |
| Created | Once, when def runs | Once, when the class is built |
| Shared by | Every call that skips the argument | Every instance without its own copy |
| Fix | Use None, build the object inside | Assign 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.
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
'Cat' object has no attribute 'name'
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
Tom TrueOverriding __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.
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
118.0 Point(x=1, y=2)
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
Noneand 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, whileobj.attr = ...creates a separate attribute on that instance.- Check with
a.items is b.items, and fix by assigningself.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)ordir(self). - Check that
__init__assigns it on every path, and that subclasses callsuper().__init__(). - Check you wrote
self.xand not a barex, which would be a local variable.
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
{'items': []}
False
'Cart' object has no attribute 'total'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.
| Piece | What it does | Tiny example |
|---|---|---|
class | Defines a new type, the blueprint for objects | class Puppy: |
__init__(self) | Runs when an object is created and sets up its starting state | def __init__(self, name): |
self.attr | Stores a value on this one instance | self.name = name |
super().__init__() | Calls the parent class setup so inherited attributes get set | super().__init__(name) |
@property | Makes a method read like an attribute, so a setter can validate | def age(self): |
@dataclass | Generates __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.
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
Rex says woof
1 ball
error: age cannot be negativeIf 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.
| Method | Triggered by | Purpose |
|---|---|---|
__repr__ | repr(obj), the REPL, debuggers, lists of objects | A debug string for developers, ideally one that looks like the code that rebuilds the object |
__str__ | print(obj), str(obj), f-strings | A friendly string for users; falls back to __repr__ if missing |
__eq__ | a == b | Decides 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 key | Returns 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.
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
Box([1, 2]) a box with 2 items True 2 1
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.
| Situation | Reach for | Why |
|---|---|---|
| It is a kind of the parent (a Puppy is an Animal) | Inherit | Is-a means the child can stand in anywhere the parent is expected |
| It has or uses another object (a Car has an Engine) | Compose | Has-a means store the other object as an attribute and delegate to it |
| It is plain data with little behavior | @dataclass | You get __init__, __repr__ and __eq__ without writing them |
| A value must obey a rule when it is set | @property | The setter can validate every assignment while callers still write obj.attr |
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
selfand 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=Trueif 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 = 5does not change the class attribute. It creates a new instance attribute ina.__dict__that shadowsDog.count.bhas nocountof its own, so lookup falls through to the class and finds 0.Dog.countwas 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'].appendchanges 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, usefield(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, soself.name = namenever runs.- Fix: add
super().__init__(name)as the first line ofPuppy.__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,Aon separate lines. super()follows the MRO of the instance's class, which isD, B, C, A, object. It does not go to the literal parent.- So
B'ssuper().hi()callsC, notA, andAruns 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 examplereturn 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__. selfis just the first parameter, andd.bark()is sugar forDog.bark(d). State only sticks if you assign it withself.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
@propertyfor validation and read-only values with a_namebacking field, and@dataclassfor plain data withdefault_factoryfor mutable defaults. - Inherit for is-a, compose for has-a, and never use a mutable default argument.