Dunder Methods and the Data Model
Dunder Methods and the Data Model
Python’s operators and builtins are method calls with nicer spelling. len(x) is x.__len__(). a == b is a.__eq__(b). print(x) uses x.__repr__() unless you also wrote __str__. The boring default is to implement the few methods callers actually use — not a complete “numeric type” or a fake sequence.
Mental model
A dunder (double underscore) method is a hook the language looks up by name. The set of those hooks is the data model: how your type talks to for, +, repr, containers, and so on.
You do not register anything. You define the method. Python calls it.
Return NotImplemented (the singleton, not an exception) when an operator does not know the other type. Python can then try the other operand. Returning False from __eq__ for every foreign object shuts that down.
Do not implement a dunder because a list of magic methods looks thorough. Each one is an API you now have to keep honest.
Worked examples
Case 1: __repr__ that looks like a constructor
Save as ticket_repr.py. One debug form is enough for a desk record. Skip __str__ until you have a sentence for a person.
# ticket_repr.py
class Ticket:
def __init__(self, id, table):
self.id = id
self.table = table
def __repr__(self):
return f"Ticket(id={self.id!r}, table={self.table!r})"
def main():
t = Ticket(7, 12)
print(t)
print(repr(t))
if __name__ == "__main__":
main()Run:
uv run python ticket_repr.pyOutput:
Ticket(id=7, table=12)
Ticket(id=7, table=12)
print uses __str__ if you wrote it, otherwise __repr__. {self.id!r} puts quotes around strings so a table named 12 and a table numbered 12 stay distinguishable.
Case 2: __eq__ for the identity you mean
Save as ticket_eq.py. Two tickets with the same id are the same ticket even if the table changed.
# ticket_eq.py
class Ticket:
def __init__(self, id, table):
self.id = id
self.table = table
def __repr__(self):
return f"Ticket(id={self.id!r}, table={self.table!r})"
def __eq__(self, other):
if not isinstance(other, Ticket):
return NotImplemented
return self.id == other.id
def main():
a = Ticket(7, 12)
b = Ticket(7, 4)
c = Ticket(8, 12)
print(a == b)
print(a == c)
print(a == 7)
if __name__ == "__main__":
main()Run:
uv run python ticket_eq.pyOutput:
True
False
False
a == 7 is False because Ticket returns NotImplemented and int does not know tickets. That is the right shape. Do not special-case int here.
Case 3: __len__ and __iter__ for a small collection
Save as shift_board.py. If the object is a list of shifts, let len and for work. You do not need __getitem__ until someone indexes it.
# shift_board.py
class Shift:
def __init__(self, name, hours):
self.name = name
self.hours = hours
def __repr__(self):
return f"Shift({self.name!r}, {self.hours})"
class ShiftBoard:
def __init__(self, shifts):
self._shifts = list(shifts)
def __len__(self):
return len(self._shifts)
def __iter__(self):
return iter(self._shifts)
def main():
board = ShiftBoard(
[
Shift("open", 4),
Shift("mid", 6),
Shift("close", 5),
]
)
print(len(board))
for shift in board:
print(shift)
if __name__ == "__main__":
main()Run:
uv run python shift_board.pyOutput:
3
Shift('open', 4)
Shift('mid', 6)
Shift('close', 5)
__iter__ can return iter(self._shifts). Do not hand-write a custom iterator class for a list you already own.
Case 4: one operator, for an amount
Save as order_cents.py. Money-as-cents is a type that can add to itself. It cannot add to a bare int until you decide what that means.
# order_cents.py
class Cents:
def __init__(self, amount):
if amount < 0:
raise ValueError("cents cannot be negative")
self.amount = amount
def __repr__(self):
return f"Cents({self.amount})"
def __add__(self, other):
if not isinstance(other, Cents):
return NotImplemented
return Cents(self.amount + other.amount)
def __eq__(self, other):
if not isinstance(other, Cents):
return NotImplemented
return self.amount == other.amount
def main():
soup = Cents(450)
bread = Cents(200)
total = soup + bread
print(total)
print(total == Cents(650))
try:
soup + 200
except TypeError as e:
print(type(e).__name__)
if __name__ == "__main__":
main()Run:
uv run python order_cents.pyOutput:
Cents(650)
True
TypeError
Skip __iadd__, __radd__, __sub__, __mul__ until a caller needs them. __add__ already covers total = soup + bread. In-place += will use __add__ and rebind the name.
The trap
Two common overreaches: silent attributes, and equality that breaks sets.
Save as noisy_ticket.py. __getattr__ runs for missing names. A typo becomes a string instead of AttributeError.
# noisy_ticket.py
class Ticket:
def __init__(self, id, table):
self.id = id
self.table = table
def __bool__(self):
return self.table != 0
def __getattr__(self, name):
return f"missing:{name}"
def main():
t = Ticket(7, 12)
print(bool(t))
print(t.table)
print(t.tabl)
if __name__ == "__main__":
main()Run:
uv run python noisy_ticket.pyOutput:
True
12
missing:tabl
t.table still works (__getattr__ is not used when the attribute exists). t.tabl does not fail. Delete __getattr__. Let the typo raise.
The other overreach: __eq__ without a story for hashing.
# unhashable.py
class Ticket:
def __init__(self, id):
self.id = id
def __eq__(self, other):
if not isinstance(other, Ticket):
return NotImplemented
return self.id == other.id
def main():
try:
{Ticket(7), Ticket(8)}
except TypeError as e:
print(type(e).__name__)
print(e)
if __name__ == "__main__":
main()Run:
uv run python unhashable.pyOutput:
TypeError
cannot use 'Ticket' as a set element (unhashable type: 'Ticket')
User-defined __eq__ makes the type unhashable unless you also define __hash__. That is a feature: mutable records should not be set elements. Key a dict by ticket.id instead.
The boring rule
- Write
__repr__first. Make it look like a call that would rebuild the object. - Write
__eq__only when value equality is real. ReturnNotImplementedfor other types. - Write
__len__and__iter__when the object is a collection. Stop there until indexing is a real need. - Write
__add__(and friends) only for amounts. One operator is a type. Twelve operators are a science project. - Do not implement
__getattr__,__bool__, or__str__“while you are here.” - If you write
__eq__, either keep the type out of sets or define__hash__on an immutable value. Default: dict keyed by id.
Try this
- In
ticket_repr.py, add__str__that returnsticket 7 @ table 12. Print bothtandrepr(t). - In
shift_board.py, add a functiontotal_hours(board)that loops withforand sumsshift.hours. - In
order_cents.py, add__mul__soCents(450) * 2isCents(900). Reject a non-intwithNotImplemented. - Remove
__getattr__fromnoisy_ticket.pyand confirmt.tablraisesAttributeError.