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:

  1. Rational: fractions that simplify themselves, with full arithmetic, comparisons, hash and the reflected operators that don’t commute (the classic FP2 example).
  2. Money: amounts in whole cents, with currency conversion, sum(), instalments and comparisons across currencies.
  3. Shelf: a shelf of the faculty library that you use like a list (len, [i], slices, in, += and +).

—

Before you start

Make sure you’re clear on:

  • That a + b is a.__add__(b) and that, if it returns NotImplemented, Python tries b.__radd__(a).
  • The difference between __str__ and __repr__.
  • That @total_ordering needs __eq__ and one of the ordering methods.
  • That += without __iadd__ creates a new object, and with __iadd__ it modifies the same one (returned with return 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.

The reflected subtraction: 1 – r1Python tries int.__sub__(1, r1), which returns NotImplemented, and calls r1.__rsub__(1). Done right it computes Rational(1) – r1 = 1/4; done wrong (self – other) it gives -1/4.1 – r1 (r1 = 3/4)int.__sub__(1, r1)NotImplementedr1.__rsub__(1)Right: other – selfreturn Rational(1) – self(1·4 − 3·1) / (1·4)= 1/4Wrong: self – otherreturn self – other(3·1 − 1·4) / (4·1)= -1/4
In __rsub__, self is the RIGHT-hand side of the subtraction. Copying __sub__ flips the sign.

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:

OperationCalculationSimplified
r1 + r2(3·2 + 1·4) / (4·2) = 10/85/4
r1 − r2(3·2 − 1·4) / 8 = 2/81/4
r1 / r2(3·2) / (4·1) = 6/43/2
1 − r1(1·4 − 3·1) / 41/4
r1 − 1(3·1 − 1·4) / 4−1/4
3 / r1(3·4) / (1·3) = 12/34 (printed without “/1”)

And sum(recipe), step by step:

StepOperationWho does itResult
10 + 1/2int doesn’t know → Rational.__radd__1/2
21/2 + 1/3Rational.__add__5/6
35/6 + 1/6Rational.__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 that r1 ** 2 gives 9/16 (with an integer exponent; if it’s negative, invert the fraction).
  • __float__ already exists: try round(float(r1), 2) and then write __round__ so round(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:

OperationIn centsResult
sum(fines)0 + 120 + 200 + 50 + 0370 → 3.70 €
25 $ to euros2500 / 1.08 = 2314.8 → 231523.15 €
till + donation370 + 23152685 → 26.85 €
donation + 3.70 €370 × 1.08 = 399.6 → 400; 2500 + 4002900 → 29.00 $
till / 32685 / 3 = 8958.95 €
fines / till370 / 26850.138
17 £ to euros1700 / 0.85 = 2000equal 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 Money accept + with a number only if it’s 0 (besides __radd__), and think about whether till + 0 makes 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.

The shelf as a containerThe five books of all_books with their positive and negative indexes; the slice [1:3] (Data Structures and Calculus) highlighted. Below, which method each operation calls.all_books = prog + maths → len(all_books) == 5Python forEverybody[0][-5]Data Structuresin C[1][-4]Calculus[2][-3]UnderstandingNetworks[3][-2]Calculus (2ndprinting)[4][-1]all_books[1:3] → new Shelf with 2 bookslen(s) → __len__ s[i] / s[a:b] → __getitem__ 'Calculus' in s → __contains__s += book → __iadd__ (same shelf) s1 + s2 → __add__ (new one) if s: → __len__
With five magic methods, the shelf is used like a list: indexes, slices, in, len, += and +.

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 and not maths is True: without __bool__, Python uses __len__.
  • After the three +=, maths has 3 books and alias is maths is True: __iadd__ modified the same object. The “2nd printing” has a different ISBN, so it’s not a duplicate.
  • prog[0] and prog[-1] show with Book‘s repr, because Book has no __str__.
  • 'Calculus' in maths is True (by title) and python in maths is False (by book, with __eq__).
  • prog + maths creates a new 5-book shelf and all_books[1:3] another one with books 1 and 2, as in the diagram.
  • sorted orders by title thanks to Book.__lt__.
  • The three errors: the duplicate (raised by our __iadd__), prog[10] (raised by the inner list: IndexError) and maths += 'Algebra', where __iadd__ returns NotImplemented, Python tries __add__, which also returns NotImplemented, 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__ so del maths[0] removes the first book, and __setitem__ to replace one (maths[0] = other).
  • Make s1 == s2 true 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 of round: 0.29 € becomes 28 cents.
  • __eq__ without __hash__ when the objects go into a set: unhashable type.
  • __add__ that modifies self: a + b would change a. + always creates a new object.
  • __iadd__ without return self: the variable becomes None.

—

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:

  1. In x = 1 - r, Python can’t subtract with the 1 and enters r‘s __rsub__: see how self points to the fraction 3/4 and other is 1.
  2. Inside, Rational(1) is created (an __init__ frame on top) and then its __sub__ is called: now self is 1/1 and other the fraction 3/4. The roles have swapped.
  3. In __sub__ it computes 1·4 − 3·1 = 1 over 1·4 = 4, and the result’s __init__ simplifies (gcd 1).
  4. Change return Rational(other) - self to return 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.5 is False?
  • Can you compute 1 - 3/4 by hand following __rsub__?
  • Do you understand why till + donation comes out in euros and donation + Money(3.70) in dollars?
  • Have you tried the “Try it yourself” tasks without looking at the theory?
  • Do you know what __getitem__ receives with s[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 →

Sigue aprendiendo

Leave a Reply

Your email address will not be published. Required fields are marked *