Emmanuel Akinfulubi

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

Three values passed everywhere, versus held once

ran at build time
# 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

self is the first parameter, and nothing more

ran at build time
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.

A method defined without self

raises TypeError
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.

Shared by the class, or owned by the instance

ran at build time
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.

One list, every instance

ran at build time
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.

__repr__ is the one method every class should have

ran at build time
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.

Underscores, and what name mangling actually does

ran at build time
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

__slots__ trades flexibility for memory

ran at build time
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.
  • self is 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

The syntax calls the method

ran at build time
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.

Two string methods, and which one containers use

ran at build time
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.

Defining __eq__ removes __hash__

ran at build time
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

And so the type cannot go in a set

raises TypeError
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.

Restoring __hash__, based on the same values as __eq__

ran at build time
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.

Why a hashable type should be immutable

ran at build time
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

One comparison plus @total_ordering

ran at build time
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.

Operator overloading, with NotImplemented as the escape hatch

ran at build time
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

__bool__, __call__, __getattr__

ran at build time
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

Six dunders, and the type works everywhere

ran at build time
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__ to None. Add __hash__ back only if the type is immutable, hashing the same fields.
  • Define __eq__ and __lt__ and let @total_ordering derive the rest.
  • Return NotImplemented from 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

Subclassing, overriding, and super()

ran at build time
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.

The parent never ran, so the attribute is absent

raises AttributeError
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.

A diamond, and the order Python computes

ran at build time
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.

super() follows the MRO, not the class you named

ran at build time
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

The same feature, two relationships

ran at build time
# 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.

Swapping the collaborator, which is why this is testable

ran at build time
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.

abstractmethod fails at construction, not at first call

ran at build time
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.

Failing early beats failing on first call

ran at build time
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 ValidationError really is an Exception.
  • 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.

Square is not a Rectangle

ran at build time
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.abstractmethod when 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

The starting point: no ceremony at all

ran at build time
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.

The same interface, now validated

ran at build time
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

Derived values cannot go stale

ran at build time
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.

Stored versus computed, after an input changes

ran at build time
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.

Named constructors, on the class

ran at build time
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.

cls is what makes it inherit correctly

ran at build time
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

static, class and instance methods side by side

ran at build time
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

Getter, setter and deleter

ran at build time
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_property for expensive work.
  • classmethod for alternative constructors, and use cls so subclasses inherit them correctly.
  • staticmethod when 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

Twenty lines, or four

ran at build time
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

The generated methods, and the __hash__ that went with __eq__

ran at build time
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.

A missing annotation means a missing field

ran at build time
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.

The language refuses the lesson-15 trap

raises ValueError
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

default_factory, called once per instance

ran at build time
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

order=True and frozen=True

ran at build time
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

frozen=True means assignment is refused

raises FrozenInstanceError
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'

slots=True, for when there will be very many

ran at build time
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

Derived fields with init=False, validated in __post_init__

ran at build time
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.

A property, or the deliberate bypass

ran at build time
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

asdict, astuple and replace

ran at build time
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.

replace is a shallow copy

ran at build time
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.

dataclass versus NamedTuple

ran at build time
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

  • @dataclass generates __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=True for comparisons, frozen=True for immutability and hashability, slots=True for memory.
  • __post_init__ for validation and derived fields; with frozen=True prefer a @property.
  • asdict copies deeply; replace copies shallowly.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.