Dunder Methods Deep Dive

intermediate dunder magic-methods data-model __str__ __repr__ __eq__ __add__

Dunder (double underscore) methods are how we hook into Python’s built-in behavior. When we write len(obj), Python actually calls obj.__len__(). When we write a + b, Python calls a.__add__(b). These methods are the data model — they let our objects play nice with Python’s syntax.

Representation: __str__ vs __repr__

These two control how our object shows up as text.

  • __repr__ — the “developer” version. Should be unambiguous. What the REPL shows.
  • __str__ — the “user” version. What print() uses. If missing, falls back to __repr__.

Rule of thumb: always implement __repr__. Only add __str__ if we want a prettier output.

class Money:
    def __init__(self, amount, currency="USD"):
        self.amount = amount
        self.currency = currency

    def __repr__(self):
        return f"Money({self.amount}, '{self.currency}')"

    def __str__(self):
        return f"${self.amount:.2f} {self.currency}"

m = Money(42.5)
print(repr(m))  # Money(42.5, 'USD') — unambiguous
print(m)        # $42.50 USD — pretty

There’s also __format__, which powers f-strings and format():

def __format__(self, spec):
    if spec == "short":
        return f"${self.amount:.0f}"
    return str(self)

Comparison: __eq__, __lt__, and Friends

By default, == compares object identity (same as is). We override __eq__ to compare by value instead.

class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __eq__(self, other):
        if not isinstance(other, Point):
            return NotImplemented  # let Python try other.__eq__
        return self.x == other.x and self.y == other.y

    def __lt__(self, other):
        return (self.x ** 2 + self.y ** 2) < (other.x ** 2 + other.y ** 2)

print(Point(1, 2) == Point(1, 2))  # True
print(Point(1, 2) < Point(3, 4))   # True — closer to origin

The __eq__/__hash__ contract: if we define __eq__, Python automatically sets __hash__ to None, making our objects unhashable (can’t be used in sets or as dict keys). If we need that, we must also define __hash__.

def __hash__(self):
    return hash((self.x, self.y))  # must be consistent with __eq__

Arithmetic: __add__, __radd__, __iadd__

Three flavors of addition alone:

  • __add__ — handles self + other
  • __radd__ — handles other + self (when other doesn’t know how)
  • __iadd__ — handles self += other
class Vector:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)

    def __radd__(self, other):
        if other == 0:  # needed for sum() to work
            return self
        return self.__add__(other)

    def __mul__(self, scalar):
        return Vector(self.x * scalar, self.y * scalar)

    def __repr__(self):
        return f"Vector({self.x}, {self.y})"

v = Vector(1, 2) + Vector(3, 4)
print(v)  # Vector(4, 6)

Container Protocol: __len__, __getitem__, __iter__

These let our object behave like a list or dictionary.

class Playlist:
    def __init__(self, songs):
        self._songs = list(songs)

    def __len__(self):
        return len(self._songs)

    def __getitem__(self, index):       # playlist[0], playlist[1:3]
        return self._songs[index]

    def __contains__(self, song):       # "Hey Jude" in playlist
        return song in self._songs

    def __iter__(self):                 # for song in playlist
        return iter(self._songs)

pl = Playlist(["Hey Jude", "Yesterday", "Let It Be"])
print(len(pl))           # 3
print(pl[0])             # Hey Jude
print("Yesterday" in pl) # True

With just __getitem__, Python can already iterate over our object. But adding __iter__ is more explicit and efficient.

Callable Objects: __call__

__call__ makes an instance callable like a function. This is great for objects that need to maintain state between calls.

class Counter:
    def __init__(self):
        self.count = 0

    def __call__(self):
        self.count += 1
        return self.count

counter = Counter()
print(counter())  # 1
print(counter())  # 2
print(counter())  # 3

Context Manager: __enter__ and __exit__

These power the with statement. __enter__ sets things up, __exit__ cleans up — even if an exception happens.

class Timer:
    def __enter__(self):
        import time
        self.start = time.perf_counter()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        import time
        self.elapsed = time.perf_counter() - self.start
        print(f"Took {self.elapsed:.4f}s")
        return False  # don't suppress exceptions

with Timer():
    sum(range(1_000_000))  # Took 0.0234s

Boolean: __bool__

Controls what happens when we use our object in a boolean context (if obj:, bool(obj)).

class Bag:
    def __init__(self, items):
        self.items = items

    def __bool__(self):
        return len(self.items) > 0  # empty bag is falsy

bag = Bag([])
if not bag:
    print("Bag is empty!")  # this runs

If __bool__ isn’t defined, Python falls back to __len__. If neither exists, the object is always truthy.

In simple language, dunder methods are hooks into Python’s syntax. By implementing the right ones, our objects can work with +, ==, len(), for loops, with statements, and everything else Python offers. We’re customizing how Python treats our objects.