Operator Overloading
|
This section documents the current Python release line as published at the official Python documentation, which is the reference these pages are written and verified against. No specific patch version is pinned. This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Python does not have operator overloading via a `/`==` keyword table -- it has *dunder methods*
(double-underscore names such as `__init__` or `__add__`) that every built-in operator, function, and
piece of syntax dispatches to. Implement the right dunder and a custom class gets `repr()`, `==`, `len()`,
`obj[i]`, `for x in obj`, `obj()`, `with obj:`, and ` for free. The full catalogue is the data model’s
special method names reference.
Construction and Representation
init initializes a new instance (construction itself is new, rarely overridden).
repr should return an unambiguous, ideally re-evaluable string aimed at developers;
str returns a human-readable string and falls back to repr if not defined. print() and
str() call str; the REPL and repr() call repr.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Point({self.x!r}, {self.y!r})"
def __str__(self):
return f"({self.x}, {self.y})"
p = Point(1, 2)
print(p) # (1, 2) -- uses __str__
print(repr(p)) # Point(1, 2) -- uses __repr__
[p] # [Point(1, 2)] -- containers repr() their elements
Rich Comparisons
Python has no default </>/⇐/>= for custom objects, and == defaults to identity (is) unless
overridden. Each operator maps to its own dunder:
eq,
ne, lt, le, gt, ge — collectively the
rich comparison methods.
class Money:
def __init__(self, cents):
self.cents = cents
def __eq__(self, other):
if not isinstance(other, Money):
return NotImplemented
return self.cents == other.cents
def __lt__(self, other):
if not isinstance(other, Money):
return NotImplemented
return self.cents < other.cents
def __repr__(self):
return f"Money({self.cents})"
print(Money(100) == Money(100)) # True
print(Money(50) < Money(100)) # True
sorted([Money(300), Money(100)]) # [Money(100), Money(300)] -- sorted() only needs __lt__
Return NotImplemented (not False) when the other operand’s type is unsupported — Python then tries
the reflected method on the other object before giving up with a TypeError.
functools.total_ordering
Defining eq plus just one of lt/le/gt/ge is enough for the
@functools.total_ordering
class decorator to fill in the rest:
from functools import total_ordering
@total_ordering
class Version:
def __init__(self, major, minor):
self.major, self.minor = major, minor
def __eq__(self, other):
return (self.major, self.minor) == (other.major, other.minor)
def __lt__(self, other):
return (self.major, self.minor) < (other.major, other.minor)
v1, v2 = Version(1, 2), Version(1, 5)
print(v1 <= v2, v1 > v2) # True False -- __le__ and __gt__ were synthesized
Container Protocol: len, getitem, setitem
Implementing the
container-type special
methods makes a class behave like a built-in sequence or mapping: len(obj) calls len,
obj[key] calls getitem, obj[key] = value calls setitem, and slicing (obj[1:3]) passes
a slice object as key.
class Grid:
def __init__(self, width, height):
self._data = [0] * (width * height)
self.width = width
def __len__(self):
return len(self._data)
def _index(self, row, col):
return row * self.width + col
def __getitem__(self, key):
row, col = key
return self._data[self._index(row, col)]
def __setitem__(self, key, value):
row, col = key
self._data[self._index(row, col)] = value
g = Grid(3, 2)
g[0, 1] = 9
print(g[0, 1], len(g)) # 9 6
Slicing support just means checking whether key is a slice:
class Series:
def __init__(self, values):
self._values = list(values)
def __len__(self):
return len(self._values)
def __getitem__(self, key):
if isinstance(key, slice):
return Series(self._values[key]) # a Series back, not a bare list
return self._values[key]
s = Series([10, 20, 30, 40])
print(s[1]) # 20
print(s[1:3]._values) # [20, 30]
iter/next for a Custom Iterable
A class with getitem is iterable by a legacy fallback, but the proper way to make a class a
first-class iterable is the iterator protocol — iter returning an iterator and next
raising StopIteration when exhausted. This is covered in full, including generators as the
usual shortcut, in Iterators, Generators, and Comprehensions:
class Countdown:
def __init__(self, start):
self.start = start
def __iter__(self):
self._current = self.start
return self
def __next__(self):
if self._current <= 0:
raise StopIteration
self._current -= 1
return self._current + 1
for n in Countdown(3):
print(n) # 3 2 1
call: Making Instances Callable
An object with call can be invoked with () like a function, which is how decorators, memoizers,
and stateful callbacks are often implemented:
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, value):
return value * self.factor
double = Multiplier(2)
print(double(21)) # 42
print(callable(double)) # True
enter/exit: Writing a Custom Context Manager
The with statement is syntax over a protocol: on entry it calls enter (whose return value is
bound by as), and on exit — normal or via exception — it calls exit with the exception info.
Files and Context Managers covers this protocol in depth for the common case of
open(); here it is implemented directly on a class:
class Timer:
def __enter__(self):
import time
self._start = time.perf_counter()
return self
def __exit__(self, exc_type, exc_value, traceback):
import time
self.elapsed = time.perf_counter() - self._start
return False # False/None propagates any exception; True would suppress it
with Timer() as t:
total = sum(range(1_000_000))
print(f"took {t.elapsed:.4f}s")
Returning a truthy value from exit suppresses a raised exception; returning False/None (the
common case) lets it propagate normally after cleanup runs.
Arithmetic Operators: add, radd, iadd
` dispatches to `__add__` on the left operand; if that returns `NotImplemented` (or the left operand's
type doesn't define it), Python tries `__radd__` on the *right* operand -- this is what lets
`5 + my_vector` work even though `int.__add__` knows nothing about `Vector`. `= tries the in-place
iadd first and only falls back to add (then rebinding the name) if iadd is absent.
Every other arithmetic operator follows the identical three-method pattern (sub/rsub, etc.)
documented alongside add in the
special method names reference.
class Vector:
def __init__(self, x, y):
self.x, self.y = x, y
def __add__(self, other):
if not isinstance(other, Vector):
return NotImplemented
return Vector(self.x + other.x, self.y + other.y)
def __radd__(self, other):
# supports sum([...]) starting its accumulator at int 0
if other == 0:
return self
return self.__add__(other)
def __iadd__(self, other):
self.x += other.x
self.y += other.y
return self # __iadd__ must return the (possibly mutated) instance
def __repr__(self):
return f"Vector({self.x}, {self.y})"
v = Vector(1, 2)
print(v + Vector(3, 4)) # Vector(4, 6) -- __add__
print(sum([Vector(1, 1), Vector(2, 2)])) # Vector(3, 3) -- __radd__ handles the initial 0
v += Vector(1, 1)
print(v) # Vector(2, 3) -- __iadd__ mutated in place
If iadd is omitted, v += other simply becomes v = v.add(other), producing a new object
instead of mutating — fine for immutable-style value types, but worth choosing deliberately.
See Also
-
Classes and Objects — class fundamentals these dunder methods build on.
-
Advanced OOP Design — where operator overloading fits among broader OOP design patterns.