Kodokon kodokon.com

Protocols and special methods: __repr__, __eq__, context managers

Master Python's object model: the __eq__/__hash__ contract, the NotImplemented value, context managers and structural typing with Protocol.

9 min · 3 questions

Open this lesson in Kodokon

In Python, every operator, every conversion, every with statement goes through a special method: a == b calls a.__eq__(b), len(x) calls x.__len__(). Be careful to distinguish __repr__ - the unambiguous representation aimed at the developer, shown in the debugger and inside lists - from __str__, aimed at the end user. If __str__ is missing, Python falls back to __repr__: so always implement __repr__ first, ideally in a form that lets you reconstruct the object.

PYTHON
class Money:
    def __init__(self, amount: int, currency: str):
        self.amount = amount
        self.currency = currency

    def __repr__(self) -> str:
        return f"Money({self.amount!r}, {self.currency!r})"

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Money):
            return NotImplemented
        return (self.amount, self.currency) == (
            other.amount, other.currency)

print(Money(10, "EUR") == Money(10, "EUR"))
print(Money(10, "EUR") == "10 EUR")
Comparing to an unknown type returns NotImplemented, never False.

Returning NotImplemented (the value, not to be confused with the NotImplementedError exception) triggers the operator negotiation protocol: Python then tries the reflected operation other.__eq__(self), and only concludes False as a last resort. Returning False directly would short-circuit that negotiation and break comparisons with subclasses or third-party types that would in fact know how to respond.

PYTHON
import time

class Timer:
    def __enter__(self):
        self.start = time.perf_counter()
        return self

    def __exit__(self, exc_type, exc, tb):
        stop = time.perf_counter()
        self.elapsed = stop - self.start
        return False

with Timer() as timer:
    sum(range(1_000_000))
print(f"{timer.elapsed:.4f} s")
Returning False lets any exception propagate normally.

If __exit__ returns True, the current exception is suppressed - that's exactly how contextlib.suppress works. To avoid writing a full class, @contextlib.contextmanager turns a generator into a context manager: the code before yield plays the role of __enter__, the code after (ideally in a finally) that of __exit__. Another pillar of the object model: the Protocol types from the typing module formalize duck typing - a class is compatible if it exposes the right methods, with no declared inheritance.

PYTHON
from typing import Protocol, runtime_checkable

@runtime_checkable
class Closable(Protocol):
    def close(self) -> None: ...

class Session:
    def close(self) -> None:
        print("session closed")

def shutdown(resource: Closable) -> None:
    resource.close()

shutdown(Session())
print(isinstance(Session(), Closable))
Session declares no inheritance: conformance is structural.

Knowledge check

Make sure you remember the key points of this lesson.

  1. In __eq__, what should you return when facing a type you cannot compare against?
    • False, to signal inequality clearly
    • Raise NotImplementedError
    • NotImplemented, to let Python try the other operand's reflected operation
    • None, which Python will convert to False
  2. You add __eq__ to a class without touching __hash__. What is the consequence?
    • None: __hash__ stays inherited from object
    • __hash__ is set to None: instances can no longer be used as dict keys or set elements
    • The hash automatically becomes that of the tuple of attributes
  3. What does an __exit__ method returning True mean?
    • The with block ended without error
    • The context manager can be reused
    • Python will re-run the with block a second time
    • The current exception is suppressed and execution resumes after the with block