Dunder methods: making your objects behave like built-ins
__init__ was the first of these. There are many more, and together they are
what lets your own classes work with Python's syntax — print, ==, +,
len, in, for, with. This is why a Path can be divided with / and a
string can be multiplied.
"Dunder" is short for double underscore. You almost never call these directly; Python calls them when you use the corresponding syntax.
str and repr
The two most valuable, and the ones to write first.
Without them:
print(Expense(250, "food"))
<__main__.Expense object at 0x7f3c8a1b2d50>
That tells a reader nothing and makes debugging miserable.
class Expense:
def __init__(self, amount: float, category: str):
self.amount = amount
self.category = category
def __repr__(self) -> str:
return f"Expense(amount={self.amount}, category={self.category!r})"
def __str__(self) -> str:
return f"{self.category}: ₹{self.amount:,.2f}"
e = Expense(250, "food")
print(e) # food: ₹250.00
print(repr(e)) # Expense(amount=250, category='food')
[e] # [Expense(amount=250, category='food')]
__str__ is for humans. Used by print and str(). Readable.
__repr__ is for developers. Used by repr(), the REPL, and — importantly —
when an object appears inside a list or dictionary. The convention is that it
looks like the code needed to recreate the object.
Note the third example: printing a list of expenses uses __repr__, not
__str__. Define only __str__ and your lists still print gibberish, which is
exactly when you most need to read them.
If you write only one, write __repr__. Python falls back to it for str()
when __str__ is missing, so you get both. The reverse is not true.
This single method probably saves more debugging time than anything else in this module.
eq
By default, two objects are equal only if they are the same object:
a = Expense(250, "food")
b = Expense(250, "food")
print(a == b) # False
That is the is versus == distinction from module 3, and here the default
== behaves like is. Usually not what you want.
def __eq__(self, other) -> bool:
if not isinstance(other, Expense):
return NotImplemented
return (self.amount, self.category) == (other.amount, other.category)
Now a == b is True, and in works too, since it uses ==.
Returning NotImplemented for an unrelated type is the correct signal — Python
then tries the other object's comparison before deciding they are unequal.
Returning False directly would short-circuit that.
hash comes with it
Defining __eq__ sets __hash__ to None, making your objects unusable in
sets and as dictionary keys:
{a}
TypeError: unhashable type: 'Expense'
That is the unhashable error from module 4, now from your own class. Python
does this deliberately: two equal objects must hash the same, and it cannot
guess how.
If the object is immutable enough to be a key, say so:
def __hash__(self) -> int:
return hash((self.amount, self.category))
Same fields as __eq__. If the object is mutable, leaving it unhashable is the
right answer — a key that changes after insertion gets lost.
Ordering
def __lt__(self, other) -> bool:
return self.amount < other.amount
__lt__ alone is enough for sorted() and min/max:
print(sorted(expenses))
For the full set of comparisons without writing six methods:
from functools import total_ordering
@total_ordering
class Expense:
def __eq__(self, other): ...
def __lt__(self, other): ...
@total_ordering derives the rest. Another decorator, used without needing to
write one.
Often sorted(expenses, key=lambda e: e.amount) is clearer anyway — define
ordering on the class only when there is one obvious natural order.
Length, containment, iteration
These make your class behave like a collection:
class ExpenseReport:
def __init__(self, expenses: list[Expense]):
self._expenses = expenses
def __len__(self) -> int:
return len(self._expenses)
def __contains__(self, expense: Expense) -> bool:
return expense in self._expenses
def __iter__(self):
return iter(self._expenses)
def __getitem__(self, index: int) -> Expense:
return self._expenses[index]
report = ExpenseReport([a, b])
print(len(report))
print(a in report)
for expense in report:
print(expense)
print(report[0])
Four dunder methods, and your class now works with syntax every Python
programmer already knows. Nobody has to learn report.get_count() when len()
exists.
Note __len__ also makes truthiness work: an empty report is falsy, so
if report: reads correctly — connecting back to module 2.
Arithmetic
def __add__(self, other: "Expense") -> "Expense":
if self.category != other.category:
raise ValueError("Cannot add expenses in different categories")
return Expense(self.amount + other.amount, self.category)
print(a + b)
Use this sparingly. + on two expenses is defensible; * on two users is not.
Only overload an operator when the meaning is obvious to somebody who has not
read your class.
Context managers
Module 7 said __enter__ and __exit__ would be covered here.
class Timer:
def __init__(self, label: str):
self.label = label
def __enter__(self):
self.start = time.perf_counter()
return self
def __exit__(self, exc_type, exc_value, traceback) -> None:
print(f"{self.label}: {time.perf_counter() - self.start:.3f}s")
with Timer("loading"):
total = sum(range(10_000_000))
__enter__ runs on entry and whatever it returns is bound by as. __exit__
runs on the way out, guaranteed — normal exit, exception, or return.
The three arguments to __exit__ describe the exception, if any. They are all
None on a clean exit. Returning True from __exit__ suppresses the
exception, which is occasionally useful and mostly a way to hide bugs — return
None unless you specifically mean to swallow it.
This is the same thing @contextmanager produced in module 7, written out
longhand.
The ones worth knowing
| Method | Triggered by |
|---|---|
__init__ |
creating an object |
__repr__ |
repr(), the REPL, containers |
__str__ |
print(), str() |
__eq__ |
==, in |
__hash__ |
sets, dictionary keys |
__lt__ |
<, sorted() |
__len__ |
len(), truthiness |
__contains__ |
in |
__iter__ |
for |
__getitem__ |
obj[key] |
__call__ |
obj() |
__enter__ / __exit__ |
with |
How many to write
For most classes, __repr__ and nothing else. Add __eq__ when you compare
objects, __len__ and __iter__ when the class genuinely wraps a collection,
and the rest only when the syntax reads better than a named method.
The failure mode is a class with fifteen dunder methods, half of them doing something surprising. Dunder methods are a way to meet expectations, not to be clever.
Check your work
With no __repr__ you get <__main__.Expense object at 0x7f3c8a1b2d50>,
which is useless for debugging.
__str__ versus __repr__. print(e) uses __str__; print([e]) uses
__repr__, because containers call repr on their items. Define only
__str__ and your lists still print gibberish — exactly when you most need to
read them. If you write one, write __repr__: Python falls back to it for
str(), so you get both.
After adding __eq__, putting one in a set fails.
TypeError: unhashable type: 'Expense'
Defining __eq__ sets __hash__ to None, because two equal objects must hash
the same and Python cannot guess how. Add __hash__ using the same fields — or,
if the object is mutable, leave it unhashable, which is the correct answer.
__lt__ versus a key. sorted(expenses) needs __lt__;
sorted(expenses, key=lambda e: e.amount) needs nothing. Define ordering on the
class only when there is one obvious natural order.
An empty report is falsy, because __len__ returning 0 makes it so. That is
why if report: reads correctly, and it connects back to module 2.
__exit__ runs even when the block raises. Its three arguments describe the
exception and are all None on a clean exit. Returning True suppresses the
exception, which is mostly a way to hide bugs — return None unless you mean
to swallow it.
How many to write. For most classes, __repr__ and nothing else.
Practice
- Print an object with no
__repr__. Add one and print it again, and inside a list. - Add
__str__as well and compareprint(e)withprint([e]). - Create two equal-looking objects and compare them. Add
__eq__and compare again. - After adding
__eq__, try putting one in a set. Read the error, then add__hash__. - Add
__lt__and sort a list of objects. Then sort the same list with akeyinstead and decide which you prefer. - Build
ExpenseReportwith__len__,__contains__and__iter__. Uselen,inand aforloop on it. - Confirm an empty report is falsy.
- Write the
Timercontext manager and use it. Raise an exception inside thewithblock and confirm__exit__still runs. - Print the three arguments
__exit__receives, once on a clean exit and once with an exception.
Next: dataclasses, which write most of this for you.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship