Special methods and protocols

Python · Advanced · 14 min

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_ordering fills 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 NotImplemented for 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.

Check yourself

  1. A class defines `__eq__` but not `__hash__`. What happens with `{obj}`?
  2. `1 + v` where `v` is your Vector: int's `__add__` returns NotImplemented. What does Python try next?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/reference/datamodel.html#special-method-names