Python Module 9 – Object-oriented Python and the data model
Operator overloading done right
Make +, -, * and comparisons work on your own classes: return NotImplemented, add reflected and in-place methods, and fill in orderings with total_ordering.
What you will learn
- Implement arithmetic operators and their reflected versions, returning NotImplemented for unknown operands
- Predict which method Python calls for a + b, and when it raises TypeError
- Implement in-place operators only for mutable types, and explain what += does without them
- Use functools.total_ordering to fill in comparisons from __eq__ and __lt__
- Test a number type against a reference implementation such as fractions.Fraction
Before you start
What you’ll learn · Before you start
What you will learn
- Implement arithmetic operators and their reflected versions, returning NotImplemented for unknown operands
- Predict which method Python calls for a + b, and when it raises TypeError
- Implement in-place operators only for mutable types, and explain what += does without them
- Use functools.total_ordering to fill in comparisons from __eq__ and __lt__
- Test a number type against a reference implementation such as fractions.Fraction
Before you start
On this page
sum() adds up a list of floats, but on a list of your own Money amounts it raises TypeError at the first item.
operator overloading: Giving Python's operators a meaning for your own classes by defining special methods: a + b calls type(a).__add__(a, b), a < b calls __lt__ and -a calls __neg__., giving Python’s operators a meaning for your own classes,
fixes that: every operator is a call to a special method, so a + b runs type(a).__add__(a, b),
a < b runs __lt__ and -a runs __neg__.
Doing it right comes down to four habits. A method that does not understand its other operand returns
NotImplemented instead of raising, so Python can try the other operand’s reflected method: The method Python calls on the right operand when the left operand's method returns NotImplemented, such as __radd__ for + and __rmul__ for *. In 2 * v, int.__mul__ gives up and the vector's __rmul__ answers.
method, such as __radd__ for +, which also makes 2 * v and sum() work. Value types return new objects, while
only mutable types define in-place methods such as __iadd__. Comparisons can be filled in by
functools.total_ordering. And operators are overloaded only where their meaning is obvious to every reader, as for
vectors, money and fractions. Below you will build a 2D vector, an amount that sum() accepts and a shirt size that
sorts.
Python asks the left operand first, then the right one
The vector below defines +, -, *, unary - and abs():
import math
from numbers import Real
class Vector2:
def __init__(self, x, y):
self.x, self.y = x, y
def __repr__(self):
return f"Vector2({self.x!r}, {self.y!r})"
def __eq__(self, other):
if not isinstance(other, Vector2):
return NotImplemented
return (self.x, self.y) == (other.x, other.y)
def __hash__(self):
return hash((self.x, self.y))
def __add__(self, other):
if not isinstance(other, Vector2):
return NotImplemented
return Vector2(self.x + other.x, self.y + other.y)
def __sub__(self, other):
if not isinstance(other, Vector2):
return NotImplemented
return Vector2(self.x - other.x, self.y - other.y)
def __mul__(self, k):
if not isinstance(k, Real):
return NotImplemented
return Vector2(self.x * k, self.y * k)
__rmul__ = __mul__ # k * v means the same as v * k; it runs after int or float gives up
def __neg__(self):
return Vector2(-self.x, -self.y)
def __abs__(self):
return math.hypot(self.x, self.y)
v, w = Vector2(3, 4), Vector2(1, -2)
print(v + w, v - w, -w)
print(v * 2, 2 * v, 0.5 * v)
print(abs(v), v + w == Vector2(4, 2))
for label, attempt in (("v + 1", lambda: v + 1), ("v * w", lambda: v * w)):
try:
attempt()
except TypeError as error:
print(f"{label}: TypeError: {error}") Output
Vector2(4, 2) Vector2(2, 6) Vector2(-1, 2) Vector2(6, 8) Vector2(6, 8) Vector2(1.5, 2.0) 5.0 True v + 1: TypeError: unsupported operand type(s) for +: 'Vector2' and 'int' v * w: TypeError: unsupported operand type(s) for *: 'Vector2' and 'Vector2'
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 vector2.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
v + wandv - wreturn new vectors and leave both operands alone, as+does for numbers.v * 2calls__mul__.2 * vfirst callsint.__mul__(2, v), which returnsNotImplementedbecause an int knows nothing about vectors, and Python then calls the vector’s reflected method,__rmul__. Multiplying by a number commutes, so__rmul__ = __mul__is all it takes.-wcalls__neg__andabs(v)calls__abs__, here the length of the vector.v + 1andv * wreturnNotImplementedfrom both sides, so Python raisesTypeErrorwith both type names, the same message the built-in types give.
How Python evaluates a + b
Text description of the diagram
The diagram is a flow from top to bottom.
- It starts with the expression a + b, which calls the __add__ method of the left operand's type with a and b.
- If that method returns a result, the result is the value of a + b.
- If it returns NotImplemented, or the type has no __add__, Python calls the reflected method of the right operand's type, __radd__, with b and a.
- If the reflected method returns a result, that is the value.
- If it also returns NotImplemented, or there is no __radd__, Python raises TypeError: unsupported operand type(s) for +.
The same flow holds for every arithmetic operator, such as - with __sub__ and __rsub__ or * with __mul__ and __rmul__.
Step through three of the vector’s expressions in that order:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- v * 2. The vector is on the left, so Python calls Vector2.__mul__(v, 2). It accepts any real number and returns Vector2(6, 8), and Python uses that result at once.
- 2 * v. Now the int is on the left, so Python calls int.__mul__(2, v) first. An int knows nothing about vectors and returns NotImplemented.
- The reflected method. Python calls the right operand's reflected method, Vector2.__rmul__(v, 2), which is __mul__ under a second name, and it returns Vector2(6, 8) as well.
- v + 1. Vector2.__add__ accepts only another vector, so for the int 1 it returns NotImplemented, and Python turns to the int.
- Both sides give up. int.__radd__(1, v) does not understand a vector either. With NotImplemented from both sides, Python raises TypeError and names both types, Vector2 and int.
Returning NotImplemented is the polite answer: “not with this operand”. Raising TypeError yourself would cut off
the other operand’s chance to answer, and returning a wrong value, such as None or False, would hide a bug.
Every method of Vector2 builds a new vector and changes neither operand, which is what readers expect: after
c = a + b, a is what it was before. One vector may be stored in several places, as a dictionary key, in a list of
waypoints and in a variable, so a + that changed its left operand would change all of them at once. Amounts,
coordinates, dates and versions of your own should follow the same rule; in-place methods, which do change an
object, belong to containers such as lists.
Checkpoint
Check: How a + b is evaluated
Every operator has a method, and most have a reflected twin
Each operator below, with the method Python calls first on the left operand and the reflected method it tries next on the right one:
| Operator | Method | Reflected method |
|---|---|---|
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 @ b |
__matmul__ |
__rmatmul__ |
a < b, a <= b |
__lt__, __le__ |
__gt__, __ge__ of the other side |
Each arithmetic operator in the table also has an in-place twin, its name with an i after the leading underscores,
such as __iadd__ for += and __imul__ for *=. Operators with one operand have no reflected form: -a calls
__neg__, +a calls __pos__ and abs(a) calls __abs__.
Comparisons reflect into their mirror image: when a < b gets NotImplemented, Python asks b > a, and ==
reflects into == on the other side. An in-place operator falls back too: without __iadd__, a += b runs
a = a + b, which builds a new object and binds the name a to it.
Checkpoint
Check: The operator methods
A reflected add makes sum() work
sum() starts from the integer 0 and adds each item to the running total, so its first step is 0 + items[0]:
class Money:
def __init__(self, minor, currency="INR"):
self.minor, self.currency = minor, currency
def __repr__(self):
return f"{type(self).__name__}({self.minor!r}, {self.currency!r})"
def __add__(self, other):
if not isinstance(other, Money):
return NotImplemented
if other.currency != self.currency:
raise TypeError(f"cannot add {self.currency} and {other.currency}")
return type(self)(self.minor + other.minor, self.currency)
bill = [Money(4000), Money(3000), Money(18000)]
try:
print(sum(bill))
except TypeError as error:
print("sum(bill): TypeError:", error)
print("sum(bill, Money(0)):", sum(bill, Money(0)))
class SummableMoney(Money):
def __radd__(self, other):
if other == 0 and type(other) is int: # sum() starts from the int 0
return self
return NotImplemented
bill = [SummableMoney(4000), SummableMoney(3000), SummableMoney(18000)]
print("sum(bill):", sum(bill))
print("0 + bill[0]:", 0 + bill[0])
try:
print(5 + bill[0])
except TypeError as error:
print("5 + bill[0]: TypeError:", error) Output
sum(bill): TypeError: unsupported operand type(s) for +: 'int' and 'Money' sum(bill, Money(0)): Money(25000, 'INR') sum(bill): SummableMoney(25000, 'INR') 0 + bill[0]: SummableMoney(4000, 'INR') 5 + bill[0]: TypeError: unsupported operand type(s) for +: 'int' and 'SummableMoney'
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 money_sum.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The first attempt fails because int.__add__ refuses an amount and Money has no reflected method. Two fixes work.
Passing a start value of the right type, sum(bill, Money(0)), avoids the integer altogether. Or __radd__ can
accept the 0 that sum() starts with and return the amount unchanged, while still refusing other integers: adding 5
to an amount of money means nothing, so 5 + bill[0] still raises TypeError. Step through the first addition,
without a reflected method and with one:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The first step. sum() keeps a running total that starts as the int 0, so its first addition is 0 + bill[0], with the int on the left.
- int gives up. int.__add__(0, Money(4000)) does not understand an amount and returns NotImplemented.
- No reflected method. Python asks the amount for __radd__. Money has none, so the first addition raises TypeError for int and Money, and the second amount is never reached.
- SummableMoney. SummableMoney adds __radd__. The same first step reaches it as __radd__(bill[0], 0), with the int 0 as the other operand.
- Only the 0 of sum(). __radd__ accepts exactly the int 0 and returns the amount itself, so the running total becomes SummableMoney(4000). Any other int still gets NotImplemented.
- The rest of the bill. From the second item on, both operands are amounts, so __add__ answers on the left, and sum() returns SummableMoney(25000, 'INR').
Each fix works around the int on the left: a start value removes it, and __radd__ answers for it.
Checkpoint
Check: sum() and a reflected add
One decorator fills in the missing comparisons
Six comparison methods are a lot to write and to keep consistent. functools.total_ordering is a class decorator
that derives the missing ones from __eq__ and one ordering method:
from functools import total_ordering
@total_ordering
class ShirtSize:
ORDER = ("XS", "S", "M", "L", "XL", "XXL")
def __init__(self, label):
if label not in self.ORDER:
raise ValueError(f"unknown size {label!r}")
self.label = label
def __repr__(self):
return f"ShirtSize({self.label!r})"
def __eq__(self, other):
if not isinstance(other, ShirtSize):
return NotImplemented
return self.label == other.label
def __hash__(self):
return hash(self.label)
def __lt__(self, other):
if not isinstance(other, ShirtSize):
return NotImplemented
return self.ORDER.index(self.label) < self.ORDER.index(other.label)
orders = [ShirtSize(label) for label in ("L", "S", "XL", "M", "S")]
print(sorted(orders))
print(min(orders), max(orders))
m, l = ShirtSize("M"), ShirtSize("L")
print(m < l, m <= l, m > l, m >= l, m != l)
print("written by total_ordering:", [name for name in ("__le__", "__gt__", "__ge__") if name in vars(ShirtSize)]) Output
[ShirtSize('S'), ShirtSize('S'), ShirtSize('M'), ShirtSize('L'), ShirtSize('XL')]
ShirtSize('S') ShirtSize('XL')
True True False False True
written by total_ordering: ['__le__', '__gt__', '__ge__']
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 shirt_sizes.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
ShirtSize defines only __eq__ and __lt__, and its order comes from the shop’s list rather than the alphabet, so
"M" sorts before "L". sorted(), min() and max() all work, and the decorator wrote __le__, __gt__ and
__ge__. Step through two questions that the derived <= answers:
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- m <= l. ShirtSize defines no __le__ of its own. total_ordering wrote one that asks a < b first, here M < L.
- Smaller, so done. __lt__ compares positions in ORDER, 2 for M and 3 for L, and returns True. a <= b is then True without any call of __eq__.
- l <= m. With the operands swapped, __lt__ compares L with M and returns False, so the derived method still has to ask whether the two are equal.
- The extra call. __eq__ compares the labels L and M and returns False, so l <= m is False after two calls where m <= l needed one.
__lt__ alone settled m <= l, while l <= m needed __eq__ as well, a second call for the same question. Each
derived method also adds a call of its own around yours, so a class compared millions of times in a hot loop may
still define all six by hand.
Checkpoint
Check: Comparisons from one decorator
Overload an operator only where its meaning is obvious
An operator is a promise that the code means what the symbol means in school arithmetic or in the field the class
models. Keep it, and code reads like the formula it implements; break it, and every reader has to look up what +
does this time.
| Overload an operator when | Write a named method when |
|---|---|
| The type is a number, vector, matrix, money or a set-like collection | The meaning would have to be guessed, such as user + group |
The operator keeps its usual laws, such as a + b == b + a |
The operation has side effects, such as sending or saving |
Readers of the field already write it that way, as a @ b for matrices |
It needs options or can fail in several ways |
The standard library follows the same line: pathlib overloads / to join paths, sets and dictionaries overload
| for union and merging, and subtracting two dates gives a timedelta, because in each case the meaning is clear.
Checkpoint
Check: When to overload
Check yourself
Practice that adapts to you
Practise this lesson
14 questions about 12 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
In-place operators, subclass dispatch, query traps and Python 3.14, reproduced Pro: opens with a Pro, Premium or Ultimate pass
Four behaviours of operators surprise even experienced programmers: what += does to other names for the same
object, which method runs first when a subclass is involved, what and, or and precedence do to conditions built
from operators, and two changes in Python 3.14. Each is reproduced below with code you can run.
In this part:
- In-place addition with and without its method, and the aliasing it causes
- Who answers first: the reflected method of a subclass
- and, or and precedence: the query-builder traps, reproduced
- Two changes in Python 3.14
2 diagrams · 4 runnable examples (Python) · about 10 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Number types done right: reflected order, hashing like ints and a Ratio checked against Fraction Pro: opens with a Pro, Premium or Ultimate pass
A class that behaves like a number has to agree with Python’s own numbers: in the order of operands, in hashing and in
every result. This part reproduces the reflected-subtraction bug with a random check, shows why a number equal to 2
must hash like 2, compares what Fraction and Decimal do with floats, and ends with an exact Ratio type tested
against fractions.Fraction.
In this part:
- Reflected subtraction: the argument-order bug, caught by a random check
- Numbers that equal ints must hash like ints, reproduced
- Floats and decimals: what the standard library decides, and why
- Exercise: an exact Ratio type
- An exact Ratio type that agrees with fractions.Fraction
- Results of the sample tests
3 runnable examples (Python) · 1 exercise with sample tests · about 8 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
-
Fresher to mid · Warm-up
What is the difference between returning NotImplemented and raising NotImplementedError?
They are unrelated apart from the name. NotImplemented is a special value that a binary special method such as __add__ or __eq__ returns to say "I cannot handle this operand": Python then tries the other operand's reflected method, such as __radd__, and raises TypeError only when both sides give up, or falls back to identity for ==. NotImplementedError is an exception: you raise it in a method that subclasses must override, so a call that reaches the base version fails clearly. Mixing them up breaks things. Raising NotImplemented fails, because it is not an exception, and returning NotImplementedError from __add__ would make a + b evaluate to the exception class itself. Raising NotImplementedError from __add__ is a mistake too: the error ends the expression before the other operand is asked.
-
Fresher to mid
sum(prices) works on a list of floats but raises TypeError on a list of your Money objects. Explain the sequence of calls that leads to the error, give two fixes, and say which one you prefer and why.
-
Mid to senior
A teammate added __iadd__ to an immutable Money value type "for speed": it adds to self.minor and returns self. What can go wrong? Describe a concrete failure and how you would review the change.
-
Senior to staff
Your Ratio type compares equal to ints, so Ratio(4, 2) == 2 is True, and both kinds end up as keys of one dictionary. What must __hash__ satisfy, how do you implement it, and how do you test it?
Key takeaways
a + bcallstype(a).__add__(a, b); when that returnsNotImplemented, Python callstype(b).__radd__(b, a), and raisesTypeErrorwhen both give up.- Return
NotImplementedfor operands a method does not understand; never raise from it or returnNone. - Reflected methods make
2 * vandsum()work;sum(items, start)avoids the integer 0 altogether. functools.total_orderingderives the missing comparisons from__eq__and one ordering method.
Terms in this lesson
- operator overloading
- Giving Python's operators a meaning for your own classes by defining special methods: a + b calls type(a).__add__(a, b), a < b calls __lt__ and -a calls __neg__.
- reflected method
- The method Python calls on the right operand when the left operand's method returns NotImplemented, such as __radd__ for + and __rmul__ for *. In 2 * v, int.__mul__ gives up and the vector's __rmul__ answers.
References
- The Python Language Reference: emulating numeric types (Python Software Foundation)
- The Python Language Reference: rich comparison methods (Python Software Foundation)
- Built-in Constants: NotImplemented (Python Software Foundation)
- functools.total_ordering() (Python Software Foundation)
- numbers: implementing the arithmetic operations (Python Software Foundation)
- Built-in Types: hashing of numeric types (Python Software Foundation)
- fractions: rational numbers (Python Software Foundation)
- decimal: decimal fixed-point and floating-point arithmetic (Python Software Foundation)
- What's New In Python 3.14: other language changes (Python Software Foundation)
- PEP 207: rich comparisons (Python Software Foundation)
- Command line and environment: the -W option (Python Software Foundation)
- The Python Language Reference: Boolean operations (Python Software Foundation)
- The Python Language Reference: operator precedence (Python Software Foundation)
- pandas user guide: Boolean indexing (pandas development team)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress