Warm-up · Activity 1 of 7
// A1.2 · ~30 min · Advanced
Operator overloading
After this lesson you can make +, * and the comparison operators work on your own classes, including sum(), 3 * x and sorted().
You will be able to
- Implement __add__, __radd__ and __mul__, returning NotImplemented for operands you do not support
- Predict when Python calls a reflected method such as __radd__, and what += does
- Define ordering with rich comparisons or with functools.total_ordering
Predict · Activity 2 of 7
Predict before you read on: Money supports Money + Money. What happens here?
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) total = sum([Money(100), Money(250)]) print(total.cents)Practice · Activity 3 of 7
Money(5) * 3 already works. Name the method that makes 3 * Money(5) work too.
class Money: def __init__(self, cents): self.cents = cents def __mul__(self, factor): if not isinstance(factor, int): return NotImplemented return Money(self.cents * factor) def ____(self, factor): return self.__mul__(factor)def (self, factor):Practice · Activity 4 of 7
Money defines only __lt__. What does this print?
class Money: def __init__(self, cents): self.cents = cents def __lt__(self, other): return self.cents < other.cents print(Money(5) > Money(3))Practice · Activity 5 of 7
a and b are instances of a class with __add__, __radd__ and __lt__, and nothing else. Match each expression to the call Python makes.
Brain teaser · Activity 6 of 7
Brain teaser. total_ordering fills in the missing comparisons. What does this print?
from functools import total_ordering @total_ordering class Money: def __init__(self, cents): self.cents = cents def __lt__(self, other): return self.cents < other.cents print(Money(5) <= Money(5))Apply · Activity 7 of 7
Mini-task. Write a class Distance(metres) whose objects add with +, work with sum(), sort with sorted() and compare with all four ordering operators. Use total_ordering. Try sum, sorted and max on Distance(1200), Distance(800) and Distance(450).
Check your work against this list
Build it yourself
Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.
Worked example
A Money type with arithmetic and ordering
Money adds with + and sum(), multiplies by whole numbers from either side, and orders through total_ordering. It refuses to mix currencies with a ValueError, because that is a wrong value, but answers NotImplemented for a float, so Python raises the usual TypeError. Change the basket or try Money(1) * 1.5.
main.py
from functools import total_ordering
@total_ordering
class Money:
"""An amount in cents of one currency."""
def __init__(self, cents: int, currency: str = "EUR") -> None:
self.cents = cents
self.currency = currency
def __repr__(self) -> str:
return f"Money({self.cents}, {self.currency!r})"
def _check(self, other: "Money") -> None:
if other.currency != self.currency:
raise ValueError(f"cannot mix {self.currency} and {other.currency}")
def __add__(self, other: object) -> "Money":
if isinstance(other, int) and other == 0: # sum() starts with 0
return self
if not isinstance(other, Money):
return NotImplemented
self._check(other)
return Money(self.cents + other.cents, self.currency)
__radd__ = __add__
def __mul__(self, factor: object) -> "Money":
if not isinstance(factor, int):
return NotImplemented
return Money(self.cents * factor, self.currency)
__rmul__ = __mul__
def __eq__(self, other: object) -> bool:
if not isinstance(other, Money):
return NotImplemented
return (self.cents, self.currency) == (other.cents, other.currency)
def __lt__(self, other: "Money") -> bool:
if not isinstance(other, Money):
return NotImplemented
self._check(other)
return self.cents < other.cents
def __hash__(self) -> int:
return hash((self.cents, self.currency))
basket = [Money(450), Money(120), Money(999)]
print("Total:", sum(basket))
print("Three coffees:", 3 * Money(320))
print("Cheapest first:", sorted(basket))
print("Over 5 EUR:", [m for m in basket if m >= Money(500)])
try:
Money(100) + 2.5
except TypeError as err:
print("TypeError:", err)
try:
Money(100) < Money(100, "USD")
except ValueError as err:
print("ValueError:", err)
Run it with
python main.pyOutput
Total: Money(1569, 'EUR')
Three coffees: Money(960, 'EUR')
Cheapest first: [Money(120, 'EUR'), Money(450, 'EUR'), Money(999, 'EUR')]
Over 5 EUR: [Money(999, 'EUR')]
TypeError: unsupported operand type(s) for +: 'Money' and 'float'
ValueError: cannot mix EUR and USD- sum() works because __radd__ = __add__ accepts the starting 0.
- 3 * Money(320) goes to __rmul__, because int.__mul__ returns NotImplemented for Money.
- m >= Money(500) comes from total_ordering: the class only wrote __eq__ and __lt__.
- Money + 2.5 returns NotImplemented, float has no answer either, so Python raises TypeError.
Change it and run it
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Exercises
Exercise 1 of 2
Adding durations
Make Duration in main.py support Duration + Duration, sum() over a list of durations, and multiplication by an int from either side: Duration(15) * 3 and 3 * Duration(15). Always return a new Duration. For any other operand, return NotImplemented, so Duration(5) + "x" raises TypeError.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Hints
Hint 1
sum() computes 0 + first item, so __radd__ must accept the int 0.
Hint 2
In __add__, handle a Duration, then the int 0 (return self), and return NotImplemented for everything else. Then write __radd__ = __add__.
Hint 3
__mul__ checks isinstance(factor, int) and returns Duration(self.minutes * factor); __rmul__ = __mul__ covers 3 * Duration(15).
Show a solution
One way to solve it. Yours can look different and still pass the checks.
class Duration:
"""A length of time in whole minutes."""
def __init__(self, minutes: int) -> None:
self.minutes = minutes
def __repr__(self) -> str:
return f"Duration({self.minutes})"
def __add__(self, other: object) -> "Duration":
if isinstance(other, Duration):
return Duration(self.minutes + other.minutes)
if isinstance(other, int) and other == 0:
return self
return NotImplemented
__radd__ = __add__
def __mul__(self, factor: object) -> "Duration":
if not isinstance(factor, int):
return NotImplemented
return Duration(self.minutes * factor)
__rmul__ = __mul__
if __name__ == "__main__":
print(sum([Duration(30), Duration(45)]), 2 * Duration(15))
Run it on your computer
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
class Duration:
"""A length of time in whole minutes."""
def __init__(self, minutes: int) -> None:
self.minutes = minutes
def __repr__(self) -> str:
return f"Duration({self.minutes})"
# Add __add__ (Duration + Duration, and Duration + 0 for sum()),
# __radd__, __mul__ (Duration * int) and __rmul__ (int * Duration).
# Return NotImplemented for any other operand.
if __name__ == "__main__":
print(sum([Duration(30), Duration(45)]), 2 * Duration(15))
test_main.py
from main import Duration
def test_add():
"""Duration + Duration adds the minutes"""
got = (Duration(30) + Duration(45)).minutes
assert got == 75, f"Duration(30) + Duration(45) has {got} minutes, expected 75"
def test_sum():
"""sum() of durations works"""
got = sum([Duration(10), Duration(20), Duration(30)]).minutes
assert got == 60, f"sum() gave {got} minutes, expected 60: sum() starts with 0 + Duration(10)"
def test_multiply_both_sides():
"""Duration * 3 and 3 * Duration both work"""
got = (Duration(15) * 3).minutes, (3 * Duration(15)).minutes
assert got == (45, 45), f"got {got!r} minutes, expected (45, 45)"
def test_other_types():
"""Adding a string raises TypeError, because __add__ returns NotImplemented"""
assert Duration(5).__add__("x") is NotImplemented, "Duration(5).__add__('x') should return NotImplemented"
try:
Duration(5) + "x"
except TypeError:
return
raise AssertionError("Duration(5) + 'x' did not raise TypeError")
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyExercise 2 of 2
Ordering version numbers
Version(1, 10, 0) must sort after Version(1, 2, 3). Decorate the class with @total_ordering and add __eq__ and __lt__ that compare (major, minor, patch). Both return NotImplemented for other types, so Version(1, 0, 0) < "2" raises TypeError. Add a matching __hash__, since you define __eq__.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Hints
Hint 1
Tuples compare item by item, so (1, 2, 3) < (1, 10, 0) is True, unlike the strings "1.2.3" and "1.10.0".
Hint 2
A helper def _key(self) returning (self.major, self.minor, self.patch) keeps __eq__, __lt__ and __hash__ in step.
Hint 3
Put @total_ordering on the line above class Version. It then derives <=, > and >= from __eq__ and __lt__.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
from functools import total_ordering
@total_ordering
class Version:
"""A version number such as 1.10.2, ordered part by part."""
def __init__(self, major: int, minor: int, patch: int) -> None:
self.major = major
self.minor = minor
self.patch = patch
def __repr__(self) -> str:
return f"Version({self.major}, {self.minor}, {self.patch})"
def _key(self) -> tuple[int, int, int]:
return (self.major, self.minor, self.patch)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Version):
return NotImplemented
return self._key() == other._key()
def __lt__(self, other: object) -> bool:
if not isinstance(other, Version):
return NotImplemented
return self._key() < other._key()
def __hash__(self) -> int:
return hash(self._key())
if __name__ == "__main__":
print(sorted([Version(1, 10, 0), Version(1, 2, 3)]))
Run it on your computer
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
from functools import total_ordering
class Version:
"""A version number such as 1.10.2, ordered part by part."""
def __init__(self, major: int, minor: int, patch: int) -> None:
self.major = major
self.minor = minor
self.patch = patch
def __repr__(self) -> str:
return f"Version({self.major}, {self.minor}, {self.patch})"
# Decorate the class with @total_ordering, then add __eq__ and __lt__.
# Both return NotImplemented when other is not a Version.
if __name__ == "__main__":
print(sorted([Version(1, 10, 0), Version(1, 2, 3)]))
test_main.py
from main import Version
def test_sorted():
"""1.2.3 sorts before 1.10.0, which is not the string order"""
got = sorted([Version(1, 10, 0), Version(1, 2, 3), Version(0, 9, 9)])
want = [Version(0, 9, 9), Version(1, 2, 3), Version(1, 10, 0)]
assert got == want, f"sorted() gave {got!r}, expected {want!r}"
def test_all_four_orderings():
"""<, <=, > and >= all work"""
a, b = Version(1, 2, 3), Version(1, 2, 4)
got = a < b, a <= b, b > a, b >= a, a <= Version(1, 2, 3)
assert got == (True, True, True, True, True), f"the comparisons gave {got!r}, all should be True"
def test_equality():
"""Equal parts mean equal versions; a string is never equal"""
assert Version(2, 0, 0) == Version(2, 0, 0), "Version(2, 0, 0) == Version(2, 0, 0) is False"
assert Version(2, 0, 0) != "2.0.0", "a Version should not equal the string '2.0.0'"
def test_other_types():
"""Ordering against a string raises TypeError"""
try:
Version(1, 0, 0) < "2"
except TypeError:
return
raise AssertionError("Version(1, 0, 0) < '2' did not raise TypeError: return NotImplemented for other types")
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyCommon mistakes
sum() without __radd__
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)
total = sum([Money(100), Money(250)])
What Python prints
TypeError: unsupported operand type(s) for +: 'int' and 'Money'Why, and the fix
sum() starts with 0, and 0 + Money(100) needs Money.__radd__. Accept 0 in __add__ and add __radd__ = __add__, or give sum() a start value: sum(items, Money(0)).
total_ordering without an ordering method
from functools import total_ordering
@total_ordering
class Version:
def __init__(self, major):
self.major = major
def __eq__(self, other):
return self.major == other.major
What Python prints
ValueError: must define at least one ordering operation: < > <= >=Why, and the fix
total_ordering derives the missing comparisons from one it is given. __eq__ does not count; define one of __lt__, __le__, __gt__ or __ge__ as well. The error appears when the class is created, before any object exists.
__add__ that changes self and returns nothing
class Money:
def __init__(self, cents):
self.cents = cents
def __add__(self, other):
self.cents += other.cents
total = Money(100) + Money(250)
print(total.cents)
What Python prints
AttributeError: 'NoneType' object has no attribute 'cents'Why, and the fix
An operator method must return its result; without return, x + y evaluates to None. It should also leave both operands unchanged: return Money(self.cents + other.cents). Changing self in place is the job of __iadd__, which serves +=.
Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source
Exit ticket
5 questions, no hints. Score 80% or more to complete the lesson.
Finish every activity above to unlock the exit ticket.