Dunder methods: making your own objects work with len, for, in and ==
Syntax is a method call
When you write len(x), Python calls x.__len__(). When you write for item in x, Python calls x.__iter__(). x == y is x.__eq__(y), x[3] is x.__getitem__(3), and x + y is x.__add__(y). The double-underscore names — "dunder" — are hooks that built-in syntax reaches for, and a class that implements them behaves like a built-in type.
You have used two already: __init__ and __repr__. Here are the ones that make a data-holding class pleasant to use, with the trap in each.
__len__ and __bool__
class Conversation:
...
def __len__(self):
return len(self.messages)Now len(conv) works. So does something you may not want: if conv: is now false for an empty conversation, because when a class defines __len__ and not __bool__, truthiness falls through to len(x) != 0. This is how empty lists and dicts are falsy. If you want an object to be truthy regardless, define __bool__ returning True.
__iter__
def __iter__(self):
return iter(self.messages)for m in conv: now walks the messages, and so does list(conv), any(... for m in conv), and unpacking. Returning iter(self.messages) delegates to the list's own iterator. A generator works too:
def __iter__(self):
for m in self.messages:
if m["role"] != "system":
yield mwhich hides the system prompt from anyone iterating. The next lesson covers what an iterator is underneath.
__getitem__ and __contains__
def __getitem__(self, i):
return self.messages[i]conv[0] and conv[-1] work, and so does conv[1:3], because slices are passed to __getitem__ as slice objects and the list handles them. A class with __getitem__ and no __iter__ is still iterable: Python calls __getitem__ with 0, 1, 2 until IndexError. That is a fallback, not a design.
in uses __contains__ if present, otherwise iterates and compares. For a large collection, a __contains__ backed by a set is the difference between O(1) and O(n).
__eq__ and the hash rule
The default == on your own class is identity: two Usage objects with identical fields are unequal unless they are the same object. To compare by value:
def __eq__(self, other):
if not isinstance(other, Usage):
return NotImplemented
return (self.input_tokens, self.output_tokens, self.model) == \
(other.input_tokens, other.output_tokens, other.model)Return NotImplemented — not False — for a type you cannot compare; that lets Python try the other operand's __eq__ before giving up.
Now the trap. The moment you define __eq__, Python sets __hash__ to None on your class. Instances can no longer go in a set or be dict keys. The reason is a rule: objects that compare equal must have the same hash, and the default hash is based on identity, which your new __eq__ no longer respects. Python cannot know what you meant, so it removes the hash rather than leave a broken one. Restore it yourself from the same fields:
def __hash__(self):
return hash((self.input_tokens, self.output_tokens, self.model))Only do this for objects whose fields will not change after creation. A hash computed from mutable fields goes stale when a field changes, and a set containing such an object will quietly fail to find it. This is why dataclasses give you __hash__ only when frozen=True.
__enter__ and __exit__
class Timer:
def __enter__(self):
self.start = time.perf_counter()
return self
def __exit__(self, exc_type, exc, tb):
self.elapsed = time.perf_counter() - self.start
return Falsewith Timer() as t:
reply = provider.complete(messages)
print(f"{t.elapsed:.2f}s")with calls __enter__ at the top and __exit__ at the bottom, including when the block raises. The three arguments to __exit__ describe the exception, or are all None. Returning False lets the exception continue; returning True swallows it, which is almost never what you want. Context managers get their own lesson shortly.
__str__ versus __repr__
__repr__ is for developers: unambiguous, ideally something you could paste back into the shell. __str__ is for people: print(x) and f-strings use it, falling back to __repr__ if it is missing. Write __repr__ always; add __str__ when a friendlier display is needed.
__call__
An object with __call__ can be used like a function:
class Prompt:
def __init__(self, template):
self.template = template
def __call__(self, **kw):
return self.template.format(**kw)
greet = Prompt("Hello, {name}. Summarise: {text}")
greet(name="Asha", text=doc)It is how a class can be handed to code that expects a function while carrying state. Decorators in a later lesson lean on it.
Restraint
Every dunder you define is a promise that your object behaves like the built-in whose syntax it borrows. __add__ on a Conversation that merges two conversations may seem clever, but a reader who sees a + b expects arithmetic-like behaviour — commutative, side-effect-free — and merging is neither. When the meaning is not obvious from the operator, a named method is kinder. __len__, __iter__, __repr__, __eq__ with __hash__: those four carry no such risk and belong on almost every class that holds a collection.
Try this now
Give Conversation a __len__, __iter__ that skips the system message, and __getitem__. Then add __eq__ to Usage, try to put one in a set, read the error, and add __hash__.
The one thing to keep
Built-in syntax is dispatched to double-underscore methods — len() calls __len__, for calls __iter__, == calls __eq__ — so implementing them makes a class feel native, and defining __eq__ without __hash__ silently makes instances unhashable.
Before you move on
A developer adds `__eq__` to a `Document` class so that two documents with the same text compare equal. Afterwards, `seen = set(); seen.add(doc)` raises `TypeError: unhashable type: 'Document'`, though it worked before. What happened?
Pick the one you would defend. Nobody sees your answer.