28  Operator Overloading

NoteCore idea

Python allows you to overload operators for your own types. The rules are clear: return NotImplemented (not raise) when you can’t handle the operand type, and Python will try the reflected method on the other side.

In this chapter you will learn to:

  1. Define unary operators (-x, +x, abs(x)).
  2. Implement + so it works with both same-type and unrelated operands — and let Python try the reflected operand’s method.
  3. Implement * for scalar multiplication and have 2 * v work as well as v * 2.
  4. Use @ for matrix multiplication / dot products.
  5. Implement rich comparison and use @total_ordering to fill in the rest.
  6. Choose between + (returns new) and += (mutates in place) by defining __iadd__.

28.1 The rules of the road

Three rules apply to every operator overload:

  1. You cannot overload operators for built-in types — only for your own classes.
  2. You cannot create new operators — only the ones Python already has.
  3. The operators is, and, or, not cannot be overloaded.

For binary operators, Python’s resolution is consistent across the language. When you write a + b:

  1. Python calls a.__add__(b).
  2. If that returns the special NotImplemented value, Python tries b.__radd__(a).
  3. If that also returns NotImplemented, Python raises TypeError.

This is what makes 2 * vector work even when int knows nothing about Vectorint.__mul__ returns NotImplemented, and Python falls through to Vector.__rmul__.

28.2 Unary operators

The minus, plus, and absolute-value operators each call one method. We need a class to overload them on — a small Vector of numeric components is the running example for the rest of the chapter.

import math
from array import array

class Vector:
    typecode = "d"

    def __init__(self, components):
        self._components = array(self.typecode, components)

    def __iter__(self):
        return iter(self._components)

    def __len__(self):
        return len(self._components)

    def __repr__(self):
        return f"Vector({list(self._components)})"

    def __abs__(self):
        return math.hypot(*self)

    def __neg__(self):
        return Vector(-x for x in self)

    def __pos__(self):
        return Vector(self)

v = Vector([3, 4, 5])
-v, +v, abs(v)
(Vector([-3.0, -4.0, -5.0]), Vector([3.0, 4.0, 5.0]), 7.0710678118654755)

Walking through the unary dunders:

  • __abs__(self) is what abs(v) calls. math.hypot(*self) unpacks the components and returns the Euclidean norm — the geometric “length” of the vector.
  • __neg__(self) is what -v calls. We return a new Vector with each component negated; the original is untouched.
  • __pos__(self) is what +v calls. It returns a copy — +v looks like a no-op, but it’s the conventional place to materialize a lazy or contextual value.
  • The three I/O dunders (__iter__, __len__, __repr__) are scaffolding so the rest of the chapter can iterate, measure, and print vectors.

The general pattern: each unary operator maps to one dunder that takes only self and returns a new value — never mutates.

28.3 Overloading +

Adding two vectors should produce a third — Vector([1, 2, 3]) + Vector([10, 20, 30]) should be Vector([11, 22, 33]). And we want [1, 2, 3] + v1 to work too, even though list knows nothing about Vector. That’s what __add__ and its reflected partner __radd__ cover.

import itertools

class Vector(Vector):
    def __add__(self, other):
        try:
            pairs = itertools.zip_longest(self, other, fillvalue=0.0)
            return Vector(a + b for a, b in pairs)
        except TypeError:
            return NotImplemented

    def __radd__(self, other):
        return self + other

v1 = Vector([1, 2, 3])
v2 = Vector([10, 20, 30])
v1 + v2
Vector([11.0, 22.0, 33.0])

Walking through the two methods:

  • __add__(self, other) is what v1 + v2 calls. zip_longest(self, other, fillvalue=0.0) pairs up components and pads the shorter iterable with 0.0 — so Vector([1, 2, 3]) + Vector([1]) works without an index error.
  • The try/except TypeError catches the case where other is not iterable (or its elements aren’t numeric). On failure we return NotImplemented — the special sentinel — instead of raising. That’s what tells Python to try other.__radd__(self) next.
  • __radd__(self, other) is the reflected form, called when the other operand is on the left. We just delegate to self + other, since vector addition is commutative.

zip_longest lets us add vectors of different length:

v1 + Vector([1])
Vector([2.0, 2.0, 3.0])

The __radd__ exists so that [1, 2, 3] + v1 would also work — Python tries list.__add__(v1), which returns NotImplemented (a list can’t add a Vector), and Python then calls v1.__radd__([1, 2, 3]).

The crucial detail is the return NotImplemented on failure. Don’t raise TypeError — that prevents Python from trying the reflected method.

28.4 Scalar multiplication

v * 3 should scale every component by 3. 3 * v should do the same — the user shouldn’t have to remember which side the scalar goes on. Same pattern as +: define __mul__ for the left-hand case, __rmul__ for the right.

class Vector(Vector):
    def __mul__(self, scalar):
        try:
            factor = float(scalar)
        except TypeError:
            return NotImplemented
        return Vector(n * factor for n in self)

    def __rmul__(self, scalar):
        return self * scalar

v = Vector([1, 2, 3])
v * 3, 3 * v, v * True
(Vector([3.0, 6.0, 9.0]), Vector([3.0, 6.0, 9.0]), Vector([1.0, 2.0, 3.0]))

Walking through the methods:

  • __mul__(self, scalar) is v * scalar. We coerce scalar through float() first — that’s what defines “scalar-like”: any value float() accepts. Anything else (a list, a string) raises TypeError from the conversion, which we catch and turn into NotImplemented.
  • Vector(n * factor for n in self) builds a new Vector from a generator expression — lazy, no intermediate list.
  • __rmul__ handles 3 * v: when Python sees int * Vector, it asks int.__mul__(Vector) first, gets NotImplemented, then falls through to Vector.__rmul__(3). We just delegate to self * scalar — multiplication by a scalar is commutative.

v * True works because True is 1 numerically — float(True) is 1.0. v * "spam" would return NotImplemented (the float() call fails), and Python would raise TypeError.

The general pattern: implement __op__ and __rop__ together for any binary operator that should accept a non-Vector on either side.

Python 3.5 also added @ for matrix multiplication via __matmul__ / __rmatmul__. Rarely overloaded outside NumPy and friends — mention only.

28.5 Rich comparison

Comparison operators each have a dunder. The most important pair is __eq__ (returns True, False, or NotImplemented) and __hash__ (must agree with __eq__).

class Vector(Vector):
    def __eq__(self, other):
        if isinstance(other, Vector):
            return len(self) == len(other) and all(a == b for a, b in zip(self, other))
        return NotImplemented

    def __hash__(self):
        return hash(tuple(self))

Vector([1, 2, 3]) == Vector([1.0, 2.0, 3.0])
True

Walking through the two methods:

  • __eq__(self, other) first checks isinstance(other, Vector) — comparing a Vector to a plain list or a string is a category error, so we return NotImplemented and let Python fall through to the other side. For two Vectors, we require equal length and element-wise equality.
  • __hash__(self) lets a Vector be used as a dict key or set member. The contract is: if a == b then hash(a) == hash(b). Hashing tuple(self) satisfies that — same components, same hash. (Mutable types should set __hash__ = None; ours is conceptually immutable.)
  • 1 == 1.0 is True in Python, so a Vector of ints equals the same Vector of floats. That’s __eq__ deferring to each component’s own __eq__.

For ordering, define one of __lt__, __le__, __gt__, __ge__ and use @functools.total_ordering to fill in the rest:

from functools import total_ordering

@total_ordering
class Angle:
    def __init__(self, degrees):
        self.degrees = degrees % 360
    def __eq__(self, other):
        return self.degrees == other.degrees
    def __lt__(self, other):
        return self.degrees < other.degrees

a, b = Angle(30), Angle(45)
a < b, a >= b, a != b, sorted([Angle(45), Angle(30), Angle(60)], key=lambda x: x.degrees)
(True,
 False,
 True,
 [<__main__.Angle at 0x7f2cf865d940>,
  <__main__.Angle at 0x7f2cf88d6d50>,
  <__main__.Angle at 0x7f2cf865e060>])

Walking through how this works:

  • degrees % 360 normalizes the input — Angle(370) and Angle(10) are the same angle.
  • We define only __eq__ and __lt__ by hand. Those are the two anchors @total_ordering needs.
  • @total_ordering reads the class and synthesizes the missing methods (__le__, __gt__, __ge__, __ne__) from the two you wrote. So a >= b and a != b work even though we never wrote them.
  • sorted(..., key=...) doesn’t need ordering on Angle itself — we hand it a key function — but with @total_ordering we could now write sorted([Angle(45), Angle(30), Angle(60)]) directly.

The general pattern: write the two most natural comparisons (__eq__ and one of __lt__/__le__/__gt__/__ge__), let @total_ordering fill in the algebra.

28.6 Augmented assignment

+= is not the same as +. By default, x += y is x = x + y — a fresh object. Mutable types may implement __iadd__ to mutate in place and return self; immutable types should not, so Python falls back to __add__ (and rebinds the name to the new object). Lists already do this — xs += [4] mutates xs and keeps the same id.

TipWhy this matters

The key insight: return NotImplemented, don’t raise TypeError. Returning NotImplemented tells Python to try the reflected method. If you raise, Python can’t recover — it propagates the error immediately, even if the other operand could have handled it.

28.7 Build: a TimeSpan value class with full arithmetic

Time durations are a classic operator-overload target: you want span1 + span2, 2 * span, -span, comparisons, and use as a dict key. We’ll build it three steps deep — same-type addition first, then scalar multiplication with reflected support, then ordering via @total_ordering.

Step 1: addition, subtraction, negation — same type only. Each operator returns a new TimeSpan; NotImplemented is the sentinel for “I can’t handle this operand”:

class TimeSpan:
    def __init__(self, seconds: float) -> None:
        self.seconds = float(seconds)

    def __repr__(self) -> str:
        return f"TimeSpan({self.seconds!r})"

    def __add__(self, other):
        if isinstance(other, TimeSpan):
            return TimeSpan(self.seconds + other.seconds)
        return NotImplemented

    def __sub__(self, other):
        if isinstance(other, TimeSpan):
            return TimeSpan(self.seconds - other.seconds)
        return NotImplemented

    def __neg__(self):
        return TimeSpan(-self.seconds)

a = TimeSpan(60)            # one minute
b = TimeSpan(30)            # half a minute
[a + b, a - b, -a]
[TimeSpan(90.0), TimeSpan(30.0), TimeSpan(-60.0)]

isinstance(other, TimeSpan) gates each operator: same-type pair, do the math; otherwise return NotImplemented so Python can try the reflected method on the other operand. The unary __neg__ only takes self and produces a fresh instance — never mutates.

Step 2: scalar multiplication with __rmul__. 2 * span should equal span * 2. Define __mul__ for the left case and __rmul__ for the reflected:

class TimeSpan(TimeSpan):
    def __mul__(self, factor):
        if isinstance(factor, (int, float)):
            return TimeSpan(self.seconds * factor)
        return NotImplemented

    def __rmul__(self, factor):
        return self * factor                         # commutative — delegate

s = TimeSpan(10)
[s * 3, 3 * s, s * 1.5, s * True]
[TimeSpan(30.0), TimeSpan(30.0), TimeSpan(15.0), TimeSpan(10.0)]

s * 3 calls s.__mul__(3) → matches (int, float) → returns TimeSpan(30). 3 * s calls int.__mul__(s) → returns NotImplemented → Python falls through to s.__rmul__(3) → delegates to s * 3. s * "ten" would return NotImplemented (isinstance check fails) and Python would raise TypeError. s * True works because bool is a subclass of int.

Step 3: comparison + hashing for use in sets, with @total_ordering. Two anchor methods (__eq__ and __lt__) plus the decorator give us all six comparisons. __hash__ makes TimeSpan usable as a dict key:

from functools import total_ordering

@total_ordering
class TimeSpan(TimeSpan):
    def __eq__(self, other):
        if isinstance(other, TimeSpan):
            return self.seconds == other.seconds
        return NotImplemented

    def __lt__(self, other):
        if isinstance(other, TimeSpan):
            return self.seconds < other.seconds
        return NotImplemented

    def __hash__(self):
        return hash(self.seconds)

durations = sorted([TimeSpan(45), TimeSpan(10), TimeSpan(120)])
[durations,
 TimeSpan(60) <= TimeSpan(60),                  # synthesised by @total_ordering
 TimeSpan(60) >  TimeSpan(30),                  # synthesised
 len({TimeSpan(60), TimeSpan(60.0)})]           # dedup via __hash__ + __eq__
[[TimeSpan(10.0), TimeSpan(45.0), TimeSpan(120.0)], True, True, 1]

@total_ordering reads the class, sees __eq__ and __lt__, and synthesises __le__, __gt__, __ge__, __ne__ in terms of those two. sorted([...]) works directly — no key= needed. __hash__ returning hash(self.seconds) agrees with __eq__ (same seconds → equal → same hash), so the set deduplicates TimeSpan(60) and TimeSpan(60.0).

The build threads the chapter together: NotImplemented (not raise) for unsupported operands (Step 1), __rmul__ for reflected scalar arithmetic (Step 2), @total_ordering for the full comparison set with two anchor methods (Step 3), plus __hash__ paired with __eq__ for use in collections. About forty lines of code for a value class that participates in arithmetic, comparison, and set/dict membership exactly like a built-in number.

28.8 Exercises

  1. __sub__. Implement __sub__ and __rsub__ for Vector. Test v1 - v2 and [1, 2, 3] - v2.

  2. Scalar division. Implement __truediv__ so v / 2 works. Should 2 / v work? Argue why or why not.

  3. @total_ordering quirks. Try implementing __eq__ and __lt__ without @total_ordering on Angle. What does a > b do? Why?

28.9 Summary

Operator overloading in Python is principled: every operator is a dunder, every binary operator has a reflected form, and the right way to refuse an operand is return NotImplemented. Once you internalize this, integrating your class with arithmetic, comparison, and matrix operators is mechanical.

That closes Part IV’s first chapter on the call site. Next, Chapter 29 turns to the implementation side: how iteration, generators, and classic coroutines work under the hood.