Skillversitas

Operator Overloading

Operator overloading defines what Python's operators mean for your classes: + becomes vector addition, * becomes matrix scaling, < becomes a domain ordering. The mechanism is the same dunder protocol as the previous chapter - a + b calls type(a).__add__(a, b), and if that returns NotImplemented, Python tries the reflected type(b).__radd__(b, a). The craft is choosing the right semantics (value types return new objects, the conventions of __iadd__ and friends), handling the reflected forms, and knowing when an operator is the right interface versus a plain method.

Arithmetic: __add__ and Friends

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __add__(self, other):
        if not isinstance(other, Vector):
            return NotImplemented
        return Vector(self.x + other.x, self.y + other.y)

    def __sub__(self, other):
        if not isinstance(other, Vector):
            return NotImplemented
        return Vector(self.x - other.x, self.y - other.y)

    def __neg__(self):
        return Vector(-self.x, -self.y)

    def __mul__(self, k):
        if not isinstance(k, (int, float)):
            return NotImplemented
        return Vector(self.x * k, self.y * k)

    def __rmul__(self, k):
        # makes 2 * v work: int has no __mul__ for Vector, so the
        # reflected form on Vector is tried
        return self.__mul__(k)

    def __repr__(self):
        return f"Vector({self.x!r}, {self.y!r})"

a = Vector(1, 2)
b = Vector(3, 4)
print(a + b)       # Vector(4, 6)
print(a - b)       # Vector(-2, -2)
print(-a)          # Vector(-1, -2)
print(a * 3)       # Vector(3, 6)
print(2 * a)       # Vector(2, 4) - via __rmul__

The pattern for every binary operator: check the operand's type, return NotImplemented (not False, not an exception) when you do not handle it, and return a new object for value types. The reflected methods (__radd__, __rmul__, ...) exist for operands whose type does not know about yours - 2 * v works because int.__mul__ returns NotImplemented for a Vector and Python falls back to Vector.__rmul__. For commutative operators (+, *), __rmul__ can just delegate to __mul__; for non-commutative ones (-, /, **) the reflected method must actually reverse the operands.

In-Place: __iadd__ and the Assignment Trap

class Stack:
    def __init__(self):
        self.items = []

    def __iadd__(self, item):
        # += on a Stack pushes - the in-place operator has domain meaning
        self.items.append(item)
        return self

    def __repr__(self):
        return f"Stack({self.items!r})"

s = Stack()
s += 1
s += 2
print(s)             # Stack([1, 2])
print(s is s)        # True - __iadd__ returned self, no rebind needed

x += y tries __iadd__ first. If your class defines it, you choose what "in-place" means - for a Stack, pushing is the natural reading. If it does not, Python falls back to x = x + y, which rebinds the name. Two traps: (1) defining __iadd__ to return None (forgetting the return self) makes s += 1 silently set s to None; (2) for immutable value types, defining __iadd__ at all is a smell - += will build a new object anyway, and the in-place name promises a mutation that did not happen. The convention: mutable stateful objects may define in-place operators with genuine in-place semantics; value types leave them out.

Comparison and Ordering

from functools import total_ordering

@total_ordering
class Card:
    """Playing cards ordered by rank then suit."""
    SUITS = {"c": 0, "d": 1, "h": 2, "s": 3}

    def __init__(self, rank, suit):
        self.rank = rank
        self.suit = suit

    def _key(self):
        return (self.rank, self.SUITS[self.suit])

    def __eq__(self, other):
        if not isinstance(other, Card):
            return NotImplemented
        return self._key() == other._key()

    def __lt__(self, other):
        if not isinstance(other, Card):
            return NotImplemented
        return self._key() < other._key()

a = Card(10, "h")
b = Card(10, "s")
c = Card(9, "s")
print(a == b)     # False - same rank, different suit
print(a > b)      # True  - hearts above spades in our order
print(a > c)      # True
print(sorted([b, a, c]))
# [Card(9,s), Card(10,h), Card(10,s)] - sort works via __lt__

Comparison dunders: __eq__, __lt__, __le__, __gt__, __ge__. You do not need all five - functools.total_ordering fills in the missing ones from __eq__ plus one ordering method (__lt__ is the usual choice), which is the standard pattern for ordered value types. The _key method that reduces the object to a comparable tuple is the trick that keeps the comparison logic in one place. As with __eq__, return NotImplemented for foreign types. Note the interaction: defining __eq__ without __hash__ makes the class unhashable (the default), which is correct for mutable orderable objects and wrong for value types you want in sets - define __hash__ when the ordering fields are stable.

The Operator Map

OperatorForwardReflected
a + b__add____radd__
a - b__sub____rsub__
a * b__mul____rmul__
a / b__truediv____rtruediv__
a // b__floordiv____rfloordiv__
a % b__mod____rmod__
a ** b__pow____rpow__
-a__neg__-
abs(a)__abs__-
a < b__lt____gt__
a == b__eq____eq__ (reflected)

The direction matters for the semantics: a - b is not b - a, so __rsub__ must compute other - self. The NotImplemented return is the handshake that makes mixed-type arithmetic work: each side gets one chance, and if both decline, Python raises TypeError - the exact error you want when someone writes vector + "text".

When an Operator Is the Right Interface

  • Use an operator when the meaning is genuinely mathematical or set-like and users will expect +/*/< to work: vectors, money (carefully), intervals, bitsets, filters.
  • Use a method when the operation is domain-specific and an operator would be a mnemonic stretch: account.transfer(to, amt) beats account -> to; query.filter(...) beats inventing an operator for filtering.
  • Never overload + to mean "and then" - the abuse that turns code into a private language. If you cannot explain the operator's meaning in one sentence of standard math, it is a method.

Summary

Overload operators by defining the dunders: __add__/__mul__ and the rest of the table, returning NotImplemented for operands you do not handle so the reflected form and the eventual TypeError work as designed. Implement __rmul__-style reflected methods for commutative cases (delegate) and true operand reversal for non-commutative ones. Reserve __iadd__ for genuinely mutable objects with real in-place semantics, and build ordered value types with __eq__ plus __lt__ under @total_ordering. The design test is semantic honesty: operators for things that are mathematically that thing, methods for everything else - the moment an operator becomes a mnemonic, the code has stopped being readable.