Implementing protocols makes your types feel built-in and interoperate with the whole language.
Python's syntax is defined in terms of special methods ("dunders"). a + b calls a.__add__(b), len(x) calls x.__len__(), x[k] calls x.__getitem__(k), for calls __iter__. Implement them and your class plugs into the language — this is duck typing: what matters is which methods exist, not what class something inherits from.
Key groups:
- representation:
__repr__,__str__,__format__ - comparison:
__eq__,__lt__and friends;functools.total_orderingfills in the rest from__eq__and one ordering method - hashing:
__hash__ - containers:
__len__,__getitem__,__contains__,__iter__ - truthiness:
__bool__(falls back to__len__) - arithmetic:
__add__,__mul__, reflected__radd__, in-place__iadd__ - callables:
__call__; context managers:__enter__/__exit__
Two rules experts know. First, when an operation doesn't apply to the other operand's type, return `NotImplemented` (don't raise). Python then tries the other operand's reflected method, e.g. b.__radd__(a), and only raises TypeError if both decline.
Second, __eq__ and __hash__ go together: objects that compare equal must have equal hashes. Defining __eq__ without __hash__ sets __hash__ to None, making instances unhashable — correct for mutable objects. For immutable value types, hash a tuple of the same fields that __eq__ compares.
Implementing a few methods often unlocks much more: __len__ and __getitem__ with integer indexes make an object iterable, and inheriting from collections.abc.Sequence adds index, count, __contains__ and __reversed__ for free.
Example
class Money:
def __init__(self, cents, currency="EUR"):
self.cents, self.currency = cents, currency
def __repr__(self):
return f"Money({self.cents}, {self.currency!r})"
def __eq__(self, other):
if not isinstance(other, Money):
return NotImplemented
return (self.cents, self.currency) == (other.cents, other.currency)
def __hash__(self): # equal objects -> equal hashes
return hash((self.cents, self.currency))
def __add__(self, other):
if not isinstance(other, Money) or other.currency != self.currency:
return NotImplemented # let Python try other.__radd__
return Money(self.cents + other.cents, self.currency)
def __radd__(self, other): # lets sum() start from 0
return self if other == 0 else NotImplemented
wallet = [Money(250), Money(100), Money(75)]
print(sum(wallet)) # Money(425, 'EUR')
print(Money(5) == Money(5), len({Money(1), Money(1)})) # True 1
try:
Money(1) + 5
except TypeError as e:
print(e) # unsupported operand type(s) for +: 'Money' and 'int'Remember
- Syntax maps to dunder methods; implementing them is duck typing
- Return
NotImplementedfor unsupported operand types - Equal objects must have equal hashes;
__eq__alone disables hashing
Watch out for
- Raising TypeError in
__eq__/__add__instead of returning NotImplemented.