len(x) calls x.__len__(). a + b calls a.__add__(b). for i in x calls x.__iter__(). Python’s syntax is a set of questions, and dunder methods are how your object answers them.
Implement the right ones and your class stops feeling bolted on.
A value type, done properly
class Money:
def __init__(self, amount, currency='BRL'):
self.amount, self.currency = amount, currency
def __repr__(self):
return f"Money({self.amount!r}, {self.currency!r})"
def __eq__(self, other):
if not isinstance(other, Money): return NotImplemented
return (self.amount, self.currency) == (other.amount, other.currency)
def __hash__(self):
return hash((self.amount, self.currency))
def __add__(self, other):
if not isinstance(other, Money): return NotImplemented
if other.currency != self.currency:
raise ValueError(f"cannot add {other.currency} to {self.currency}")
return Money(self.amount + other.amount, self.currency)
def __lt__(self, other):
return self.amount < other.amount
a, b = Money(10), Money(10)
print(a == b, a is b)
print(a + Money(5))
print(sorted([Money(30), Money(10), Money(20)]))
print({Money(10), Money(10), Money(20)})
It prints:
True False
Money(15, 'BRL')
[Money(10, 'BRL'), Money(20, 'BRL'), Money(30, 'BRL')]
{Money(10, 'BRL'), Money(20, 'BRL')}
Four separate wins from four methods. Equality by value not identity. Addition. Sorting with no key function, from __lt__ alone. Set deduplication, from __hash__.
__eq__ without __hash__ breaks your object
Define __eq__ and Python sets __hash__ to None, because two objects that compare equal must hash equal and Python cannot guess how:
class NoHash:
def __init__(self, v): self.v = v
def __eq__(self, other): return self.v == other.v
try:
{NoHash(1)}
except TypeError as err:
print(type(err).__name__ + ':', err)
It prints:
TypeError: unhashable type: 'NoHash'
The object can no longer go in a set or be a dict key. If your class is a value type, define both, over the same fields, as Money does. If it is genuinely mutable, leaving it unhashable is the correct outcome.
Return NotImplemented, not False
print(a == 'not money')
try:
a + 5
except TypeError as err:
print(type(err).__name__ + ':', err)
It prints:
False
unsupported operand type(s) for +: 'Money' and 'int'
Returning NotImplemented tells Python “I cannot handle this, try the other operand”. Python then falls back — to identity comparison for ==, and to a clear TypeError for +. Return False from __eq__ instead and you break the other type’s chance to answer.
Note NotImplemented is a value you return. NotImplementedError is an exception you raise, for abstract methods. Confusing them is common.
Containers get two behaviours free
class Deck:
def __init__(self, cards): self._cards = list(cards)
def __len__(self): return len(self._cards)
def __getitem__(self, i): return self._cards[i]
d = Deck(['A', 'K', 'Q', 'J'])
print(len(d), d[0], d[-1], d[1:3])
print([c for c in d]) # iteration for free from __getitem__
print('K' in d) # membership for free too
It prints:
4 A J ['K', 'Q']
['A', 'K', 'Q', 'J']
True
Two methods bought indexing, negative indexing, slicing, iteration and in. Iteration and membership come free because Python falls back to __getitem__ when __iter__ and __contains__ are missing. Slicing works because the index is passed straight to the list.
Truthiness
class Basket:
def __init__(self, items): self.items = items
def __len__(self): return len(self.items)
print(bool(Basket([])), bool(Basket(['apple'])))
It prints:
False True
if basket: now means “if the basket has anything in it”. Python asks __bool__, and falls back to __len__. Without either, every object is truthy — which is why if my_object: on a class with no __len__ is always True and never the check you meant.
Callable objects
class Multiplier:
def __init__(self, by): self.by = by
def __call__(self, x): return x * self.by
triple = Multiplier(3)
print(triple(5), list(map(triple, [1, 2, 3])))
It prints:
15 [3, 6, 9]
An object that behaves like a function but carries state. This is what makes decorators-as-classes and configurable callbacks work.
What to remember
-
__repr__always;__eq__and__hash__together for value types. -
Return
NotImplementedfrom operators you cannot handle, neverFalse. -
__len__and__getitem__buy iteration, membership and slicing. -
__bool__(or__len__) soif obj:means something.
Do not implement dunders you have no use for. Each one is a promise about how your object behaves, and a promise nobody needed is just more to keep true.