Dunder methods¶
A dunder method (from "double underscore") is a method with a name like __repr__ or __eq__. Python calls these on your behalf when you use built-in syntax or functions: repr(x) calls x.__repr__(), x == y calls x.__eq__(y), len(x) calls x.__len__(), and so on.
Implementing the right dunders is what makes a class feel Pythonic — your objects behave like built-in types, play well with print, sorted, set, dict, and debugging tools. This notebook covers the dunders you'll reach for ninety percent of the time. The reference catalogue is the place to look up the rest.
__repr__ — a useful string for debugging¶
Without __repr__, your class prints as that ugly memory-address string. Fix that first — it's the highest-leverage dunder by a long way.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(3, 4)
print(p)
print(repr(p))
Both print(p) and repr(p) give the ugly form because there's no __repr__ defined. Add one:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Point(x={self.x}, y={self.y})"
p = Point(3, 4)
print(p)
print(repr(p))
print([Point(1, 2), Point(3, 4)]) # containers call repr on their elements
Convention: __repr__ should look like Python code that would reconstruct the object — Point(x=3, y=4) rather than point at (3, 4). It doesn't have to actually be valid code, but it should look like it, so that when you see the string in a log or a traceback you know exactly what you're looking at.
The print([Point(1, 2), Point(3, 4)]) call above is the reason this matters in practice: as soon as your objects end up in lists, dicts, or tracebacks, their __repr__ is what you see.
__str__ — the user-facing version¶
__str__ is what print(x) and str(x) use. If you don't define it, Python falls back to __repr__, which is usually what you want. Only define a separate __str__ when the user-facing form should differ from the debugging form.
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
def __repr__(self):
return f"Temperature(celsius={self.celsius})"
def __str__(self):
return f"{self.celsius}°C"
t = Temperature(22)
print(str(t)) # user-facing
print(repr(t)) # debugging
Rule of thumb: always implement __repr__. Only implement __str__ if there's a genuine reason the user-facing and debugging forms should differ.
__eq__ — equality that compares values¶
By default, two instances are equal only if they're the same object. That's usually not what you want for value-like types.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
a = Point(3, 4)
b = Point(3, 4)
print(a == b) # False — different objects
print(a == a) # True — same object
Implement __eq__ to compare the things that actually matter:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return self.x == other.x and self.y == other.y
a = Point(3, 4)
b = Point(3, 4)
print(a == b)
Two details worth pausing on:
- Returning
NotImplemented(the built-in sentinel, not raisingNotImplementedError) whenotherisn't a compatible type is the right move. It tells Python to tryother.__eq__(self)before giving up — soPoint(1, 2) == "hello"returnsFalserather than crashing. - Never call
super().__eq__orobject.__eq__as your equality check. Default object equality is identity (is), so you'd be back where you started.
The __eq__ / __hash__ pair¶
Defining __eq__ removes the default __hash__. Instances of the class become unhashable — they can't go in a set or be keys in a dict.
a = Point(3, 4)
try:
{a}
except TypeError as e:
print(f"{type(e).__name__}: {e}")
The rule is that objects which compare equal must have the same hash. Python doesn't know how to satisfy that automatically once you override __eq__, so it removes __hash__ to stop you from introducing a subtle bug.
If the class is logically immutable (its equality-relevant fields don't change), add __hash__:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Point({self.x}, {self.y})"
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return self.x == other.x and self.y == other.y
def __hash__(self):
return hash((self.x, self.y))
print({Point(3, 4), Point(3, 4), Point(1, 2)}) # one duplicate collapses
Don't define __hash__ on mutable classes. If an instance's hash can change while it's in a set or dict, you've broken the container's invariants — lookups will silently fail to find the object.
Ordering — __lt__ and @total_ordering¶
sorted() and the < operator need __lt__ ("less than"). Without it, you get a TypeError.
pts = [Point(3, 4), Point(1, 2), Point(5, 0)]
try:
sorted(pts)
except TypeError as e:
print(f"{type(e).__name__}: {e}")
Add __lt__ and sorting works:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Point({self.x}, {self.y})"
def __lt__(self, other):
return (self.x, self.y) < (other.x, other.y)
pts = [Point(3, 4), Point(1, 2), Point(5, 0)]
print(sorted(pts))
That works, but < is only one of six ordering operators (<, <=, >, >=, ==, !=). If you want all of them, either define them all — tedious — or use functools.total_ordering, which fills in the missing four from whichever one you provide plus __eq__:
from functools import total_ordering
@total_ordering
class Priority:
def __init__(self, level):
self.level = level
def __eq__(self, other):
if not isinstance(other, Priority):
return NotImplemented
return self.level == other.level
def __lt__(self, other):
if not isinstance(other, Priority):
return NotImplemented
return self.level < other.level
p1, p2 = Priority(1), Priority(5)
print(p1 < p2, p1 <= p2, p1 >= p2, p1 > p2)
total_ordering is convenient but adds a tiny runtime cost on each comparison. For hot code paths you might prefer to write all four out by hand; for almost everything else, reach for the decorator.
__len__ and truthiness¶
Implementing __len__ makes len(x) work on your class. It also makes your class truthy when non-empty, falsy when empty, without any extra work — see the conditional logic guide for the rules.
class Inventory:
def __init__(self):
self.items = []
def add(self, item):
self.items.append(item)
def __len__(self):
return len(self.items)
inv = Inventory()
print(len(inv), bool(inv))
inv.add("widget")
print(len(inv), bool(inv))
The other dunders, briefly¶
The dunders we've covered here are the ones you'll add to almost every class worth making. There are others, grouped by role:
- Container behaviour —
__iter__,__getitem__,__setitem__,__contains__,__len__. See make a class iterable or container-like. - Arithmetic —
__add__,__mul__,__neg__, and the rest. Useful for numeric-like types. - Context managers —
__enter__and__exit__forwithblocks. - Attribute access —
__getattr__,__setattr__,__delattr__,__getattribute__. Powerful but easy to misuse. - Callable —
__call__lets you use an instance like a function.
The dunder methods catalogue has the full list with signatures and typical use cases.
Exercise¶
Build a Money class representing an amount in a currency (store the amount as an integer number of pennies to avoid float rounding). Give it:
__init__(self, pennies, currency="GBP").- A
__repr__likeMoney(pennies=150, currency='GBP'). - A
__str__like£1.50. __eq__that compares amount and currency.__lt__that compares amounts — but only when currencies match (raiseValueErrorotherwise).- Decorate with
@total_orderingto get the full set of comparisons.
Test that two Money(150) instances compare equal, that Money(100) < Money(150) is true, and that comparing Money(100, "GBP") with Money(100, "USD") raises.
# Your code here
Solution
from functools import total_ordering
CURRENCY_SYMBOLS = {"GBP": "£", "USD": "$", "EUR": "€"}
@total_ordering
class Money:
def __init__(self, pennies, currency="GBP"):
self.pennies = pennies
self.currency = currency
def __repr__(self):
return f"Money(pennies={self.pennies}, currency={self.currency!r})"
def __str__(self):
symbol = CURRENCY_SYMBOLS.get(self.currency, self.currency + " ")
return f"{symbol}{self.pennies / 100:.2f}"
def __eq__(self, other):
if not isinstance(other, Money):
return NotImplemented
return self.pennies == other.pennies and self.currency == other.currency
def __lt__(self, other):
if not isinstance(other, Money):
return NotImplemented
if self.currency != other.currency:
raise ValueError(
f"cannot compare {self.currency} with {other.currency}"
)
return self.pennies < other.pennies
def __hash__(self):
return hash((self.pennies, self.currency))
Recap¶
- Always implement
__repr__. It's the single most useful dunder. - Implement
__str__only if the user-facing form should differ from the debugging form. __eq__makes value-equality work. ReturnNotImplementedfor unknown types, notFalse.- Defining
__eq__removes__hash__— add it back for immutable classes, leave it off for mutable ones. - For ordering, define
__lt__plus__eq__, then apply@functools.total_ordering. __len__makeslen()work and gives you free truthiness.
Next: Data classes, which generate most of these dunders automatically from a field list.