PYTHON / OBJECT-ORIENTED PYTHON
Operator overloading
Implement dunder methods like __add__, __eq__, and __lt__ so your own classes work with +, ==, < and sorted(), following Python's operand-fallback rules.
What you will learn
- Map operators to the dunder methods Python actually calls (+ to __add__, < to __lt__)
- Return NotImplemented, not False or a raised TypeError, for unsupported operand types
- Use __radd__/__rmul__ so your type works when it is the right-hand operand
- Restore __hash__ yourself after defining __eq__ if instances must go in sets or dicts
Understanding Operator overloading
In Python an operator is not a special language feature applied to values; it is a shorthand for a method lookup on a type. When the interpreter sees a + b it looks for __add__ on type(a), and a < b becomes a call to type(a).__lt__. Because the lookup happens on the type rather than the instance, defining these methods in your class is the whole of operator overloading: there is no registration step and no separate operator table.
The dispatch rules are what make this more than string replacement. For a binary operator Python first tries the left operand's method; if that method returns the special singleton NotImplemented, Python then tries the reflected method on the right operand (__radd__ for +, and __lt__ used in place of a missing __gt__). Only when both sides decline does Python raise TypeError with a message naming both types. This is why returning NotImplemented for types you do not understand is correct behaviour and not a failure: it hands the decision to the other operand instead of ending the negotiation.
Comparison dunders also feed the standard library, which is where overloading pays off most. sorted, min, max, and list.sort all express their work in terms of a single <, so defining __lt__ makes your objects sortable without writing a key function anywhere. The mental model to keep is that Python's syntax and builtins are a set of protocols, and a dunder method is you opting your class into one of them.
class Money:
def __init__(self, cents):
self.cents = cents
def __add__(self, other):
if not isinstance(other, Money):
return NotImplemented
return Money(self.cents + other.cents)
def __mul__(self, factor):
if not isinstance(factor, int):
return NotImplemented
return Money(self.cents * factor)
def __eq__(self, other):
if not isinstance(other, Money):
return NotImplemented
return self.cents == other.cents
def __repr__(self):
return f"Money({self.cents})"
a = Money(250)
b = Money(199)
print(a + b)
print(a * 3)
print(a == Money(250), a == 250)
try:
a + 5
except TypeError as exc:
print(exc)
Operators are syntax for dunder-method calls on the operand types, with a defined fallback to the right operand when a method returns NotImplemented.
Worked examples
Reflected operands
Shows Python falling back to __rmul__ when the left operand is an int that cannot handle your type.
class Meters:
def __init__(self, value):
self.value = value
def __mul__(self, other):
print("__mul__ called")
return Meters(self.value * other)
def __rmul__(self, other):
print("__rmul__ called")
return Meters(self.value * other)
def __repr__(self):
return f"Meters({self.value})"
print(Meters(3) * 2)
print(2 * Meters(3))
Example explained
Line 1Meters(3) * 2 finds Meters.__mul__ on the left operand and never consults the int.
Line 2For 2 * Meters(3) Python first calls int.__mul__(2, meters), which returns NotImplemented because int knows nothing about Meters.
Line 3Python then retries with the reflected method Meters.__rmul__, passing the int as other.
Line 4Without __rmul__ the second line would raise TypeError even though __mul__ exists.
Sorting with a single __lt__
Demonstrates that one comparison dunder is enough for sorted() and also serves as the reflection for >.
class Version:
def __init__(self, major, minor):
self.major = major
self.minor = minor
def __lt__(self, other):
return (self.major, self.minor) < (other.major, other.minor)
def __repr__(self):
return f"{self.major}.{self.minor}"
versions = [Version(1, 10), Version(1, 2), Version(2, 0)]
print(sorted(versions))
print(Version(1, 2) > Version(1, 10))
Example explained
Line 1sorted expresses every comparison as a < b, so only __lt__ is required to order the list.
Line 2Comparing tuples inside __lt__ gives major-then-minor ordering, which is why 1.2 sorts before 1.10.
Line 3Version(1, 2) > Version(1, 10) has no __gt__ to call, so Python reflects it into Version(1, 10).__lt__(Version(1, 2)), which is False.
Line 4The list prints using __repr__ of each element, since containers show reprs rather than strs.
Important notes
You cannot invent new operators or overload and, or, not, and is; and/or only consult __bool__, and is always compares identity.
+= tries __iadd__ first and falls back to __add__ followed by rebinding the name, so an immutable-style class needs no __iadd__ at all.
Common mistakes
Raising TypeError or returning False from __add__ for an unknown type: this blocks Python from trying the other operand's __radd__, so mixed-type additions that could have worked fail instead.
Mutating self inside __add__ and returning nothing: the expression a + b evaluates to None and a has been silently changed, which breaks any code that reuses a.
Defining __eq__ and forgetting that Python then sets __hash__ to None: instances raise TypeError: unhashable type as soon as they are put in a set or used as a dict key.
Try it yourself
Change, predict, then run
Write a Temperature class holding degrees Celsius with __add__, __eq__, __lt__ and __repr__, returning NotImplemented for non-Temperature operands. Confirm that sorted([Temperature(30), Temperature(-5), Temperature(12)]) orders correctly and that Temperature(20) == 20 evaluates to False rather than raising.
Open the Python workspaceCheck your understanding
A class defines __add__ that returns NotImplemented when other is not the same class. What does Python do when you evaluate my_obj + 5?
- It calls int.__radd__(5, my_obj); if that also returns NotImplemented, it raises TypeError naming both types
- It raises TypeError immediately, because __add__ did not return a value of the class
- It converts 5 into an instance of the class and calls __add__ again
- The expression evaluates to the NotImplemented object, which is truthy
Show answer
NotImplemented is a signal to the interpreter, not a result: it makes Python try the reflected method on the right operand and only raise TypeError once both operands decline. Option 4 is tempting because NotImplemented is a real, truthy object you could return from an ordinary method, but the binary-operator machinery intercepts it and never lets it become the value of the expression.