How it works
The explanation, with examples you can run.
Operators are method calls
class Vector: def __init__(self, x, y): self.x, self.y = x, y def __repr__(self): return f"Vector({self.x}, {self.y})" def __add__(self, other): if not isinstance(other, Vector): return NotImplemented return Vector(self.x + other.x, self.y + other.y) def __eq__(self, other): return isinstance(other, Vector) and (self.x, self.y) == (other.x, other.y) def __abs__(self): return (self.x ** 2 + self.y ** 2) ** 0.5 v = Vector(3, 4)print(v + Vector(1, 1)) # Vector(4, 5)print(abs(v)) # 5.0print(v == Vector(3, 4)) # Truev + 1 raises TypeError: unsupported operand type(s) for +: 'Vector' and 'int'. Returning NotImplemented (a special singleton, not an exception) tells Python to try the other operand's reflected method, int.__radd__, and only when that also declines does it raise. That is how 2 * vector can work if you define __rmul__.
__repr__ is the developer-facing text used by the REPL, debuggers and containers; __str__ is for end users and falls back to __repr__. Define __repr__ first.
Equality, hashing and dict keys
Dicts and sets locate keys by hash and then confirm with ==, so equal objects must have equal hashes. Because the default hash is based on identity, defining __eq__ sets __hash__ to None, and {v} raises TypeError: unhashable type: 'Vector'. If instances are immutable, add def __hash__(self): return hash((self.x, self.y)). If they are mutable, leave them unhashable: a key whose hash changes after insertion can never be found again.
@dataclass writes __init__, __repr__ and __eq__ for you, and @dataclass(frozen=True) adds a matching __hash__.
Protocols, not base classes
Python cares about which methods exist, not what you inherit from. A class with __len__ and __getitem__ is already a usable sequence:
class Deck: def __init__(self): self.cards = [r + s for s in "SH" for r in "AKQ"] def __len__(self): return len(self.cards) def __getitem__(self, i): return self.cards[i] d = Deck()print(len(d), d[0], d[-1]) # 6 AS QHprint("KH" in d) # True: no __contains__, so Python scans with __getitem__print(list(reversed(d))[:2]) # ['QH', 'KH']Iteration, in and reversed() all work through fallbacks. When you want to state the contract explicitly, the abstract classes in collections.abc (such as Sequence and Mapping) fill in the rest of the methods from a minimal set.
Looked up on the type
Special methods are found on the class, skipping the instance's own attributes. Setting obj.__len__ = lambda: 3 on an instance has no effect on len(obj), which still raises TypeError: object of type 'C' has no len(). This rule lets the interpreter take fast paths, and it is why the place to customise behaviour is always the class.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.