RizTech Academy logo
RizTech Academy
Object-Oriented PythonLesson 5 of 725 min

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

  1. Print an object with no __repr__. Add one and print it again, and inside a list.
  2. Add __str__ as well and compare print(e) with print([e]).
  3. Create two equal-looking objects and compare them. Add __eq__ and compare again.
  4. After adding __eq__, try putting one in a set. Read the error, then add __hash__.
  5. Add __lt__ and sort a list of objects. Then sort the same list with a key instead and decide which you prefer.
  6. Build ExpenseReport with __len__, __contains__ and __iter__. Use len, in and a for loop on it.
  7. Confirm an empty report is falsy.
  8. Write the Timer context manager and use it. Raise an exception inside the with block and confirm __exit__ still runs.
  9. 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