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. Whatprint()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__— handlesself + other__radd__— handlesother + self(when other doesn’t know how)__iadd__— handlesself += 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.