Addaly is in open beta. Things will change, and AI answers can be wrong — check anything that matters.

Python, From Zero, For AI

From your first line of code to your first API call.

Lesson 63 of 899 min

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__

python
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__

python
    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:

python
    def __iter__(self):
        for m in self.messages:
            if m["role"] != "system":
                yield m

which hides the system prompt from anyone iterating. The next lesson covers what an iterator is underneath.

__getitem__ and __contains__

python
    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:

python
    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:

python
    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__

python
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 False
python
with 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:

python
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.

No ads. No data sale. No public scores on people. Ever.

© 2026 Addaly