Customizing Object Behavior

Dunder (double-underscore) methods are Python's protocol for customizing how your objects integrate with the language. Implement __len__ and len(obj) works. Implement __iter__ and for x in obj works. Implement __eq__ and == works. Python calls these methods behind familiar syntax; giving your class the right ones makes it feel “native”.

The most common dunders fall into three groups. Representation: __str__ (for str() and print), __repr__ (for the debugger and the REPL). Comparison: __eq__, __lt__, __hash__. Container-like: __len__, __iter__, __getitem__, __contains__. Start with these; skip the exotic ones until you need them.

Contracts matter. If you implement __eq__, you should also implement __hash__ (or set it to None to mark the object unhashable). If you implement __lt__, consider functools.total_ordering to fill in the other comparison operators from that one. If you implement __iter__, also implement __len__ when the length is known.

Go light on dunders. Too much magic makes a class hard to read: if + does something completely unrelated to addition, you surprise readers. A good rule: implement a dunder only when the class really behaves like the concept the operator or built-in represents.

Representation and comparison

__repr__ should return a string that, ideally, is valid Python to recreate the object: Point(3, 4). It is what the REPL prints. __str__ is the human-friendly version used by print; if you only define one, pick __repr__.

__eq__ + __hash__ usually move together. If your class is immutable, return the hash of a tuple of the identifying fields. If it is mutable, either forgo hashing (set __hash__ = None) or base the hash on an immutable identity.

Container protocols and with statements

Implement __len__, __getitem__, __iter__, and __contains__ to make your class feel like a sequence. Dictionaries use __getitem__ for lookup; iteration uses __iter__; for loops work from either.

For the with statement, implement __enter__ and __exit__ on the class (or decorate a generator with contextlib.contextmanager). Most resources you build should consider being a context manager.

A starter set of dunder methods.

ToolPurpose
__repr__
method
Unambiguous developer-facing text.
__str__
method
Human-friendly text (fallback: __repr__).
__eq__, __hash__
methods
Value-based equality and hashing.
__len__
method
Makes len(obj) work.
__iter__, __next__
methods
Custom iteration protocol.
__getitem__
method
Indexing: obj[key].
__contains__
method
Membership: x in obj.
@total_ordering
decorator
Fills in comparison operators from one.

Customizing Object Behavior code example

The script defines a small Deck class that supports len, iteration, indexing, containment, and sorting.

# Lesson: Customizing Object Behavior
from dataclasses import dataclass
from functools import total_ordering


@total_ordering
@dataclass(frozen=True)
class Card:
    rank: int    # 2..14 (14 = Ace)
    suit: str    # one of "cdhs"

    def __lt__(self, other: "Card") -> bool:
        return (self.rank, self.suit) < (other.rank, other.suit)

    def __str__(self) -> str:
        names = {11: "J", 12: "Q", 13: "K", 14: "A"}
        rank = names.get(self.rank, str(self.rank))
        return f"{rank}{self.suit}"


class Deck:
    def __init__(self):
        self._cards = [Card(r, s) for s in "cdhs" for r in range(2, 15)]

    def __len__(self) -> int:
        return len(self._cards)

    def __iter__(self):
        return iter(self._cards)

    def __getitem__(self, index):
        return self._cards[index]

    def __contains__(self, card: Card) -> bool:
        return card in self._cards

    def __repr__(self) -> str:
        return f"Deck({len(self)} cards)"


deck = Deck()
print(deck)                  # __repr__
print("len:", len(deck))    # __len__
print("first 3:", [str(c) for c in deck[:3]])   # __getitem__
print("Ace of spades?", Card(14, "s") in deck)  # __contains__

sorted_cards = sorted(deck)
print("top 3:", [str(c) for c in sorted_cards[-3:]])  # __lt__ via @total_ordering

# Iterate with for
counts = {s: 0 for s in "cdhs"}
for card in deck:
    counts[card.suit] += 1
print("by suit:", counts)

Observe how Python drives each dunder:

1) `print(deck)` calls `__repr__` because no `__str__` is defined.
2) `len(deck)`, `deck[:3]`, and `card in deck` each go through one dunder.
3) `sorted(deck)` uses `__lt__`; `@total_ordering` fills the rest in.
4) `for card in deck` uses `__iter__`.

Implement a small vector class with + and ==.

from dataclasses import dataclass

@dataclass(frozen=True)
class Vec2:
    x: float
    y: float
    def __add__(self, other: "Vec2") -> "Vec2":
        return Vec2(self.x + other.x, self.y + other.y)
    def __abs__(self) -> float:
        return (self.x * self.x + self.y * self.y) ** 0.5

print(Vec2(1, 2) + Vec2(3, 4))        # Vec2(x=4, y=6)
print(abs(Vec2(3, 4)))                  # 5.0
print(Vec2(1, 2) == Vec2(1, 2))         # True

Verify a handful of dunder contracts.

class A:
    def __init__(self, xs): self.xs = xs
    def __len__(self): return len(self.xs)
    def __iter__(self): return iter(self.xs)
    def __contains__(self, x): return x in self.xs
a = A([1, 2, 3])
assert len(a) == 3
assert list(a) == [1, 2, 3]
assert 2 in a and 99 not in a

Running prints:

Deck(52 cards)
len: 52
first 3: ['2c', '3c', '4c']
Ace of spades? True
top 3: ['Ah', 'As', 'Ad']
by suit: {'c': 13, 'd': 13, 'h': 13, 's': 13}