Objects and classes all 5 written
What a class is for, the dunder methods that make your type behave like a built-in, and why composition usually beats inheritance.
28 Classes: bundling state with behaviour
A class bundles data with the functions that operate on it. The honest reason to reach for one is narrow and practical: when you find yourself passing the same few values into every function in a group, those values and those functions belong together.
The case for a class, starting from functions
# Functions that all need the same three values, passed in every time.
def account_balance(deposits, withdrawals, fee):
return sum(deposits) - sum(withdrawals) - fee
def account_summary(deposits, withdrawals, fee):
return f"{len(deposits)} in, {len(withdrawals)} out, balance {account_balance(deposits, withdrawals, fee)}"
deposits, withdrawals, fee = [100, 50], [30], 5
print("as functions:", account_summary(deposits, withdrawals, fee))
# The same thing with the data held once.
class Account:
def __init__(self, fee=5):
self.deposits = []
self.withdrawals = []
self.fee = fee
def deposit(self, amount):
self.deposits.append(amount)
return self
def withdraw(self, amount):
self.withdrawals.append(amount)
return self
def balance(self):
return sum(self.deposits) - sum(self.withdrawals) - self.fee
def summary(self):
return f"{len(self.deposits)} in, {len(self.withdrawals)} out, balance {self.balance()}"
acct = Account()
acct.deposit(100).deposit(50).withdraw(30)
print("as a class: ", acct.summary())
test("both compute the same balance", account_balance(deposits, withdrawals, fee), acct.balance())
test("the object carries its own state", acct.deposits, [100, 50])
test("returning self allows chaining", Account().deposit(10).balance(), 5)
What it printed, when the build ran it
as functions: 2 in, 1 out, balance 115 as a class: 2 in, 1 out, balance 115 ok both compute the same balance ok the object carries its own state ok returning self allows chaining
A class is not always the answer. One function with two arguments should stay a function. A class with a single method and no state is a function wearing a costume. The signal is repetition: the same data threaded through several functions, or a value that has to stay consistent with another one.
__init__, self, and what happens at the call
class Point:
def __init__(self, x, y):
# __init__ does not create the object -- it is handed the new one as self
# and its job is to set it up.
self.x = x
self.y = y
def distance_from_origin(self):
return (self.x ** 2 + self.y ** 2) ** 0.5
p = Point(3, 4)
print("p.x, p.y:", p.x, p.y)
print("distance:", p.distance_from_origin())
# self is not magic: it is the first parameter, and the instance is passed
# into it. These two calls are the same thing.
print()
print("normal call: ", p.distance_from_origin())
print("explicit call: ", Point.distance_from_origin(p))
# Attributes can be added after construction, because an instance is just a
# namespace. Convenient, and a common source of typo bugs.
p.z = 99
print()
print("instance namespace:", p.__dict__)
test("__init__ set both attributes", (p.x, p.y), (3, 4))
test("the method computes from them", p.distance_from_origin(), 5.0)
test("a bound call and an explicit call agree",
p.distance_from_origin(), Point.distance_from_origin(p))
test("an instance's attributes live in a dict", p.__dict__["z"], 99)
What it printed, when the build ran it
p.x, p.y: 3 4
distance: 5.0
normal call: 5.0
explicit call: 5.0
instance namespace: {'x': 3, 'y': 4, 'z': 99}
ok __init__ set both attributes
ok the method computes from them
ok a bound call and an explicit call agree
ok an instance's attributes live in a dict
Forgetting self in a method signature is the most common beginner error here, and the
message names the count rather than the cause, so it is worth recognising.
class Counter:
def __init__(self):
self.total = 0
def increment(): # missing self
pass
Counter().increment()
The traceback it really produced, when the build ran it
Traceback (most recent call last):
File "example.py", line 8, in <module>
Counter().increment()
~~~~~~~~~~~~~~~~~~~^^
TypeError: Counter.increment() takes 0 positional arguments but 1 was given
Class attributes versus instance attributes
An attribute assigned in the class body belongs to the class and is shared by every
instance. One assigned in __init__ belongs to that instance alone. Reading falls back
from instance to class; assigning always creates an instance attribute.
class Circle:
PI = 3.14159 # class attribute: one, shared
count = 0 # also shared -- used here as a counter
def __init__(self, radius):
self.radius = radius # instance attribute: one per object
Circle.count += 1 # deliberately reaching the class attribute
def area(self):
return Circle.PI * self.radius ** 2
a, b = Circle(1), Circle(2)
print("a.radius, b.radius:", a.radius, b.radius)
print("both see the same PI:", a.PI is b.PI is Circle.PI)
print("instances created:", Circle.count)
# Assigning through an instance creates an instance attribute, shadowing the
# class one -- it does NOT change the shared value.
a.PI = 3
print()
print("after a.PI = 3")
print(" a.PI: ", a.PI, "<- instance attribute, shadowing")
print(" b.PI: ", b.PI, "<- untouched")
print(" Circle.PI:", Circle.PI, "<- untouched")
test("class attributes are shared", b.PI, Circle.PI)
test("the counter counted both instances", Circle.count, 2)
test("assigning through an instance shadows rather than changes", (a.PI, Circle.PI), (3, 3.14159))
test("and the shadow lives in the instance dict", "PI" in a.__dict__, True)
What it printed, when the build ran it
a.radius, b.radius: 1 2 both see the same PI: True instances created: 2 after a.PI = 3 a.PI: 3 <- instance attribute, shadowing b.PI: 3.14159 <- untouched Circle.PI: 3.14159 <- untouched ok class attributes are shared ok the counter counted both instances ok assigning through an instance shadows rather than changes ok and the shadow lives in the instance dict
The trap: a mutable class attribute
The shadowing above is harmless because numbers are immutable — assignment is the only way to change them. A mutable class attribute is different: mutating it does not create a shadow, so every instance sees the change.
class Basket:
items = [] # the bug: one list for the whole class
def add(self, thing):
self.items.append(thing) # mutates the SHARED list
first, second = Basket(), Basket()
first.add("apple")
print("first.items: ", first.items)
print("second.items:", second.items, "<- it never added anything")
print("the same object:", first.items is second.items)
# The fix: create the list per instance, in __init__.
class FixedBasket:
def __init__(self):
self.items = []
def add(self, thing):
self.items.append(thing)
third, fourth = FixedBasket(), FixedBasket()
third.add("apple")
print()
print("third.items: ", third.items)
print("fourth.items:", fourth.items)
print("separate objects:", third.items is not fourth.items)
test("the class attribute was shared", second.items, ["apple"])
test("and both names point at one list", first.items is second.items, True)
test("per-instance lists are independent", fourth.items, [])
test("and are genuinely different objects", third.items is fourth.items, False)
What it printed, when the build ran it
first.items: ['apple'] second.items: ['apple'] <- it never added anything the same object: True third.items: ['apple'] fourth.items: [] separate objects: True ok the class attribute was shared ok and both names point at one list ok per-instance lists are independent ok and are genuinely different objects
This is the same mechanism as lesson 15's mutable default. An object created once,
at definition time, and shared by everything afterwards. The rule generalises: anything mutable that
is created in a class body or a default argument is created once. Put mutable state in
__init__. A class attribute is for genuine constants and for values deliberately shared
across all instances.
Making an object printable
By default an object prints as its type and memory address, which is useless in logs and in a
debugger. __repr__ is the one method worth adding to every class you write.
class Bare:
def __init__(self, name):
self.name = name
class Described:
def __init__(self, name):
self.name = name
def __repr__(self):
return f"Described(name={self.name!r})"
bare, described = Bare("ada"), Described("ada")
print("default: ", repr(bare)[:30] + "...")
print("with repr:", repr(described))
print()
print("and inside a container, where it matters most:")
print(" ", [Bare("a"), Bare("b")][0].__class__.__name__, "list prints addresses")
print(" ", [Described("a"), Described("b")])
test("the default repr is not useful", "object at 0x" in repr(bare), True)
test("a custom repr is", repr(described), "Described(name='ada')")
test("and containers use it", repr([Described("a")]), "[Described(name='a')]")
What it printed, when the build ran it
default: <__main__.Bare object at 0x102...
with repr: Described(name='ada')
and inside a container, where it matters most:
Bare list prints addresses
[Described(name='a'), Described(name='b')]
ok the default repr is not useful
ok a custom repr is
ok and containers use it
Privacy by convention
Python has no private keyword. A single leading underscore is a convention meaning "this
is internal, do not rely on it". Nothing enforces it, and that is deliberate.
class Connection:
def __init__(self, host):
self.host = host # public: part of the interface
self._socket = "handle" # internal: may change without notice
self.__token = "secret" # double underscore: name-mangled
def describe(self):
return f"{self.host} via {self._socket}"
conn = Connection("example.com")
print("public: ", conn.host)
print("internal:", conn._socket, "<- reachable, just impolite")
# Double underscore is mangled to _ClassName__name, which prevents accidental
# clashes in subclasses. It is not security.
print()
print("attributes actually stored:", sorted(conn.__dict__))
print("the mangled one is reachable too:", conn._Connection__token)
try:
conn.__token
except AttributeError as exc:
print("but not by its written name:", exc)
test("a single underscore is only a convention", conn._socket, "handle")
test("double underscore is mangled, not hidden", conn._Connection__token, "secret")
test("the mangled name is what is stored", "_Connection__token" in conn.__dict__, True)
What it printed, when the build ran it
public: example.com internal: handle <- reachable, just impolite attributes actually stored: ['_Connection__token', '_socket', 'host'] the mangled one is reachable too: secret but not by its written name: 'Connection' object has no attribute '__token' ok a single underscore is only a convention ok double underscore is mangled, not hidden ok the mangled name is what is stored
Use one underscore; almost never use two. A single leading underscore communicates intent, which is all you need among people reading the same codebase. Double underscore exists to stop a subclass accidentally overwriting an attribute the base class depends on — a real but narrow problem. Using it for "privacy" mostly annoys the people debugging your code at 3am.
__slots__, when there will be very many instances
import sys
class Normal:
def __init__(self, x, y):
self.x, self.y = x, y
class Slotted:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x, self.y = x, y
n, s = Normal(1, 2), Slotted(1, 2)
print("Normal has a __dict__: ", hasattr(n, "__dict__"))
print("Slotted has a __dict__:", hasattr(s, "__dict__"))
# Which means a slotted instance cannot grow new attributes.
try:
s.z = 3
except AttributeError as exc:
print()
print("adding an attribute to a slotted instance:")
print(" ", exc)
n.z = 3
print("the normal one accepts it:", n.z)
test("slots removes the per-instance dict", hasattr(s, "__dict__"), False)
test("and fixes the attribute set", hasattr(n, "__dict__"), True)
test("the normal instance can grow", n.z, 3)
What it printed, when the build ran it
Normal has a __dict__: True Slotted has a __dict__: False adding an attribute to a slotted instance: 'Slotted' object has no attribute 'z' and no __dict__ for setting new attributes the normal one accepts it: 3 ok slots removes the per-instance dict ok and fixes the attribute set ok the normal instance can grow
The point of __slots__ is memory: no per-instance dictionary. It matters when you have
hundreds of thousands of small objects and nowhere else — and it is worth knowing mainly because
dataclass(slots=True) in lesson 32 gives you the same thing for free.
What to take from this lesson
- Reach for a class when the same data is threaded through several functions.
selfis just the first parameter; the instance is passed into it.- Class attributes are shared; instance attributes are per-object. Assigning through an instance shadows rather than changes.
- Never put mutable state in a class attribute — it is created once, exactly like a mutable default argument.
- Give every class a
__repr__. - One leading underscore for "internal"; two only to avoid subclass clashes.
29 Dunder methods: making your type feel built-in
There is no separate mechanism behind Python's syntax. len(x)
calls x.__len__(), x + y calls x.__add__(y), and
x in y calls y.__contains__(x). Implementing those methods is all it takes
for your own type to work with the syntax everyone already knows.
The delegation, made visible
class Noisy:
def __len__(self):
print(" __len__ was called")
return 3
def __getitem__(self, index):
print(f" __getitem__({index!r}) was called")
return index * 10
def __contains__(self, value):
print(f" __contains__({value!r}) was called")
return value == 1
n = Noisy()
print("len(n):")
length = len(n)
print(" ->", length)
print("n[2]:")
item = n[2]
print(" ->", item)
print("1 in n:")
present = 1 in n
print(" ->", present)
test("len() delegates to __len__", length, 3)
test("subscripting delegates to __getitem__", item, 20)
test("in delegates to __contains__", present, True)
What it printed, when the build ran it
len(n): __len__ was called -> 3 n[2]: __getitem__(2) was called -> 20 1 in n: __contains__(1) was called -> True ok len() delegates to __len__ ok subscripting delegates to __getitem__ ok in delegates to __contains__
__repr__ and __str__: two audiences
__repr__ is for you — unambiguous, ideally something you could paste back into code.
__str__ is for the end user. If you write only one, write __repr__, because
str() falls back to it.
class Money:
def __init__(self, pounds):
self.pounds = pounds
def __repr__(self):
return f"Money({self.pounds!r})"
def __str__(self):
return f"£{self.pounds:.2f}"
m = Money(5)
print("print() uses __str__: ", m)
print("repr() uses __repr__: ", repr(m))
print("f-string uses __str__: ", f"{m}")
print("f-string with !r: ", f"{m!r}")
print("inside a list, __repr__: ", [m])
# With only __repr__ defined, str() falls back to it.
class OnlyRepr:
def __repr__(self):
return "OnlyRepr()"
print()
print("a class with only __repr__, printed:", OnlyRepr())
test("str and repr differ when both are defined", (str(m), repr(m)), ("£5.00", "Money(5)"))
test("containers always use repr", repr([m]), "[Money(5)]")
test("str falls back to repr when __str__ is absent", str(OnlyRepr()), "OnlyRepr()")
What it printed, when the build ran it
print() uses __str__: £5.00 repr() uses __repr__: Money(5) f-string uses __str__: £5.00 f-string with !r: Money(5) inside a list, __repr__: [Money(5)] a class with only __repr__, printed: OnlyRepr() ok str and repr differ when both are defined ok containers always use repr ok str falls back to repr when __str__ is absent
__eq__, and the __hash__ it silently removes
By default two objects are equal only if they are the same object. __eq__ lets you
compare by value — and doing so makes your class unhashable, because Python cannot
assume your notion of equality still matches the default hash.
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return (self.x, self.y) == (other.x, other.y)
def __repr__(self):
return f"Point({self.x}, {self.y})"
print("equality now works by value:", Point(1, 2) == Point(1, 2))
print("and they are still different objects:", Point(1, 2) is Point(1, 2))
print()
print("but __hash__ has become:", Point.__hash__)
test("equal by value", Point(1, 2) == Point(1, 2), True)
test("not identical", Point(1, 2) is Point(1, 2), False)
test("defining __eq__ set __hash__ to None", Point.__hash__, None)
What it printed, when the build ran it
equality now works by value: True and they are still different objects: False but __hash__ has become: None ok equal by value ok not identical ok defining __eq__ set __hash__ to None
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __eq__(self, other):
return (self.x, self.y) == (other.x, other.y)
seen = {Point(1, 2)}
The traceback it really produced, when the build ran it
Traceback (most recent call last):
File "example.py", line 8, in <module>
seen = {Point(1, 2)}
^^^^^^^^^^^^^
TypeError: cannot use 'Point' as a set element (unhashable type: 'Point')
Why Python does this rather than guessing. The contract is that equal objects must
have equal hashes. If __eq__ were redefined and the default identity-based
__hash__ kept, two equal objects would hash differently and a dict would store both — a
silent, extremely confusing bug. Removing __hash__ turns that into an immediate, obvious
error.
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return (self.x, self.y) == (other.x, other.y)
def __hash__(self):
# Hash the same values equality is based on. A tuple does it for you.
return hash((self.x, self.y))
def __repr__(self):
return f"Point({self.x}, {self.y})"
# Now it works as a set member and a dict key.
unique = {Point(1, 2), Point(1, 2), Point(3, 4)}
print("deduplicated:", unique)
lookup = {Point(0, 0): "origin"}
print("looked up by an equal-but-different object:", lookup[Point(0, 0)])
test("equal objects hash equally", hash(Point(1, 2)) == hash(Point(1, 2)), True)
test("so a set deduplicates them", len(unique), 2)
test("and a dict finds by value", lookup[Point(0, 0)], "origin")
What it printed, when the build ran it
deduplicated: {Point(1, 2), Point(3, 4)}
looked up by an equal-but-different object: origin
ok equal objects hash equally
ok so a set deduplicates them
ok and a dict finds by value
Only add __hash__ to something immutable. If the values a hash is
computed from can change after the object is put in a set, the set loses it — the object is filed under
the old hash and looked up under the new one. Hash a type whose fields do not change, or do not make it
hashable at all.
class Mutable:
def __init__(self, x):
self.x = x
def __eq__(self, other):
return isinstance(other, Mutable) and self.x == other.x
def __hash__(self):
return hash(self.x)
item = Mutable(1)
container = {item}
print("found before mutating:", item in container)
item.x = 99 # the hash has now changed
print("found after mutating: ", item in container, "<- lost, though it is still in there")
print("the set still holds one item:", len(container))
print("and iterating finds it:", [m.x for m in container])
test("the object is still in the set", len(container), 1)
test("but membership testing no longer finds it", item in container, False)
What it printed, when the build ran it
found before mutating: True found after mutating: False <- lost, though it is still in there the set still holds one item: 1 and iterating finds it: [99] ok the object is still in the set ok but membership testing no longer finds it
Ordering
from functools import total_ordering
@total_ordering
class Version:
def __init__(self, major, minor):
self.major, self.minor = major, minor
def __eq__(self, other):
return (self.major, self.minor) == (other.major, other.minor)
def __lt__(self, other):
return (self.major, self.minor) < (other.major, other.minor)
def __repr__(self):
return f"v{self.major}.{self.minor}"
versions = [Version(1, 10), Version(1, 2), Version(2, 0)]
print("sorted:", sorted(versions))
print("max: ", max(versions))
# total_ordering derived >, <=, >= from __eq__ and __lt__.
print()
print("v1.2 < v1.10 :", Version(1, 2) < Version(1, 10))
print("v2.0 >= v1.10:", Version(2, 0) >= Version(1, 10))
test("sorting uses __lt__", repr(sorted(versions)), "[v1.2, v1.10, v2.0]")
test("total_ordering supplied >=", Version(2, 0) >= Version(1, 10), True)
test("and <=", Version(1, 2) <= Version(1, 2), True)
test("comparing by tuple gets version ordering right", Version(1, 2) < Version(1, 10), True)
What it printed, when the build ran it
sorted: [v1.2, v1.10, v2.0] max: v2.0 v1.2 < v1.10 : True v2.0 >= v1.10: True ok sorting uses __lt__ ok total_ordering supplied >= ok and <= ok comparing by tuple gets version ordering right
Arithmetic, and NotImplemented
Returning NotImplemented — not raising, not returning False — tells Python
"I do not know how to do this", which lets it try the other operand's reflected method before giving up
with a clear TypeError.
class Metres:
def __init__(self, value):
self.value = value
def __add__(self, other):
if isinstance(other, Metres):
return Metres(self.value + other.value)
if isinstance(other, (int, float)):
return Metres(self.value + other)
return NotImplemented # let Python decide what to do
def __radd__(self, other):
# Called when the LEFT operand does not know how to add a Metres.
return self.__add__(other)
def __mul__(self, factor):
if isinstance(factor, (int, float)):
return Metres(self.value * factor)
return NotImplemented
def __repr__(self):
return f"{self.value}m"
print("Metres(3) + Metres(4):", Metres(3) + Metres(4))
print("Metres(3) + 2: ", Metres(3) + 2)
print("2 + Metres(3): ", 2 + Metres(3), "<- __radd__ handled this")
print("Metres(3) * 2: ", Metres(3) * 2)
# And an unsupported combination gives a proper TypeError rather than nonsense.
try:
Metres(3) + "x"
except TypeError as exc:
print()
print("Metres(3) + 'x':", exc)
test("adding two of the same type works", (Metres(3) + Metres(4)).value, 7)
test("the reflected method handles the reversed order", (2 + Metres(3)).value, 5)
test("multiplication by a number works", (Metres(3) * 2).value, 6)
What it printed, when the build ran it
Metres(3) + Metres(4): 7m Metres(3) + 2: 5m 2 + Metres(3): 5m <- __radd__ handled this Metres(3) * 2: 6m Metres(3) + 'x': unsupported operand type(s) for +: 'Metres' and 'str' ok adding two of the same type works ok the reflected method handles the reversed order ok multiplication by a number works
Truthiness, callability, and attribute access
class Box:
def __init__(self, items):
self.items = list(items)
def __len__(self):
return len(self.items)
class Always:
pass
# Truthiness uses __bool__, or falls back to __len__ != 0.
print("an empty Box is truthy?", bool(Box([])))
print("a full Box is truthy? ", bool(Box([1])))
print("a plain object is always truthy:", bool(Always()))
# __call__ makes an instance callable.
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, value):
return value * self.factor
triple = Multiplier(3)
print()
print("triple(5):", triple(5), " and callable(triple):", callable(triple))
# __getattr__ is consulted only when normal lookup FAILS.
class Lenient:
def __init__(self):
self.real = 1
def __getattr__(self, name):
return f"<no attribute {name!r}>"
lenient = Lenient()
print()
print("existing attribute:", lenient.real)
print("missing attribute: ", lenient.anything)
test("bool falls back to __len__", (bool(Box([])), bool(Box([1]))), (False, True))
test("an object with no __bool__ or __len__ is truthy", bool(Always()), True)
test("__call__ makes an instance callable", triple(5), 15)
test("__getattr__ only fires for missing names", lenient.real, 1)
test("and supplies a value for them", lenient.missing, "<no attribute 'missing'>")
What it printed, when the build ran it
an empty Box is truthy? False a full Box is truthy? True a plain object is always truthy: True triple(5): 15 and callable(triple): True existing attribute: 1 missing attribute: <no attribute 'anything'> ok bool falls back to __len__ ok an object with no __bool__ or __len__ is truthy ok __call__ makes an instance callable ok __getattr__ only fires for missing names ok and supplies a value for them
A worked example: a type that behaves like a container
class Playlist:
def __init__(self, tracks=None):
self._tracks = list(tracks or [])
def __len__(self):
return len(self._tracks)
def __getitem__(self, index):
# Supporting a slice too: the index may be a slice object.
if isinstance(index, slice):
return Playlist(self._tracks[index])
return self._tracks[index]
def __contains__(self, track):
return track in self._tracks
def __iter__(self):
return iter(self._tracks)
def __add__(self, other):
return Playlist(self._tracks + list(other))
def __repr__(self):
return f"Playlist({self._tracks!r})"
p = Playlist(["a", "b", "c", "d"])
print("len: ", len(p))
print("index: ", p[0])
print("negative: ", p[-1])
print("slice: ", p[1:3])
print("membership: ", "b" in p)
print("iteration: ", [t.upper() for t in p])
print("addition: ", p[:1] + ["z"])
print()
print("and everything that consumes an iterable now works:")
print(" sorted:", sorted(p, reverse=True))
print(" list: ", list(p))
test("len works", len(p), 4)
test("indexing and negative indexing work", (p[0], p[-1]), ("a", "d"))
test("slicing returns the same type", isinstance(p[1:3], Playlist), True)
test("membership works", "b" in p, True)
test("it is iterable", list(p), ["a", "b", "c", "d"])
test("and addition returns a new Playlist", repr(p[:1] + ["z"]), "Playlist(['a', 'z'])")
What it printed, when the build ran it
len: 4 index: a negative: d slice: Playlist(['b', 'c']) membership: True iteration: ['A', 'B', 'C', 'D'] addition: Playlist(['a', 'z']) and everything that consumes an iterable now works: sorted: ['d', 'c', 'b', 'a'] list: ['a', 'b', 'c', 'd'] ok len works ok indexing and negative indexing work ok slicing returns the same type ok membership works ok it is iterable ok and addition returns a new Playlist
Implement the protocol, get the ecosystem. Adding __iter__ did not just
enable a for loop — it made sorted, list, sum,
max, unpacking and comprehensions all work, because every one of them is written against
the iteration protocol rather than against list. This is the practical payoff of Python
defining behaviour by protocol.
What to take from this lesson
- Syntax delegates to dunder methods. There is no separate mechanism.
- Write
__repr__on everything;__str__only when the user-facing form differs. - Defining
__eq__sets__hash__toNone. Add__hash__back only if the type is immutable, hashing the same fields. - Define
__eq__and__lt__and let@total_orderingderive the rest. - Return
NotImplementedfrom an operator you cannot handle, so Python can try the reflected method. - Implementing a protocol makes every built-in written against it work.
30 Inheritance, the MRO, and composition instead
Inheritance says "this type is a kind of that type, and may be used anywhere that one is". That is a strong promise and a narrow one. Most of the time what you actually want is composition — holding another object and delegating to it — and this lesson makes the case for preferring it.
The mechanics
class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary
def describe(self):
return f"{self.name} earns {self.salary}"
def annual_cost(self):
return self.salary * 12
class Manager(Employee):
def __init__(self, name, salary, reports):
super().__init__(name, salary) # run the parent's setup
self.reports = reports
# Overriding: same name, different behaviour.
def describe(self):
base = super().describe() # reuse rather than retype
return f"{base}, managing {len(self.reports)}"
e = Employee("Ada", 100)
m = Manager("Grace", 200, ["Ada", "Alan"])
print("employee:", e.describe())
print("manager: ", m.describe())
print("inherited method:", m.annual_cost())
print()
print("isinstance(m, Manager) :", isinstance(m, Manager))
print("isinstance(m, Employee):", isinstance(m, Employee))
print("issubclass(Manager, Employee):", issubclass(Manager, Employee))
test("the subclass inherits methods it did not define", m.annual_cost(), 2400)
test("and can override the ones it wants to", m.describe(), "Grace earns 200, managing 2")
test("super() reaches the overridden version", e.describe(), "Ada earns 100")
test("an instance is both types", (isinstance(m, Manager), isinstance(m, Employee)), (True, True))
What it printed, when the build ran it
employee: Ada earns 100 manager: Grace earns 200, managing 2 inherited method: 2400 isinstance(m, Manager) : True isinstance(m, Employee): True issubclass(Manager, Employee): True ok the subclass inherits methods it did not define ok and can override the ones it wants to ok super() reaches the overridden version ok an instance is both types
Forgetting super().__init__() is the common bug. The parent's setup
never runs, so attributes it would have created do not exist — and the failure arrives later, as an
AttributeError in an inherited method that is not obviously related.
class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary
def annual_cost(self):
return self.salary * 12
class Manager(Employee):
def __init__(self, name, salary, reports):
# super().__init__(name, salary) is missing
self.reports = reports
Manager("Grace", 200, []).annual_cost()
The traceback it really produced, when the build ran it
Traceback (most recent call last):
File "example.py", line 14, in <module>
Manager("Grace", 200, []).annual_cost()
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
File "example.py", line 7, in annual_cost
return self.salary * 12
^^^^^^^^^^^
AttributeError: 'Manager' object has no attribute 'salary'
The method resolution order
With single inheritance the lookup order is obvious. With multiple inheritance Python computes a linear order — the MRO — and you can read it. The classic diamond shows why it matters.
class Base:
def who(self):
return "Base"
class Left(Base):
def who(self):
return "Left"
class Right(Base):
def who(self):
return "Right"
class Both(Left, Right):
pass
print("Both().who():", Both().who())
print()
print("the resolution order:")
for cls in Both.__mro__:
print(" ", cls.__name__)
print()
print("Left comes before Right because it is listed first,")
print("and Base comes after BOTH of them -- never before a subclass of itself.")
test("the first listed parent wins", Both().who(), "Left")
test("the MRO is Both, Left, Right, Base, object",
[c.__name__ for c in Both.__mro__], ["Both", "Left", "Right", "Base", "object"])
test("Base appears after both its subclasses",
[c.__name__ for c in Both.__mro__].index("Base") > 1, True)
What it printed, when the build ran it
Both().who(): Left
the resolution order:
Both
Left
Right
Base
object
Left comes before Right because it is listed first,
and Base comes after BOTH of them -- never before a subclass of itself.
ok the first listed parent wins
ok the MRO is Both, Left, Right, Base, object
ok Base appears after both its subclasses
Which is why "super() calls the parent" is a simplification. It calls the next class
in the MRO, which in multiple inheritance is not necessarily the one you would point at.
log = []
class Base:
def setup(self):
log.append("Base")
class Left(Base):
def setup(self):
log.append("Left")
super().setup()
class Right(Base):
def setup(self):
log.append("Right")
super().setup()
class Both(Left, Right):
def setup(self):
log.append("Both")
super().setup()
Both().setup()
print("the chain that actually ran:", log)
print()
print("Left's super() went to RIGHT, not to Base -- because Right is next in")
print("the MRO. Base ran once, not twice, which is the point of the MRO.")
test("every class in the chain ran exactly once", log, ["Both", "Left", "Right", "Base"])
test("Base ran once despite two paths to it", log.count("Base"), 1)
What it printed, when the build ran it
the chain that actually ran: ['Both', 'Left', 'Right', 'Base'] Left's super() went to RIGHT, not to Base -- because Right is next in the MRO. Base ran once, not twice, which is the point of the MRO. ok every class in the chain ran exactly once ok Base ran once despite two paths to it
This is the argument against deep hierarchies in one panel. To know what
super().setup() does in Left, you have to know what
Left is combined with at the point of use — which you cannot know while writing
Left. Multiple inheritance is not broken, but it makes local reasoning impossible, and
that cost is usually larger than the duplication it saves.
Composition: holding an object instead of becoming one
# Inheritance, used where the "is a" claim is false.
class Engine:
def start(self):
return "engine running"
def stop(self):
return "engine stopped"
class CarByInheritance(Engine): # a Car is NOT an Engine
def drive(self):
return f"{self.start()} -- driving"
# Composition: a Car HAS an Engine.
class CarByComposition:
def __init__(self, engine):
self._engine = engine
def drive(self):
return f"{self._engine.start()} -- driving"
by_inheritance = CarByInheritance()
by_composition = CarByComposition(Engine())
print("inheritance:", by_inheritance.drive())
print("composition:", by_composition.drive())
# The difference shows in the surface area. Inheritance exposed the engine's
# whole interface on the car, whether that made sense or not.
print()
print("car.stop() exists via inheritance:", hasattr(by_inheritance, "stop"))
print("car.stop() exists via composition:", hasattr(by_composition, "stop"))
test("both drive", by_inheritance.drive(), by_composition.drive())
test("inheritance leaked the engine's interface onto the car", hasattr(by_inheritance, "stop"), True)
test("composition kept the car's surface deliberate", hasattr(by_composition, "stop"), False)
What it printed, when the build ran it
inheritance: engine running -- driving composition: engine running -- driving car.stop() exists via inheritance: True car.stop() exists via composition: False ok both drive ok inheritance leaked the engine's interface onto the car ok composition kept the car's surface deliberate
Composition also lets the collaborator be swapped, which is what makes the code testable — and this is the practical payoff rather than a design-purity argument.
class RealEngine:
def start(self):
return "vroom"
class FakeEngine:
"""Records what happened, for a test."""
def __init__(self):
self.started = 0
def start(self):
self.started += 1
return "fake"
class Car:
def __init__(self, engine):
self._engine = engine
def drive(self):
return f"{self._engine.start()} -- driving"
# In production.
print("real:", Car(RealEngine()).drive())
# In a test, with no engine involved at all.
fake = FakeEngine()
car = Car(fake)
car.drive()
car.drive()
print("fake:", fake.started, "starts recorded")
print()
print("Nothing about Car changed. The collaborator did.")
test("the real engine works", Car(RealEngine()).drive(), "vroom -- driving")
test("the fake one can be inspected afterwards", fake.started, 2)
test("the two engines share no base class beyond object",
set(RealEngine.__mro__) & set(FakeEngine.__mro__), {object})
test("so Car depends on the method, not on a type", Car(fake).drive(), "fake -- driving")
What it printed, when the build ran it
real: vroom -- driving fake: 2 starts recorded Nothing about Car changed. The collaborator did. ok the real engine works ok the fake one can be inspected afterwards ok the two engines share no base class beyond object ok so Car depends on the method, not on a type
Abstract base classes: promising an interface
When you do want inheritance, abc lets the base class declare that subclasses must
implement something — and it fails at instantiation rather than at the first call, which is much
earlier and clearer.
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
"""Every shape must be able to report its area."""
def describe(self): # ordinary shared behaviour
return f"{type(self).__name__} with area {self.area():.2f}"
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self):
return self.side ** 2
print(Square(3).describe())
# A subclass that forgot to implement area cannot even be created.
class Broken(Shape):
pass
refused = []
for cls in [Broken, Shape]:
try:
cls()
except TypeError as exc:
refused.append(cls.__name__)
print()
print(f"instantiating {cls.__name__}:")
print(" ", exc)
test("a complete subclass works", Square(3).area(), 9)
test("and inherits the shared method", Square(2).describe(), "Square with area 4.00")
test("neither the incomplete subclass nor the base can be instantiated",
refused, ["Broken", "Shape"])
test("abc records what is still missing", Broken.__abstractmethods__, frozenset({"area"}))
What it printed, when the build ran it
Square with area 9.00 instantiating Broken: Can't instantiate abstract class Broken without an implementation for abstract method 'area' instantiating Shape: Can't instantiate abstract class Shape without an implementation for abstract method 'area' ok a complete subclass works ok and inherits the shared method ok neither the incomplete subclass nor the base can be instantiated ok abc records what is still missing
Compare that with the NotImplementedError approach from the traceback catalogue: it also
works, but the failure waits until somebody calls the method, which may be much later and in production.
from abc import ABC, abstractmethod
# Approach one: fails when the method is called.
class LateShape:
def area(self):
raise NotImplementedError("subclasses must implement area()")
class LateSquare(LateShape):
pass
instance = LateSquare() # constructed happily
print("constructed without complaint:", type(instance).__name__)
late_failure = None
try:
instance.area()
except NotImplementedError as exc:
late_failure = str(exc)
print(" but fails on first use:", exc)
# Approach two: fails at construction.
class EarlyShape(ABC):
@abstractmethod
def area(self): ...
class EarlySquare(EarlyShape):
pass
print()
early_failure = None
try:
EarlySquare()
except TypeError as exc:
early_failure = type(exc).__name__
print("cannot even be constructed:", early_failure)
test("the NotImplementedError version constructs fine", type(instance).__name__, "LateSquare")
test("and only reveals the problem when the method is called",
late_failure, "subclasses must implement area()")
test("the abstract version refuses at construction", early_failure, "TypeError")
What it printed, when the build ran it
constructed without complaint: LateSquare but fails on first use: subclasses must implement area() cannot even be constructed: TypeError ok the NotImplementedError version constructs fine ok and only reveals the problem when the method is called ok the abstract version refuses at construction
When inheritance is the right answer
- A genuine is-a relationship where the subclass can be used anywhere the base can,
with no surprises. A
ValidationErrorreally is anException. - A framework asks for it. Subclassing what a library tells you to subclass is using its interface, not a design decision.
- Sharing implementation among close variants, one level deep, where the variants genuinely differ only in a couple of methods.
And the counter-signals: a subclass that overrides most of what it inherits, a hierarchy more than
two levels deep, a subclass that raises on an inherited method because it does not apply, or a name like
SquareThatIsAlsoACircle. All of those mean the relationship being modelled is not
is-a.
The classic broken hierarchy. Square(Rectangle) looks obviously right
and is not: a rectangle's width and height are independently settable, and a square's are not. So a
function that legitimately sets width and height on a Rectangle breaks when handed a
Square — which means the subclass cannot be used everywhere the base can, and the is-a
promise is false.
class Rectangle:
def __init__(self, width, height):
self.width, self.height = width, height
def area(self):
return self.width * self.height
class Square(Rectangle):
def __init__(self, side):
super().__init__(side, side)
# To stay a square, setting one side must change the other.
def set_width(self, w):
self.width = self.height = w
def stretch(rect):
"""Works for any Rectangle: double the width, keep the height."""
rect.width = rect.width * 2
return rect.area()
r = Rectangle(2, 3)
s = Square(2)
print("stretching a rectangle:", stretch(r), "(expected 12)")
print("stretching a square: ", stretch(s), "(expected 8 for a 4x2, but it is no longer a square)")
print()
print("The function is correct. The subclass relationship is not.")
test("the rectangle behaves as the function expects", stretch(Rectangle(2, 3)), 12)
test("the square is silently no longer square", (lambda sq: (stretch(sq), sq.width == sq.height))(Square(2)), (8, False))
What it printed, when the build ran it
stretching a rectangle: 12 (expected 12) stretching a square: 8 (expected 8 for a 4x2, but it is no longer a square) The function is correct. The subclass relationship is not. ok the rectangle behaves as the function expects ok the square is silently no longer square
What to take from this lesson
- Inheritance promises the subclass works anywhere the base does. If that is false, do not use it.
- Always call
super().__init__(). super()follows the MRO, not the class you named — which is why multiple inheritance makes local reasoning hard.- Prefer composition: it keeps the public surface deliberate and lets collaborators be swapped in tests.
- Use
abc.abstractmethodwhen you do want a required interface, so it fails at construction.
31 Properties, classmethods and staticmethods
In many languages every field gets a getter and a setter, in case validation is needed later. Python does not need that: a plain attribute can become a computed one without any caller changing, so you start simple and add machinery only when something actually requires it.
Start with a plain attribute
class Temperature:
def __init__(self, celsius):
self.celsius = celsius # a plain, public attribute
t = Temperature(20)
initial = t.celsius
print("read: ", initial)
t.celsius = 25
after_write = t.celsius
print("write:", after_write)
print()
print("No getter, no setter, and nothing lost -- because this can change later")
print("without the two lines above changing.")
test("reading works", initial, 20)
test("writing works", after_write, 25)
What it printed, when the build ran it
read: 20 write: 25 No getter, no setter, and nothing lost -- because this can change later without the two lines above changing. ok reading works ok writing works
Adding validation without changing any caller
Now suppose the temperature must not go below absolute zero. @property lets the attribute
name become a method pair while the calling code stays exactly as it was.
class Temperature:
def __init__(self, celsius):
self.celsius = celsius # this goes through the setter below
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError(f"below absolute zero: {value}")
self._celsius = value
t = Temperature(20)
# Identical calling code to the previous panel.
print("read: ", t.celsius)
t.celsius = 25
print("write:", t.celsius)
# But now invalid values are rejected.
rejected = None
try:
t.celsius = -300
except ValueError as exc:
rejected = str(exc)
print()
print("rejected:", rejected)
print("and the value is unchanged:", t.celsius)
test("reading goes through the getter", t.celsius, 25)
test("the setter rejected the impossible value", rejected, "below absolute zero: -300")
test("a rejected write leaves the old value", t.celsius, 25)
test("the real value is stored under a private name", t._celsius, 25)
What it printed, when the build ran it
read: 20 write: 25 rejected: below absolute zero: -300 and the value is unchanged: 25 ok reading goes through the getter ok the setter rejected the impossible value ok a rejected write leaves the old value ok the real value is stored under a private name
This is the argument against pre-emptive getters and setters. In a language without
properties, changing a public field into a validated one breaks every caller, so you write accessors
defensively from the start. In Python the change is invisible to callers, so writing
get_celsius() and set_celsius() buys nothing and costs readability at every
call site.
A computed property, which needs no storage at all
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
@property
def area(self):
"""Derived, so it cannot go stale."""
return self.width * self.height
@property
def is_square(self):
return self.width == self.height
r = Rectangle(3, 4)
print("area: ", r.area)
print("is_square:", r.is_square)
# Change an input and the derived values follow automatically.
r.width = 4
print()
print("after width = 4")
print("area: ", r.area)
print("is_square:", r.is_square)
# A property with no setter is read-only.
try:
r.area = 99
except AttributeError as exc:
print()
print("assigning to a computed property:", exc)
test("the derived value is computed on read", Rectangle(3, 4).area, 12)
test("and follows its inputs", (r.area, r.is_square), (16, True))
test("a getter-only property is read-only",
isinstance(Rectangle.area, property) and Rectangle.area.fset is None, True)
What it printed, when the build ran it
area: 12 is_square: False after width = 4 area: 16 is_square: True assigning to a computed property: property 'area' of 'Rectangle' object has no setter ok the derived value is computed on read ok and follows its inputs ok a getter-only property is read-only
The alternative — storing self.area in __init__ — is the bug this avoids: it
is correct once and then silently wrong the moment an input changes.
class Stored:
def __init__(self, width, height):
self.width = width
self.height = height
self.area = width * height # a snapshot, not a relationship
class Computed:
def __init__(self, width, height):
self.width = width
self.height = height
@property
def area(self):
return self.width * self.height
s, c = Stored(3, 4), Computed(3, 4)
print("both start correct:", s.area, c.area)
s.width = c.width = 10
print("after changing width:")
print(" stored: ", s.area, "<- stale")
print(" computed:", c.area)
test("both are right initially", (Stored(3, 4).area, Computed(3, 4).area), (12, 12))
test("the stored one goes stale", s.area, 12)
test("the computed one does not", c.area, 40)
What it printed, when the build ran it
both start correct: 12 12 after changing width: stored: 12 <- stale computed: 40 ok both are right initially ok the stored one goes stale ok the computed one does not
A property should be cheap. It looks like attribute access, so callers will use it in
loops without thinking. If the computation is expensive, either make it an obvious method —
calculate_total() — or cache it with functools.cached_property, which lesson 37
covers. A property that takes a second to read is a trap dressed as a field.
classmethod: alternative constructors
A class method receives the class rather than an instance. Its main real use is providing extra ways to construct an object, which keeps the construction logic on the class where it belongs.
from datetime import date
class Event:
def __init__(self, name, when):
self.name = name
self.when = when
# Alternative constructors: named, discoverable, and they return an instance.
@classmethod
def from_string(cls, text):
name, _, datestamp = text.partition("@")
return cls(name.strip(), date.fromisoformat(datestamp.strip()))
@classmethod
def today(cls, name):
return cls(name, date.today())
def __repr__(self):
return f"Event({self.name!r}, {self.when!r})"
direct = Event("launch", date(2026, 3, 14))
parsed = Event.from_string("launch @ 2026-03-14")
print("built directly:", direct)
print("built by parsing:", parsed)
print("equal in content:", (direct.name, direct.when) == (parsed.name, parsed.when))
test("the alternative constructor produces the same thing",
(parsed.name, parsed.when), ("launch", date(2026, 3, 14)))
test("and it returns an instance of the class", isinstance(parsed, Event), True)
What it printed, when the build ran it
built directly: Event('launch', datetime.date(2026, 3, 14))
built by parsing: Event('launch', datetime.date(2026, 3, 14))
equal in content: True
ok the alternative constructor produces the same thing
ok and it returns an instance of the class
The reason it takes cls rather than naming the class directly is inheritance: a subclass
gets the alternative constructors for free, and they return the subclass.
from datetime import date
class Event:
def __init__(self, name, when):
self.name, self.when = name, when
@classmethod
def from_string(cls, text):
name, _, stamp = text.partition("@")
return cls(name.strip(), date.fromisoformat(stamp.strip())) # cls, not Event
def __repr__(self):
return f"{type(self).__name__}({self.name!r})"
class Deadline(Event):
def urgency(self):
return "high"
e = Event.from_string("launch @ 2026-03-14")
d = Deadline.from_string("submit @ 2026-04-01")
print("Event.from_string ->", e, type(e).__name__)
print("Deadline.from_string ->", d, type(d).__name__)
print("and the subclass method is available:", d.urgency())
test("the base class builds a base instance", type(e).__name__, "Event")
test("the subclass builds a subclass instance", type(d).__name__, "Deadline")
test("which would be impossible if it hardcoded Event", isinstance(d, Deadline), True)
What it printed, when the build ran it
Event.from_string -> Event('launch') Event
Deadline.from_string -> Deadline('submit') Deadline
and the subclass method is available: high
ok the base class builds a base instance
ok the subclass builds a subclass instance
ok which would be impossible if it hardcoded Event
staticmethod: a function that lives in the class
class Validator:
MIN_LENGTH = 8
@staticmethod
def has_digit(text):
"""Takes neither self nor cls -- it just belongs here conceptually."""
return any(c.isdigit() for c in text)
@classmethod
def long_enough(cls, text):
"""Needs the class, to read MIN_LENGTH."""
return len(text) >= cls.MIN_LENGTH
def __init__(self, password):
self.password = password
def is_valid(self):
return self.has_digit(self.password) and self.long_enough(self.password)
print("has_digit('abc'): ", Validator.has_digit("abc"))
print("has_digit('abc1'): ", Validator.has_digit("abc1"))
print("long_enough('short'):", Validator.long_enough("short"))
print()
print("is_valid('password1'):", Validator("password1").is_valid())
# All three callable from the class or an instance.
print()
print("from an instance too:", Validator("x").has_digit("a1"))
test("a staticmethod needs no instance", Validator.has_digit("a1"), True)
test("a classmethod can read class attributes", Validator.long_enough("12345678"), True)
test("and the instance method combines them", Validator("password1").is_valid(), True)
test("a short password fails", Validator("pass1").is_valid(), False)
What it printed, when the build ran it
has_digit('abc'): False
has_digit('abc1'): True
long_enough('short'): False
is_valid('password1'): True
from an instance too: True
ok a staticmethod needs no instance
ok a classmethod can read class attributes
ok and the instance method combines them
ok a short password fails
When to use which. Instance method: needs the object's data — the default.
classmethod: needs the class, usually to construct one. staticmethod: needs
neither, and is in the class only because that is where a reader would look for it. If a
staticmethod has no conceptual tie to the class at all, it should be a module-level
function.
A deleter, and the full property
class Config:
def __init__(self):
self._cache = {}
@property
def token(self):
if "token" not in self._cache:
self._cache["token"] = "generated-token"
return self._cache["token"]
@token.setter
def token(self, value):
self._cache["token"] = value
@token.deleter
def token(self):
self._cache.pop("token", None)
c = Config()
generated = c.token
print("generated on first read:", generated)
c.token = "explicit"
assigned = c.token
print("after assignment: ", assigned)
del c.token
regenerated = c.token
print("after deletion, regenerated:", regenerated)
test("the getter generates a value on first read", generated, "generated-token")
test("the setter overrides it", assigned, "explicit")
test("the deleter clears it, so the getter runs again", regenerated, "generated-token")
What it printed, when the build ran it
generated on first read: generated-token after assignment: explicit after deletion, regenerated: generated-token ok the getter generates a value on first read ok the setter overrides it ok the deleter clears it, so the getter runs again
What to take from this lesson
- Start with a plain public attribute. Add a property only when validation or computation is actually needed — callers do not change.
- Never write Java-style
get_x()/set_x()pairs in Python. - Compute derived values in a property rather than storing them, so they cannot go stale.
- Keep properties cheap; use a method or
cached_propertyfor expensive work. classmethodfor alternative constructors, and useclsso subclasses inherit them correctly.staticmethodwhen neither the instance nor the class is needed — and consider a plain function instead.
32 dataclasses: the boilerplate you stop writing
Most classes exist to hold a few values. Writing __init__,
__repr__ and __eq__ by hand for those is repetitive and easy to get subtly
wrong. @dataclass generates them from the annotations, and everything the previous four
lessons taught by hand is what it is generating.
The same class, twice
from dataclasses import dataclass
# By hand, as lessons 28 and 29 did it.
class PointByHand:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"PointByHand(x={self.x!r}, y={self.y!r})"
def __eq__(self, other):
if not isinstance(other, PointByHand):
return NotImplemented
return (self.x, self.y) == (other.x, other.y)
# And with a dataclass.
@dataclass
class Point:
x: int
y: int
print("by hand: ", PointByHand(1, 2))
print("dataclass: ", Point(1, 2))
print()
print("equality, both by value:")
print(" by hand: ", PointByHand(1, 2) == PointByHand(1, 2))
print(" dataclass:", Point(1, 2) == Point(1, 2))
test("the generated repr matches the hand-written shape", repr(Point(1, 2)), "Point(x=1, y=2)")
test("equality is by value", Point(1, 2) == Point(1, 2), True)
test("and a different value is not equal", Point(1, 2) == Point(1, 3), False)
test("the attributes are ordinary attributes", (Point(1, 2).x, Point(1, 2).y), (1, 2))
What it printed, when the build ran it
by hand: PointByHand(x=1, y=2) dataclass: Point(x=1, y=2) equality, both by value: by hand: True dataclass: True ok the generated repr matches the hand-written shape ok equality is by value ok and a different value is not equal ok the attributes are ordinary attributes
What it actually generated
from dataclasses import dataclass, fields
@dataclass
class Point:
x: int
y: int
# The generated methods are real methods on the class.
print("has __init__:", "__init__" in Point.__dict__)
print("has __repr__:", "__repr__" in Point.__dict__)
print("has __eq__: ", "__eq__" in Point.__dict__)
# And, as lesson 29 explained, defining __eq__ removed __hash__.
print("__hash__ is:", Point.__hash__)
# The field metadata is introspectable, which is what makes tooling possible.
print()
print("declared fields:")
for f in fields(Point):
print(f" {f.name}: {f.type}")
test("the three methods were generated",
all(name in Point.__dict__ for name in ("__init__", "__repr__", "__eq__")), True)
test("eq=True means unhashable, exactly as in lesson 29", Point.__hash__, None)
test("the fields are introspectable", [f.name for f in fields(Point)], ["x", "y"])
What it printed, when the build ran it
has __init__: True has __repr__: True has __eq__: True __hash__ is: None declared fields: x: <class 'int'> y: <class 'int'> ok the three methods were generated ok eq=True means unhashable, exactly as in lesson 29 ok the fields are introspectable
The annotations are what drive it. x: int is not checked at runtime —
lesson 14 established that — but @dataclass reads it to know a field called x
exists. A plain x = 0 with no annotation is a class attribute and is not a field at all,
which is an easy and silent mistake.
from dataclasses import dataclass, fields
@dataclass
class Mixed:
annotated: int = 1
not_annotated = 2 # no annotation: a class attribute, NOT a field
m = Mixed()
print("both readable:", m.annotated, m.not_annotated)
print()
print("but only one is a field:", [f.name for f in fields(Mixed)])
print("so the constructor takes one argument:", Mixed(annotated=5).annotated)
print("and the repr shows one:", repr(Mixed()))
test("only the annotated name is a field", [f.name for f in fields(Mixed)], ["annotated"])
test("the unannotated one is a shared class attribute", Mixed.not_annotated, 2)
test("and does not appear in the repr", "not_annotated" in repr(Mixed()), False)
What it printed, when the build ran it
both readable: 1 2 but only one is a field: ['annotated'] so the constructor takes one argument: 5 and the repr shows one: Mixed(annotated=1) ok only the annotated name is a field ok the unannotated one is a shared class attribute ok and does not appear in the repr
The mutable default, refused outright
Lesson 15's trap — a mutable default shared by every call — would be even worse here, shared by every instance. Dataclasses refuse to let you write it, at class-creation time, with a message naming the fix.
from dataclasses import dataclass
@dataclass
class Basket:
items: list = []
The traceback it really produced, when the build ran it
Traceback (most recent call last):
File "example.py", line 3, in <module>
@dataclass
^^^^^^^^^
File "<python>/lib/python3.14/dataclasses.py", line 1451, in dataclass
return wrap(cls)
File "<python>/lib/python3.14/dataclasses.py", line 1441, in wrap
return _process_class(cls, init, repr, eq, order, unsafe_hash,
frozen, match_args, kw_only, slots,
weakref_slot)
File "<python>/lib/python3.14/dataclasses.py", line 1066, in _process_class
cls_fields.append(_get_field(cls, name, type, kw_only))
~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^
File "<python>/lib/python3.14/dataclasses.py", line 917, in _get_field
raise ValueError(f'mutable default {type(f.default)} for field '
f'{f.name} is not allowed: use default_factory')
ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory
from dataclasses import dataclass, field
@dataclass
class Basket:
owner: str
items: list = field(default_factory=list) # called per instance
a = Basket("ada")
b = Basket("alan")
a.items.append("apple")
print("a:", a)
print("b:", b, "<- unaffected")
print("separate lists:", a.items is not b.items)
# default_factory takes any zero-argument callable.
@dataclass
class Record:
tags: set = field(default_factory=set)
meta: dict = field(default_factory=dict)
created: str = field(default_factory=lambda: "2026-01-01")
r = Record()
print()
print("other factories:", r)
test("each instance gets its own list", b.items, [])
test("and they are different objects", a.items is b.items, False)
test("a factory can build any type", (type(r.tags).__name__, type(r.meta).__name__), ("set", "dict"))
test("including from a lambda", r.created, "2026-01-01")
What it printed, when the build ran it
a: Basket(owner='ada', items=['apple'])
b: Basket(owner='alan', items=[]) <- unaffected
separate lists: True
other factories: Record(tags=set(), meta={}, created='2026-01-01')
ok each instance gets its own list
ok and they are different objects
ok a factory can build any type
ok including from a lambda
Ordering, immutability and slots
from dataclasses import dataclass
# order=True generates __lt__, __le__, __gt__, __ge__ comparing field by field.
@dataclass(order=True)
class Version:
major: int
minor: int
versions = [Version(1, 10), Version(1, 2), Version(2, 0)]
print("sorted:", sorted(versions))
print("max: ", max(versions))
print("v1.2 < v1.10:", Version(1, 2) < Version(1, 10))
# frozen=True makes instances immutable -- and therefore hashable again.
@dataclass(frozen=True)
class Coordinate:
x: int
y: int
c = Coordinate(1, 2)
print()
print("frozen instances are hashable:", hash(c) == hash(Coordinate(1, 2)))
print("so they work as dict keys:", {Coordinate(0, 0): "origin"}[Coordinate(0, 0)])
test("order=True sorts by field order", repr(sorted(versions)[0]), "Version(major=1, minor=2)")
test("comparison works field by field", Version(1, 2) < Version(1, 10), True)
test("frozen restores hashability", hash(Coordinate(1, 2)) == hash(Coordinate(1, 2)), True)
test("and frozen instances can be dict keys", {Coordinate(0, 0): "origin"}[Coordinate(0, 0)], "origin")
What it printed, when the build ran it
sorted: [Version(major=1, minor=2), Version(major=1, minor=10), Version(major=2, minor=0)] max: Version(major=2, minor=0) v1.2 < v1.10: True frozen instances are hashable: True so they work as dict keys: origin ok order=True sorts by field order ok comparison works field by field ok frozen restores hashability ok and frozen instances can be dict keys
from dataclasses import dataclass
@dataclass(frozen=True)
class Coordinate:
x: int
y: int
c = Coordinate(1, 2)
c.x = 99
The traceback it really produced, when the build ran it
Traceback (most recent call last):
File "example.py", line 9, in <module>
c.x = 99
^^^
File "<string>", line 16, in __setattr__
dataclasses.FrozenInstanceError: cannot assign to field 'x'
from dataclasses import dataclass
# slots=True generates __slots__, removing the per-instance dict (lesson 28).
@dataclass(slots=True)
class Compact:
x: int
y: int
@dataclass
class Roomy:
x: int
y: int
compact, roomy = Compact(1, 2), Roomy(1, 2)
print("Compact has __dict__:", hasattr(compact, "__dict__"))
print("Roomy has __dict__: ", hasattr(roomy, "__dict__"))
try:
compact.z = 3
except AttributeError as exc:
print()
print("and a slotted instance cannot grow:", type(exc).__name__)
test("slots=True removes the instance dict", hasattr(compact, "__dict__"), False)
test("the default keeps it", hasattr(roomy, "__dict__"), True)
test("both still work normally", (compact.x, roomy.x), (1, 1))
What it printed, when the build ran it
Compact has __dict__: False Roomy has __dict__: True and a slotted instance cannot grow: AttributeError ok slots=True removes the instance dict ok the default keeps it ok both still work normally
__post_init__, for derived fields and validation
from dataclasses import dataclass, field
@dataclass
class Order:
unit_price: float
quantity: int
total: float = field(init=False) # not a constructor argument
def __post_init__(self):
if self.quantity < 1:
raise ValueError(f"quantity must be at least 1, got {self.quantity}")
self.total = round(self.unit_price * self.quantity, 2)
o = Order(unit_price=2.50, quantity=4)
print(o)
print("total was derived:", o.total)
try:
Order(unit_price=1.0, quantity=0)
except ValueError as exc:
print()
print("validation:", exc)
test("__post_init__ derived the total", o.total, 10.0)
test("init=False kept it out of the constructor signature",
"total" not in Order.__init__.__code__.co_varnames, True)
test("and validation runs at construction", isinstance(o, Order), True)
What it printed, when the build ran it
Order(unit_price=2.5, quantity=4, total=10.0) total was derived: 10.0 validation: quantity must be at least 1, got 0 ok __post_init__ derived the total ok init=False kept it out of the constructor signature ok and validation runs at construction
__post_init__ and frozen=True conflict. A frozen instance
refuses attribute assignment, including from __post_init__ — so the obvious
self.total = ... raises. The way round it is
object.__setattr__(self, "total", value), which bypasses the check, or better: make the
derived value a @property so there is nothing to assign.
from dataclasses import dataclass, field
# The clean answer for a frozen dataclass: compute, do not store.
@dataclass(frozen=True)
class Order:
unit_price: float
quantity: int
@property
def total(self):
return round(self.unit_price * self.quantity, 2)
o = Order(2.50, 4)
print("computed total:", o.total)
print("still hashable:", isinstance(hash(o), int))
try:
o.unit_price = 5.0
except Exception as exc:
print("still immutable: ", type(exc).__name__)
# And the bypass, for when a stored field really is needed.
@dataclass(frozen=True)
class Stored:
value: int
doubled: int = field(init=False)
def __post_init__(self):
object.__setattr__(self, "doubled", self.value * 2)
s = Stored(21)
print()
print("via object.__setattr__:", s)
test("a property avoids the conflict entirely", Order(2.50, 4).total, 10.0)
test("and the instance stays hashable", isinstance(hash(Order(1.0, 1)), int), True)
test("object.__setattr__ works when a stored field is required", s.doubled, 42)
What it printed, when the build ran it
computed total: 10.0 still hashable: True still immutable: FrozenInstanceError via object.__setattr__: Stored(value=21, doubled=42) ok a property avoids the conflict entirely ok and the instance stays hashable ok object.__setattr__ works when a stored field is required
Useful helpers
from dataclasses import dataclass, asdict, astuple, replace, field
@dataclass
class Address:
city: str
postcode: str
@dataclass
class Person:
name: str
address: Address
tags: list = field(default_factory=list)
p = Person("Ada", Address("London", "E1"), ["maths"])
# asdict recurses into nested dataclasses -- handy for JSON.
print("asdict:", asdict(p))
print("astuple:", astuple(p))
# replace makes a modified copy, which is how you "change" a frozen instance.
moved = replace(p, address=Address("Bath", "BA1"))
print()
print("original city:", p.address.city)
print("copy's city: ", moved.address.city)
test("asdict recurses into nested dataclasses", asdict(p)["address"], {"city": "London", "postcode": "E1"})
test("astuple does the same positionally", astuple(p)[1], ("London", "E1"))
test("replace returns a modified copy", moved.address.city, "Bath")
test("leaving the original alone", p.address.city, "London")
What it printed, when the build ran it
asdict: {'name': 'Ada', 'address': {'city': 'London', 'postcode': 'E1'}, 'tags': ['maths']}
astuple: ('Ada', ('London', 'E1'), ['maths'])
original city: London
copy's city: Bath
ok asdict recurses into nested dataclasses
ok astuple does the same positionally
ok replace returns a modified copy
ok leaving the original alone
asdict is a deep copy, and replace is not.
asdict recursively converts and copies, which is what you want for serialising and which
can be slow on a large structure. replace copies the fields you did not name by
reference, so a mutable field is shared between the original and the copy — the same shallow-copy
behaviour lesson 6 covered.
from dataclasses import dataclass, replace, field
@dataclass
class Task:
title: str
tags: list = field(default_factory=list)
original = Task("write", ["draft"])
copy = replace(original, title="review")
copy.tags.append("urgent")
print("original:", original)
print("copy: ", copy)
print("the list is shared:", original.tags is copy.tags)
test("replace copied the named field", copy.title, "review")
test("but shared the unnamed mutable one", original.tags, ["draft", "urgent"])
test("because it is the same object", original.tags is copy.tags, True)
What it printed, when the build ran it
original: Task(title='write', tags=['draft', 'urgent']) copy: Task(title='review', tags=['draft', 'urgent']) the list is shared: True ok replace copied the named field ok but shared the unnamed mutable one ok because it is the same object
Dataclass, NamedTuple, or dict?
- dataclass — the default for a record with named fields. Mutable unless frozen, supports methods, properties and defaults.
- NamedTuple — when it must also behave like a tuple: unpackable, indexable,
immutable and hashable without any extra argument. Lighter, but no mutability and no
__post_init__. - dict — when the keys are genuinely data rather than a known structure: parsed JSON, arbitrary configuration, anything where the field names are not fixed at the time you write the code.
from dataclasses import dataclass
from typing import NamedTuple
@dataclass(frozen=True)
class AsDataclass:
x: int
y: int
class AsNamedTuple(NamedTuple):
x: int
y: int
d, n = AsDataclass(1, 2), AsNamedTuple(1, 2)
print("both have named access:", d.x, n.x)
print("only the NamedTuple unpacks:", tuple(n))
print("only the NamedTuple indexes:", n[0])
try:
d[0]
except TypeError as exc:
print("indexing the dataclass:", type(exc).__name__)
print()
print("both are hashable here:", isinstance(hash(d), int), isinstance(hash(n), int))
test("named access works on both", (d.x, n.x), (1, 1))
test("the NamedTuple is a real tuple", isinstance(n, tuple), True)
test("the dataclass is not", isinstance(d, tuple), False)
test("and equality works on both", (d == AsDataclass(1, 2), n == (1, 2)), (True, True))
What it printed, when the build ran it
both have named access: 1 1 only the NamedTuple unpacks: (1, 2) only the NamedTuple indexes: 1 indexing the dataclass: TypeError both are hashable here: True True ok named access works on both ok the NamedTuple is a real tuple ok the dataclass is not ok and equality works on both
What to take from this lesson
@dataclassgenerates__init__,__repr__and__eq__from annotated fields — a field needs its annotation to exist.- A mutable default is refused at class creation. Use
field(default_factory=...). order=Truefor comparisons,frozen=Truefor immutability and hashability,slots=Truefor memory.__post_init__for validation and derived fields; withfrozen=Trueprefer a@property.asdictcopies deeply;replacecopies shallowly.