The Data Model and Dunder Methods
Make small domain objects cooperate with Python syntax through coherent, minimal protocols.
By the end, you’ll be able to…
- Implement useful representation, iteration, and containment.
- Respect equality and hashing invariants.
- Return NotImplemented for unsupported binary operands.
01Syntax calls protocols
Python syntax delegates behavior to special methods on a type. len(value), iteration, indexing, arithmetic, and with statements each have a protocol. Implementing a small coherent protocol lets existing tools work with your object. It is usually more useful than adding many special methods to make an object imitate several unrelated built-in types at once.
Special methods are normally looked up on the class, so assigning an instance attribute named __len__ is not a reliable way to change len(instance). Design behavior on the type. Begin with a clear invariant: for a playlist, stored tracks preserve order and clients cannot mutate the private sequence accidentally. The tuple inside this example provides a stable snapshot of the input iterable.
02Representations serve different readers
__repr__ should help a developer identify an object and investigate its state; __str__ can provide a friendly display. An evaluable repr is useful when practical, but clarity is more important than pretending every object can be reconstructed. Neither representation should disclose tokens, passwords, or sensitive payloads merely because the object appears in a log or an assertion failure.
A protocol carries expectations beyond the existence of a method. __len__ must return a nonnegative integer. __iter__ should return an iterator, and a container usually returns a fresh one for every call. Indexing should raise IndexError when an index is outside a finite sequence. Surprising contracts force every consumer to learn private exceptions and defeat the benefit of ordinary Python syntax.
03Equality and hashing must agree
If two objects compare equal, their hashes must match whenever they are hashable. Keys must not change the state used for equality while they live in a dictionary or set. A mutable value object is therefore usually unhashable. A frozen dataclass can provide value equality and a suitable generated hash when all of its participating fields are themselves hashable.
For binary operators, return NotImplemented when the operand type is unsupported. This gives Python an opportunity to try the reflected operation or report a useful TypeError. Raising NotImplementedError means something different: it is an exception commonly used for an operation deliberately left unimplemented. A money type would also need currency rules and rounding policy; a numeric wrapper alone cannot establish those domain decisions.
04Implement the smallest honest interface
Protocols should simplify a real use case. A configuration object may need readable representation and key access, while a resource owner may need __enter__ and __exit__. It rarely needs arithmetic. Prefer composition with standard containers to a partial imitation of every list method. Collections abstract base classes can document a contract, but registering a type does not magically supply missing behavior.
Test the syntax that callers use, including unsupported operands and empty values. Verify that two iterations over a container are independent and that equal immutable values behave as one set member. Keep object construction invariants visible and validate them once. The result should make ordinary code easier to predict; clever syntax that conceals mutation or remote requests usually makes debugging harder.
The ideas worth keeping.
- Implement protocols around explicit domain invariants.
- Hashable keys need stable equality-related state.
- NotImplemented is a dispatch signal, not an exception.
Make it yours.
Type and test your code right here in the interactive workspace. Run your solution, check the output, and compare with the worked guide when ready.
A reusable range container
Implement Countdown(top) for nonnegative integers. It must support len() and repeated independent iteration from top to 1. Reject a negative top.
Explore a worked solution
class Countdown: def __init__(self, top): if type(top) is not int or top < 0: raise ValueError('top must be a nonnegative integer') self.top = top def __len__(self): return self.top def __iter__(self): return iter(range(self.top, 0, -1))countdown = Countdown(3)assert len(countdown) == 3assert list(countdown) == list(countdown) == [3, 2, 1]The container creates a fresh range iterator for every iteration. It does not keep a shared cursor on itself.
An immutable lookup key
Use a frozen dataclass for Route(origin, destination). Verify duplicate routes collapse in a set and reverse direction remains distinct.
Explore a worked solution
from dataclasses import dataclass@dataclass(frozen=True)class Route: origin: str destination: strroutes = {Route('A', 'B'), Route('A', 'B'), Route('B', 'A')}assert len(routes) == 2assert Route('A', 'B') in routesThe generated equality uses ordered field values. Immutable string fields make the generated hash consistent with that equality.
What stayed with you?
Check the ideas while they’re fresh. Your answers stay in this page and feedback appears as you go.
A reusable range container
Implement Countdown(top) for nonnegative integers. It must support len() and repeated independent iteration from top to 1. Reject a negative top.
[Process exited with code 0 - No output]
Keep exploring.
The official references behind this chapter, when you want a second angle or the full details.
One chapter closer.
Reading is a start. Trying it yourself makes the connection.