Python operator overloading practice — fractions that simplify themselves, money without cent errors and the library shelf (and the dinner kitty that was always one cent off)
At the end-of-first-year dinner we had a kitty and someone wrote a little Python program to split the costs. It added the amounts as float, compared the total with what had been collected… and it never matched: there was always “0.000000000004” too much or too little. It wasn’t a bug in the program, it’s how computers store decimals (0.1 + 0.2 isn’t exactly 0.3). With a Money class that stores whole cents and knows how to add itself with +, that problem disappears, and it’s precisely one of the three programs in this practice.
In the operator overloading theory you saw __eq__, __add__, the reflected operators, @total_ordering and __iadd__ with Vector2D. Here we use them in three intermediate-level classes, with data that looks like the real thing and a few bad cases on purpose:
Rational: fractions that simplify themselves, with full arithmetic, comparisons,hashand the reflected operators that don’t commute (the classic FP2 example).Money: amounts in whole cents, with currency conversion,sum(), instalments and comparisons across currencies.Shelf: a shelf of the faculty library that you use like a list (len,[i], slices,in,+=and+).
Table of Contents
—
Before you start
Make sure you’re clear on:
- That
a + bisa.__add__(b)and that, if it returnsNotImplemented, Python triesb.__radd__(a). - The difference between
__str__and__repr__. - That
@total_orderingneeds__eq__and one of the ordering methods. - That
+=without__iadd__creates a new object, and with__iadd__it modifies the same one (returned withreturn self).
If any of them sounds shaky, review the theory before going on.
Run each program in full before reading the explanation. The last lines of each one test cases that must fail (with eval or exec inside a try), so you see the real error without the program stopping.
—
Program 1: the Rational class
A rational number is a fraction num/den with a non-zero denominator. We want it to behave like any other number: 3/4 + 1/2 should give 5/4, 6/8 and 3/4 should be equal (also inside a set), they should sort and add up with sum(), and mixing with integers should work both ways (r1 + 1 and 1 + r1, r1 - 1 and 1 - r1). What makes it tricky: subtraction and division don’t commute, and a zero denominator can appear when creating the fraction or when dividing by a fraction worth 0.
The idea
The key decision is to simplify in __init__: if every fraction is stored already reduced and with the sign in the numerator, comparing is as easy as comparing numerators and denominators, and the hash comes for free. Then a helper _as_rational turns integers into Rational(n) (and returns None for anything else), so every operator starts the same way: convert the other operand or return NotImplemented.
Before coding, write the four formulas on paper: a/b + c/d = (a·d + c·b)/(b·d), subtraction the same with −, product (a·c)/(b·d) and division (a·d)/(b·c). And think: which reflected methods can call the direct one, and which can’t?
The code
from functools import total_ordering
from math import gcd
@total_ordering
class Rational:
def __init__(self, num, den=1):
if den == 0:
raise ZeroDivisionError(f'denominator 0 in {num}/{den}')
if den < 0: # the sign always goes on top
num, den = -num, -den
m = gcd(num, den) # simplify on creation: 6/8 -> 3/4
self.__num = num // m
self.__den = den // m
@property
def num(self):
return self.__num
@property
def den(self):
return self.__den
@staticmethod
def _as_rational(x): # int -> Rational; anything else, None
if isinstance(x, Rational):
return x
if isinstance(x, int):
return Rational(x)
return None
def __str__(self):
return str(self.__num) if self.__den == 1 else f'{self.__num}/{self.__den}'
def __repr__(self):
return f'Rational({self.__num}, {self.__den})'
# --- comparison
def __eq__(self, other):
o = Rational._as_rational(other)
if o is None:
return NotImplemented
return self.__num == o.num and self.__den == o.den # already simplified
def __lt__(self, other):
o = Rational._as_rational(other)
if o is None:
return NotImplemented
return self.__num * o.den < o.num * self.__den # a/b < c/d <=> a·d < c·b
def __hash__(self): # equal (3/4 and 6/8) -> same hash
return hash((self.__num, self.__den))
# --- arithmetic
def __add__(self, other):
o = Rational._as_rational(other)
if o is None:
return NotImplemented
return Rational(self.__num * o.den + o.num * self.__den, self.__den * o.den)
def __sub__(self, other):
o = Rational._as_rational(other)
if o is None:
return NotImplemented
return Rational(self.__num * o.den - o.num * self.__den, self.__den * o.den)
def __mul__(self, other):
o = Rational._as_rational(other)
if o is None:
return NotImplemented
return Rational(self.__num * o.num, self.__den * o.den)
def __truediv__(self, other):
o = Rational._as_rational(other)
if o is None:
return NotImplemented
return Rational(self.__num * o.den, self.__den * o.num) # 0 on top -> error
# --- reflected: the number is on the LEFT
def __radd__(self, other): # addition and product are commutative
return self + other
def __rmul__(self, other):
return self * other
def __rsub__(self, other): # other - self: NOT self - other
o = Rational._as_rational(other)
return NotImplemented if o is None else o - self
def __rtruediv__(self, other): # other / self
o = Rational._as_rational(other)
return NotImplemented if o is None else o / self
# --- unary operators and conversions
def __neg__(self):
return Rational(-self.__num, self.__den)
def __abs__(self):
return Rational(abs(self.__num), self.__den)
def __float__(self):
return self.__num / self.__den
r1, r2, r3 = Rational(3, 4), Rational(1, 2), Rational(6, -8)
print('r1 =', r1, ' r2 =', r2, ' r3 =', r3, ' ', repr(r3))
print('r1 + r2 =', r1 + r2, ' r1 - r2 =', r1 - r2, ' r1 * r2 =', r1 * r2, ' r1 / r2 =', r1 / r2)
print('r1 + 1 =', r1 + 1, ' 1 + r1 =', 1 + r1, ' 2 * r1 =', 2 * r1)
print('1 - r1 =', 1 - r1, ' r1 - 1 =', r1 - 1, ' 3 / r1 =', 3 / r1)
print('-r1 =', -r1, ' abs(r3) =', abs(r3), ' float(r1) =', float(r1))
print('r1 == Rational(6, 8):', r1 == Rational(6, 8), ' r2 == 0.5:', r2 == 0.5)
print('r2 < r1:', r2 < r1, ' r1 >= r2:', r1 >= r2, ' r3 < 0:', r3 < 0)
recipe = [Rational(1, 2), Rational(1, 3), Rational(1, 6)]
print('sum(recipe) =', sum(recipe), ' sorted:', sorted(recipe))
print('set:', {Rational(1, 2), Rational(2, 4), Rational(3, 6)})
for expr in ['Rational(1, 0)', 'r1 / Rational(0, 5)', 'r1 + 0.5']:
try:
eval(expr)
except (ZeroDivisionError, TypeError) as err:
print(f'{expr:<20} -> {type(err).__name__}: {err}')
__init__ normalises the fraction:
def __init__(self, num, den=1):
if den == 0:
raise ZeroDivisionError(f'denominator 0 in {num}/{den}')
if den < 0: # the sign always goes on top
num, den = -num, -den
m = gcd(num, den) # simplify on creation: 6/8 -> 3/4
self.__num = num // m
self.__den = den // m
With Rational(6, -8): the denominator is negative, so both signs flip (−6/8); gcd(-6, 8) is 2 and we get −3/4. From then on, any Rational equal to −3/4 has exactly num = -3 and den = 4.
The reflected methods are the delicate part:
def __radd__(self, other): # addition and product are commutative
return self + other
def __rmul__(self, other):
return self * other
def __rsub__(self, other): # other - self: NOT self - other
o = Rational._as_rational(other)
return NotImplemented if o is None else o - self
def __rtruediv__(self, other): # other / self
o = Rational._as_rational(other)
return NotImplemented if o is None else o / self
__radd__ and __rmul__ can reuse the direct one because addition and multiplication commute. __rsub__ and __rtruediv__ can’t: they turn the number on the left into a Rational and do the operation in its order, o - self and o / self.
Output
r1 = 3/4 r2 = 1/2 r3 = -3/4 Rational(-3, 4)
r1 + r2 = 5/4 r1 - r2 = 1/4 r1 * r2 = 3/8 r1 / r2 = 3/2
r1 + 1 = 7/4 1 + r1 = 7/4 2 * r1 = 3/2
1 - r1 = 1/4 r1 - 1 = -1/4 3 / r1 = 4
-r1 = -3/4 abs(r3) = 3/4 float(r1) = 0.75
r1 == Rational(6, 8): True r2 == 0.5: False
r2 < r1: True r1 >= r2: True r3 < 0: True
sum(recipe) = 1 sorted: [Rational(1, 6), Rational(1, 3), Rational(1, 2)]
set: {Rational(1, 2)}
Rational(1, 0) -> ZeroDivisionError: denominator 0 in 1/0
r1 / Rational(0, 5) -> ZeroDivisionError: denominator 0 in 3/0
r1 + 0.5 -> TypeError: unsupported operand type(s) for +: 'Rational' and 'float'
Let’s check some of the maths:
| Operation | Calculation | Simplified |
|---|---|---|
| r1 + r2 | (3·2 + 1·4) / (4·2) = 10/8 | 5/4 |
| r1 − r2 | (3·2 − 1·4) / 8 = 2/8 | 1/4 |
| r1 / r2 | (3·2) / (4·1) = 6/4 | 3/2 |
| 1 − r1 | (1·4 − 3·1) / 4 | 1/4 |
| r1 − 1 | (3·1 − 1·4) / 4 | −1/4 |
| 3 / r1 | (3·4) / (1·3) = 12/3 | 4 (printed without “/1”) |
And sum(recipe), step by step:
| Step | Operation | Who does it | Result |
|---|---|---|---|
| 1 | 0 + 1/2 | int doesn’t know → Rational.__radd__ | 1/2 |
| 2 | 1/2 + 1/3 | Rational.__add__ | 5/6 |
| 3 | 5/6 + 1/6 | Rational.__add__ | 6/6 = 1 |
The set {1/2, 2/4, 3/6} keeps a single element: all three simplify to 1/2, they’re equal by __eq__ and they have the same hash.
Two lines deserve a pause. r2 == 0.5 is False even though 1/2 is 0.5: our __eq__ only accepts integers and rationals, it returns NotImplemented for a float, the float doesn’t know how to compare itself with a Rational either, and Python ends up comparing identities. It’s consistent with the last line: r1 + 0.5 raises TypeError. Mixing exact fractions with approximate decimals is a design decision; here we chose not to. And both divisions by zero come from the same place, __init__: r1 / Rational(0, 5) computes Rational(3·1, 4·0), and it’s the constructor that spots the 0.
Questions you might ask
Why does __eq__ compare num and den directly, without cross-multiplying? Because every fraction is simplified from __init__. 3/4 and 6/8 reach __eq__ as 3/4 and 3/4. In __lt__ we do cross-multiply (a·d < c·b), because there looking at the numbers separately isn’t enough.
Why are the attributes private with read-only properties? Because __hash__ depends on them. If someone could do r.num = 5 with the fraction inside a set, the set would no longer find it. A number shouldn’t change after it’s created.
Why is _as_rational a @staticmethod? Because it doesn’t need self: it takes anything and returns a Rational or None. Every operator uses it, so the “which types do I accept” rule lives in one place.
What happens with 2 * r1? int.__mul__(2, r1) returns NotImplemented and Python calls r1.__rmul__(2), which returns r1 * 2 = 6/4 = 3/2.
Why does abs(r3) show 3/4 and not Rational(3, 4)? Because print uses __str__. The repr (Rational(-3, 4)) only appears where we ask for it with repr() or inside collections, as on the sorted line.
def __rsub__(self, other): return self - other. It runs, it works for addition… and 1 - r1 gives −1/4. Always test reflected methods with a case that isn’t symmetric.
Try it yourself
- Add
__pow__so thatr1 ** 2gives 9/16 (with an integer exponent; if it’s negative, invert the fraction). __float__already exists: tryround(float(r1), 2)and then write__round__soround(r1, 2)works without converting first.
—
Program 2: the Money class
The faculty library’s till collects fines in euros (the ones from the inheritance practice) and one day receives a donation in dollars. We want to add amounts without cent errors, mix currencies by converting the right operand to the left one’s currency, split the total into instalments, compute ratios, compare 25 $ with 20 € and have sum() work on a list of amounts. Exchange rates are made up and fixed (1 € = 1.08 $ = 0.85 £); in a real program they’d come from an external service.
The idea
Store whole cents (self.cents = round(amount * 100)): integers have no rounding errors. Every operation works with integers and only divides by 100 when printing. To mix currencies, the to(currency) method converts through euros. And each operator decides which types it accepts: Money + Money yes, Money + 5 no (5 what?), Money * number yes, Money * Money no (euros squared?).
Before coding, decide what each combination returns. Money / number is instalments (money); money / money is a ratio (a number with no currency). The same operator can return different types depending on the operand.
The code
from functools import total_ordering
RATES = {'EUR': 1.0, 'USD': 1.08, 'GBP': 0.85} # units per 1 euro (made up, fixed)
SYMBOL = {'EUR': '€', 'USD': '$', 'GBP': '£'}
@total_ordering
class Money:
def __init__(self, amount, currency='EUR'):
if currency not in RATES:
raise ValueError(f'unsupported currency: {currency!r}')
self.cents = round(amount * 100) # integer: no 0.1 + 0.2 issues
self.currency = currency
@classmethod
def _from_cents(cls, cents, currency):
d = cls(0, currency)
d.cents = cents
return d
def to(self, currency): # convert currency
euros = self.cents / RATES[self.currency]
return Money._from_cents(round(euros * RATES[currency]), currency)
def __str__(self):
return f'{self.cents / 100:.2f} {SYMBOL[self.currency]}'
def __repr__(self):
return f'Money({self.cents / 100}, {self.currency!r})'
def __add__(self, other): # the result's currency: the left one's
if not isinstance(other, Money):
return NotImplemented
return Money._from_cents(self.cents + other.to(self.currency).cents, self.currency)
def __radd__(self, other): # only for sum(): 0 + money
return self if other == 0 else NotImplemented
def __sub__(self, other):
if not isinstance(other, Money):
return NotImplemented
return Money._from_cents(self.cents - other.to(self.currency).cents, self.currency)
def __mul__(self, n): # money * number (not money * money)
if not isinstance(n, (int, float)):
return NotImplemented
return Money._from_cents(round(self.cents * n), self.currency)
def __rmul__(self, n):
return self * n
def __truediv__(self, other):
if isinstance(other, Money): # money / money -> number (ratio)
return self.cents / other.to(self.currency).cents
if isinstance(other, (int, float)): # money / number -> money (instalments)
return Money._from_cents(round(self.cents / other), self.currency)
return NotImplemented
def __eq__(self, other): # compare the value in euros
if not isinstance(other, Money):
return NotImplemented
return self.to('EUR').cents == other.to('EUR').cents
def __lt__(self, other):
if not isinstance(other, Money):
return NotImplemented
return self.to('EUR').cents < other.to('EUR').cents
def __hash__(self):
return hash(self.to('EUR').cents)
def __bool__(self):
return self.cents != 0
print('With float: 0.1 + 0.2 == 0.3 ->', 0.1 + 0.2 == 0.3)
print('With Money:', Money(0.1) + Money(0.2), '== 0.30 € ->', Money(0.1) + Money(0.2) == Money(0.3))
fines = [Money(1.20), Money(2.00), Money(0.50), Money(0)]
till = sum(fines) # works thanks to __radd__
print('Fines:', [str(m) for m in fines], '-> till:', till)
donation = Money(25, 'USD')
print('Donation:', donation, '=', donation.to('EUR'))
till = till + donation
print('Till + donation:', till, ' (the other way round:', donation + Money(3.70), ')')
print('One of 3 instalments:', till / 3, ' 3 instalments:', 3 * (till / 3))
print('Fines/till ratio:', round(sum(fines) / till, 3))
print('25 $ > 20 €:', donation > Money(20), ' 17 £ == 20 €:', Money(17, 'GBP') == Money(20))
print('Sorted:', sorted([Money(20), donation, Money(17, 'GBP')]))
print('No fines:', [str(m) for m in fines if not m])
for expr in ['till * till', 'till + 5', "Money(3, 'JPY')"]:
try:
eval(expr)
except (TypeError, ValueError) as err:
print(f'{expr:<16} -> {type(err).__name__}: {err}')
Addition converts the right operand and keeps the left one’s currency; __radd__ only exists for sum():
def __add__(self, other): # the result's currency: the left one's
if not isinstance(other, Money):
return NotImplemented
return Money._from_cents(self.cents + other.to(self.currency).cents, self.currency)
def __radd__(self, other): # only for sum(): 0 + money
return self if other == 0 else NotImplemented
Output
With float: 0.1 + 0.2 == 0.3 -> False With Money: 0.30 € == 0.30 € -> True Fines: ['1.20 €', '2.00 €', '0.50 €', '0.00 €'] -> till: 3.70 € Donation: 25.00 $ = 23.15 € Till + donation: 26.85 € (the other way round: 29.00 $ ) One of 3 instalments: 8.95 € 3 instalments: 26.85 € Fines/till ratio: 0.138 25 $ > 20 €: True 17 £ == 20 €: True Sorted: [Money(20.0, 'EUR'), Money(17.0, 'GBP'), Money(25.0, 'USD')] No fines: ['0.00 €'] till * till -> TypeError: unsupported operand type(s) for *: 'Money' and 'Money' till + 5 -> TypeError: unsupported operand type(s) for +: 'Money' and 'int' Money(3, 'JPY') -> ValueError: unsupported currency: 'JPY'
The maths, step by step:
| Operation | In cents | Result |
|---|---|---|
| sum(fines) | 0 + 120 + 200 + 50 + 0 | 370 → 3.70 € |
| 25 $ to euros | 2500 / 1.08 = 2314.8 → 2315 | 23.15 € |
| till + donation | 370 + 2315 | 2685 → 26.85 € |
| donation + 3.70 € | 370 × 1.08 = 399.6 → 400; 2500 + 400 | 2900 → 29.00 $ |
| till / 3 | 2685 / 3 = 895 | 8.95 € |
| fines / till | 370 / 2685 | 0.138 |
| 17 £ to euros | 1700 / 0.85 = 2000 | equal to 20 € |
Notice that till + donation and donation + Money(3.70) give different currencies: the result has the left operand’s currency, because it’s its __add__ that runs. And in sorted, the 20 € and the 17 £ are worth the same; sorted is stable and keeps first the one that was already first.
[str(m) for m in fines if not m] uses __bool__: a 0 € fine is “falsy”, just like the number 0.
The last three lines are the rejections: till * till raises TypeError because __mul__ returns NotImplemented with another Money (and __rmul__ does too, since it calls self * n); till + 5 likewise; and Money(3, 'JPY') is stopped by __init__ with a ValueError.
Questions you might ask
Why round(amount * 100) and not int(amount * 100)? Because with float many calculations land a hair below: 0.29 * 100 gives 28.999999999999996, and int chops the decimals (28 cents). round makes it 29.
What’s _from_cents for? To create a Money straight from cents without going through __init__ (which expects euros and would multiply by 100 again). It’s a @classmethod acting as an alternative constructor, like the factory in the classes topic.
Why does __eq__ compare the value in euros? So that 17 £ and 20 € are equal if they’re worth the same. And __hash__ uses the same data, because equal objects must have the same hash.
Why does __radd__ only accept 0? Because it’s the only number that makes sense in a sum of money: the 0 that sum() starts with. 5 + till still raises TypeError, as it should.
And if splitting into instalments isn’t exact? round(self.cents / other) rounds to the cent. 2685 / 3 is exact (895); with 2686 / 3, each instalment would be 8.95 € and one cent would be missing. In a real program, the last instalment is adjusted so the sum matches.
Storing money as float and comparing with ==. Money(0.1) + Money(0.2) == Money(0.3) is True here because they’re whole cents; with float it would be False, as on the first line of the output.
Try it yourself
- Add
__neg__and__abs__to represent refunds (−5.00 €). - Make
Moneyaccept+with a number only if it’s 0 (besides__radd__), and think about whethertill + 0makes sense or it’s better for it to keep failing.
—
Program 3: the library shelf
Back to the faculty library. An Shelf holds books and we want to use it as if it were a list: know how many it has (len), whether it’s empty (if not s), get the first or the last one (s[0], s[-1]), take a slice (s[1:3]), ask whether a title is there ('Calculus' in s), add books with += without duplicates and join two shelves with +. What makes it tricky: two books are the same if they have the same ISBN even if they’re different objects, and += and + must behave differently (modify versus create).
The idea
Book defines __eq__ and __hash__ by ISBN and __lt__ by title (for sorted). Shelf keeps a private list and exposes it through magic methods: __len__, __getitem__, __contains__, __iadd__ and __add__. Whoever uses the shelf doesn’t know there’s a list inside; they just see it behaves like one.
Before coding, think about what __getitem__ receives with s[1:3]: not an integer, but a slice object. And what a slice of a shelf should return: a list or another shelf?
The code
class Book:
def __init__(self, title, isbn):
self.title = title
self.isbn = isbn
def __repr__(self):
return f'Book({self.title!r})'
def __eq__(self, other): # same ISBN, same book
if not isinstance(other, Book):
return NotImplemented
return self.isbn == other.isbn
def __hash__(self):
return hash(self.isbn)
def __lt__(self, other): # for sorted(): by title
return self.title < other.title
class Shelf:
def __init__(self, name, books=()):
self.name = name
self.__books = list(books) # its own copy
def __len__(self): # len(s) and also if s:
return len(self.__books)
def __getitem__(self, i): # s[0], s[-1], s[1:3]
if isinstance(i, slice):
return Shelf(f'{self.name}[{i.start}:{i.stop}]', self.__books[i])
return self.__books[i]
def __contains__(self, x): # "in" by book or by title
if isinstance(x, str):
return any(l.title == x for l in self.__books)
return x in self.__books
def __iadd__(self, book): # s += book (changes THIS shelf)
if not isinstance(book, Book):
return NotImplemented
if book in self.__books:
raise ValueError(f'‘{book.title}’ is already on {self.name}')
self.__books.append(book)
return self
def __add__(self, another): # s1 + s2 -> a NEW shelf
if not isinstance(another, Shelf):
return NotImplemented
together = self.__books + [l for l in another.__books if l not in self.__books]
return Shelf(f'{self.name}+{another.name}', together)
def __str__(self):
return f'{self.name} ({len(self)}): ' + ', '.join(l.title for l in self.__books)
python = Book('Python for Everybody', '978-1530051120')
calculus = Book('Calculus', '978-6075265490')
networks = Book('Understanding Networks', '978-8490355282')
data = Book('Data Structures in C', '978-8448142276')
prog = Shelf('Programming', [python, data])
maths = Shelf('Mathematics')
print(len(maths), 'books;', 'empty' if not maths else 'with books')
maths += calculus
maths += networks
alias = maths
maths += Book('Calculus (2nd printing)', '978-6075265490'.replace('490', '491'))
print(maths, '| alias is maths:', alias is maths)
print(prog[0], prog[-1], len(prog))
print('‘Calculus’ in maths?', 'Calculus' in maths, ' python in maths?', python in maths)
all_books = prog + maths
print(all_books)
print(all_books[1:3])
print('Sorted:', sorted([all_books[i] for i in range(len(all_books))]))
for expr in ['maths += calculus', 'prog[10]', "maths += 'Algebra'"]:
try:
exec(expr)
except (ValueError, IndexError, TypeError) as err:
print(f'{expr:<18} -> {type(err).__name__}: {err}')
The methods that make it look like a list:
def __len__(self): # len(s) and also if s:
return len(self.__books)
def __getitem__(self, i): # s[0], s[-1], s[1:3]
if isinstance(i, slice):
return Shelf(f'{self.name}[{i.start}:{i.stop}]', self.__books[i])
return self.__books[i]
def __contains__(self, x): # "in" by book or by title
if isinstance(x, str):
return any(l.title == x for l in self.__books)
return x in self.__books
def __iadd__(self, book): # s += book (changes THIS shelf)
if not isinstance(book, Book):
return NotImplemented
if book in self.__books:
raise ValueError(f'‘{book.title}’ is already on {self.name}')
self.__books.append(book)
return self
__getitem__ tells an index from a slice with isinstance(i, slice): with an index it returns the book (negative ones work on their own because we pass them to the list); with a slice it returns another shelf. __contains__ accepts a title or a book. And __iadd__ adds to this shelf, rejects duplicates with a ValueError and ends with return self.
Output
0 books; empty
Mathematics (3): Calculus, Understanding Networks, Calculus (2nd printing) | alias is maths: True
Book('Python for Everybody') Book('Data Structures in C') 2
‘Calculus’ in maths? True python in maths? False
Programming+Mathematics (5): Python for Everybody, Data Structures in C, Calculus, Understanding Networks, Calculus (2nd printing)
Programming+Mathematics[1:3] (2): Data Structures in C, Calculus
Sorted: [Book('Calculus'), Book('Calculus (2nd printing)'), Book('Data Structures in C'), Book('Python for Everybody'), Book('Understanding Networks')]
maths += calculus -> ValueError: ‘Calculus’ is already on Mathematics
prog[10] -> IndexError: list index out of range
maths += 'Algebra' -> TypeError: unsupported operand type(s) for +=: 'Shelf' and 'str'
Line by line:
len(maths)is 0 andnot mathsisTrue: without__bool__, Python uses__len__.- After the three
+=,mathshas 3 books andalias is mathsisTrue:__iadd__modified the same object. The “2nd printing” has a different ISBN, so it’s not a duplicate. prog[0]andprog[-1]show withBook‘srepr, becauseBookhas no__str__.'Calculus' in mathsisTrue(by title) andpython in mathsisFalse(by book, with__eq__).prog + mathscreates a new 5-book shelf andall_books[1:3]another one with books 1 and 2, as in the diagram.sortedorders by title thanks toBook.__lt__.- The three errors: the duplicate (raised by our
__iadd__),prog[10](raised by the inner list:IndexError) andmaths += 'Algebra', where__iadd__returnsNotImplemented, Python tries__add__, which also returnsNotImplemented, and the message mentions+=.
Questions you might ask
Why no __iter__ to loop over it with for? Because that’s the next topic, iterators and generators. (Fun fact: with __getitem__ and __len__, Python can already loop over it with a for through the old protocol, trying indexes 0, 1, 2… until an IndexError; here we use range(len(all_books)) so you can see it.)
Why doesn’t __add__ reuse __iadd__? Because + mustn’t touch either operand: it creates a new list. If it did self += another, prog + maths would change prog.
Why is a duplicate an error and not silently ignored? A design decision: if someone tries to shelve a book that’s already there, they’ve probably made a mistake. In __add__, on the other hand, joining two shelves with books in common is normal and repeats are skipped.
Shouldn’t Book.__lt__ have isinstance and NotImplemented? Yes, in a real class. Here it’s only used for sorted inside the shelf; adding it (and @total_ordering) is a good exercise.
Forgetting return self in __iadd__. The first maths += calculus would leave maths as None. You have it with real output in snippet 3 of the exercises.
Try it yourself
- Add
__delitem__sodel maths[0]removes the first book, and__setitem__to replace one (maths[0] = other). - Make
s1 == s2true if both shelves have the same books, even in a different order.
—
Common mistakes in this practice
- Non-commutative reflected methods copied from the direct one (
__rsub__=self - other): sign flipped. - Money as
float: cent errors when adding and comparing. Whole cents. int(x * 100)instead ofround: 0.29 € becomes 28 cents.__eq__without__hash__when the objects go into a set:unhashable type.__add__that modifiesself:a + bwould changea.+always creates a new object.__iadd__withoutreturn self: the variable becomesNone.
—
Watch it step by step in Python Tutor
Let’s watch the reflected subtraction live. On pythontutor.com, choose Python 3 and paste this reduced version of Rational:
from math import gcd
class Rational:
def __init__(self, num, den=1):
m = gcd(num, den)
self.num = num // m
self.den = den // m
def __sub__(self, other):
return Rational(self.num * other.den - other.num * self.den, self.den * other.den)
def __rsub__(self, other):
return Rational(other) - self
r = Rational(3, 4)
x = 1 - r
print(x.num, x.den)
Output:
1 4
What to watch step by step:
- In
x = 1 - r, Python can’t subtract with the 1 and entersr‘s__rsub__: see howselfpoints to the fraction 3/4 andotheris 1. - Inside,
Rational(1)is created (an__init__frame on top) and then its__sub__is called: nowselfis 1/1 andotherthe fraction 3/4. The roles have swapped. - In
__sub__it computes 1·4 − 3·1 = 1 over 1·4 = 4, and the result’s__init__simplifies (gcd 1). - Change
return Rational(other) - selftoreturn self - Rational(other)and run again: it’ll print-1 4.
—
Summary
- A “numeric” class (
Rational,Money) normalises itself in__init__(simplify, whole cents), which makes comparing and hashing simple. - Every operator starts the same way: convert the other operand to a known type or return
NotImplemented. - Commutative reflected methods (
__radd__,__rmul__) reuse the direct one; the others (__rsub__,__rtruediv__) do the operation in their order. sum()needs__radd__with 0.- An operator can return different types depending on the operand (money / number is money; money / money, a number).
- A container class behaves like a list with
__len__,__getitem__(indexes and slices),__contains__,__iadd__and__add__.
Checklist: have you practised it?
- Have you run the three programs and checked the outputs?
- Can you explain why
r2 == 0.5isFalse? - Can you compute
1 - 3/4by hand following__rsub__? - Do you understand why
till + donationcomes out in euros anddonation + Money(3.70)in dollars? - Have you tried the “Try it yourself” tasks without looking at the theory?
- Do you know what
__getitem__receives withs[1:3]?
If you answered yes to almost everything, you’re ready for the operator overloading exercises, where I no longer give you the code. See you there!
Next step: exercisesPython operator overloading exercises — complex numbers and polynomials that add, multiply and differentiate, with solutions, a quiz and a cheat sheet (and the shelf that turned into None)Did this article help you? Here is how you can support SergioLearns →




The hardware that holds up the operating system — theory with my IC2 assignment on the printer, an interrupt that stops everything and a cache that hits (and the infinite loop that didn’t freeze my laptop)