Python 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)

The night before an FP2 lab exam I was revising with an Shelf that used += to add books. First +=: fine. Next line: AttributeError: 'NoneType' object has no attribute 'books'. It took me a good while to see it: my __iadd__ added the book… and returned nothing. Python assigned that None to the variable and the shelf vanished. That slip, the __rsub__ written as if subtraction commuted and the __eq__ that keeps objects out of sets are the three most common stumbles with operator overloading.

You’ve already seen the theory with Vector2D and the practice with Rational, Money and the shelf. Now it’s your turn.

—

How to use these exercises

  1. Read the statement and the expected output. Your program must give exactly that output with that data.
  2. Try it without looking at the hints. If you’re stuck for more than 15 minutes, open the hint.
  3. Before the solutions, do the “What’s wrong here?” section: three snippets with typical bugs to predict.
  4. Compare with the commented solution: what matters is understanding why it’s built that way.
  5. Finish with the exam-style quiz and keep the cheat sheet.

Every output is the real output of the solutions.

—

Basic level

Exercise 1: complex number class

Implement a Complex class for numbers of the form a + bi (real part a, imaginary part b):

  • Private attributes real and imag, with read-only properties; imag defaults to 0.
  • __str__ with the format 3+4i or 1-2i (no spaces) and __repr__ with the format Complex(3, 4).
  • == between complex numbers and with real numbers (Complex(5) == 5 is True), and a consistent __hash__. Don’t implement < or >: complex numbers have no natural order.
  • +, - and * between complex numbers and with numbers (int or float), both ways round: c + 2 and 2 + c, c - 3 and 3 - c, 3 * c. The product is (a + bi)(c + di) = (ac − bd) + (ad + bc)i.
  • -c (negation), abs(c) (the modulus: the square root of a² + b²) and a conjugate() method that returns a − bi.

With c1 = Complex(3, 4) and c2 = Complex(1, -2), test every operation, compare two results with Python’s built-in complex numbers, and check that c1 < c2 raises TypeError. Expected output:

c1 = 3+4i  c2 = 1-2i  repr: Complex(3, 4)
c1 + c2 = 4+2i  c1 - c2 = 2+6i  c1 * c2 = 11-2i
c1 + 2 = 5+4i  2 + c1 = 5+4i  3 * c2 = 3-6i
c1 - 3 = 0+4i  3 - c1 = 0-4i
-c1 = -3-4i  abs(c1) = 5.0  conjugate: 3-4i
c1 * conjugate = 25+0i
c1 == Complex(3, 4): True  Complex(5) == 5: True
Check with complex: (11-2j) -4j
TypeError: '<' not supported between instances of 'Complex' and 'Complex'

First write a @staticmethod that turns a number into Complex(x) and returns None for anything else; that way every operator starts the same. Addition and multiplication commute, so __radd__ and __rmul__ can call the direct ones. Subtraction doesn’t: 3 - c1 is Complex(3) - c1 = 0 − 4i, not c1 - 3 = 0 + 4i. To make < raise TypeError you don’t have to do anything: if you don’t define __lt__, Python raises it by itself.

—

Intermediate level and final challenge

Exercise 2: polynomial class

Implement a one-variable Polynomial class. It stores the list of coefficients from lowest to highest degree: [1, -3, 2] is 1 − 3x + 2x², written 2x² - 3x + 1.

  • The constructor drops trailing zeros ([1, -3, 2, 0] is the same polynomial); the empty list is the zero polynomial.
  • A coefficients property that returns a copy of the list, degree() and evaluate(x).
  • A readable __str__: from highest to lowest degree, no coefficient 1 in front of x (x + 2, not 1x + 2), superscript exponents (x², x³) and the sign separated by spaces. __repr__: Polynomial([1, -3, 2]).
  • + and - between polynomials and with numbers, both ways round (p + 5, 3 - p). To add, pad the shorter one with zeros.
  • * between polynomials (each term of the first times all of the second) and with numbers (2 * p).
  • sum() of a list of polynomials.

With p1 = Polynomial([1, -3, 2]) and p2 = Polynomial([2, 1]), the expected output is:

p1 = 2x² - 3x + 1  p2 = x + 2  repr: Polynomial([1, -3, 2])  degree: 2
p1(0), p1(1), p1(3): 1 0 10
p1 + p2 = 2x² - 2x + 3   p1 - p2 = 2x² - 4x - 1
p1 * p2 = 2x³ + x² - 5x + 2
p1 + 5 = 2x² - 3x + 6   2 * p1 = 4x² - 6x + 2
p1 - 3 = 2x² - 3x - 2   3 - p1 = -2x² + 3x + 2
p1 - p1 = 0  degree: 0
sum: x³ + 2x² - 2x + 3

To evaluate, Horner’s scheme is short and fast: go through the coefficients from highest to lowest degree doing r = r * x + a. To subtract, don’t write another loop: p - q is p + (-q), so __neg__ is all you need. In the product, the degree-i term of the first times the degree-j term of the second goes to degree i + j of the result. And check p1 - p1: it must print 0 with degree 0.

Exercise 3 (challenge): polynomials you can compare, call and differentiate

Without touching Polynomial, create a subclass PolynomialPlus(Polynomial) (inheritance, as in the previous topic) that adds:

  • == between polynomials and with numbers, ignoring trailing zeros, and a consistent __hash__.
  • __call__, to use the polynomial like a function: p(3) is the same as p.evaluate(3).
  • derivative(): the derivative of a·xⁿ is n·a·xⁿ⁻¹.
  • ** with a non-negative integer exponent (q ** 2, q ** 0), reusing multiplication.

With p = PolynomialPlus([1, -3, 2]) and q = PolynomialPlus([2, 1]), test the integer roots of p between −5 and 5, a set with p twice and q, and check that (p · q)(2) = p(2) · q(2). Expected output:

p = 2x² - 3x + 1  p(0), p(1), p(3): 1 0 10
p' = 4x - 3   p'' = 4   q' = 1
q ** 2 = x² + 4x + 4   q ** 0 = 1
p == [1, -3, 2, 0]: True   p == 1: False   -p = -2x² + 3x - 1
Roots of p: [1]  p(0.5) = 0.0
Set: 2
(p * q)(2) = 12 = p(2) * q(2) = 12

When you define __eq__ in the subclass, the inherited __hash__ disappears: write it with hash(tuple(...)). The derivative fits in one line with enumerate: multiply each coefficient by its degree and drop the first one (degree 0). And watch an inheritance detail: the parent’s operators return Polynomial, not PolynomialPlus, so the result of ** has to be “converted” at the end.

—

What’s wrong here?

Three snippets with one bug each. Before reading the explanation, predict what happens.

Snippet 1: the set of borrowed books

class Book:
    def __init__(self, title, isbn):
        self.title = title
        self.isbn = isbn

    def __eq__(self, other):
        return isinstance(other, Book) and self.isbn == other.isbn


prestados = {Book('Calculus', '978-6075265490')}

What happens when you run it?

Traceback (most recent call last):
  File "hash.py", line 10, in <module>
    prestados = {Book('Calculus', '978-6075265490')}
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: unhashable type: 'Book'

By defining __eq__, Python removes the __hash__ that Book inherited from object, and an object without a hash can’t go into a set. The fix is a __hash__ that uses the same data as __eq__ (the ISBN), so two equal books have the same hash and the set counts them as one:

class Book:
    def __init__(self, title, isbn):
        self.title = title
        self.isbn = isbn

    def __eq__(self, other):
        return isinstance(other, Book) and self.isbn == other.isbn

    def __hash__(self):                        # same data as __eq__
        return hash(self.isbn)


prestados = {Book('Calculus', '978-6075265490'), Book('Calculus', '978-6075265490')}
print(len(prestados))

Output:

1

Snippet 2: the addition that doesn’t warn you

class Vector2D:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __add__(self, other):
        if isinstance(other, Vector2D):
            return Vector2D(self.x + other.x, self.y + other.y)
        # for anything else… returns nothing


r = Vector2D(3, 4) + 'hello'
print(r)

What does it print?

None

None, with no error at all. When other isn’t a Vector2D, the method reaches the end without return and returns None, and Python accepts that as the “sum”. With return NotImplemented, Python asks the string (which can’t add vectors either) and raises a clear error:

class Vector2D:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __add__(self, other):
        if isinstance(other, Vector2D):
            return Vector2D(self.x + other.x, self.y + other.y)
        return NotImplemented


r = Vector2D(3, 4) + 'hello'

Output:

Traceback (most recent call last):
  File "none.py", line 12, in <module>
    r = Vector2D(3, 4) + 'hello'
        ~~~~~~~~~~~~~~~^~~~~~~~~
TypeError: unsupported operand type(s) for +: 'Vector2D' and 'str'

An error that warns you is better than a None that sneaks three functions further down.

Snippet 3: the shelf that disappears

class Shelf:
    def __init__(self):
        self.books = []

    def __iadd__(self, title):
        self.books.append(title)          # adds… but returns nothing


e = Shelf()
e += 'Calculus'
print(e)
print(len(e.books))

What happens?

None
Traceback (most recent call last):
  File "iadd.py", line 12, in <module>
    print(len(e.books))
              ^^^^^^^
AttributeError: 'NoneType' object has no attribute 'books'

e += 'Calculus' is e = e.__iadd__('Calculus'): Python assigns to e whatever the method returns. Since it returns nothing, e becomes None. The book was added, but to an object nobody points to any more. With return self:

class Shelf:
    def __init__(self):
        self.books = []

    def __iadd__(self, title):
        self.books.append(title)
        return self


e = Shelf()
e += 'Calculus'
e += 'Understanding Networks'
print(e.books)

Output:

['Calculus', 'Understanding Networks']

—

Commented solutions

Solution to exercise 1

class Complex:
    def __init__(self, real, imag=0):
        self.__real = real
        self.__imag = imag

    @property
    def real(self):
        return self.__real

    @property
    def imag(self):
        return self.__imag

    @staticmethod
    def _as_complex(x):                         # a real number is a complex with imag 0
        if isinstance(x, Complex):
            return x
        if isinstance(x, (int, float)):
            return Complex(x)
        return None

    def __str__(self):
        sign = '+' if self.__imag >= 0 else '-'
        return f'{self.__real}{sign}{abs(self.__imag)}i'

    def __repr__(self):
        return f'Complex({self.__real}, {self.__imag})'

    def __eq__(self, other):                    # equality yes; ordering (<, >) makes no sense
        o = Complex._as_complex(other)
        if o is None:
            return NotImplemented
        return self.__real == o.real and self.__imag == o.imag

    def __hash__(self):
        return hash((self.__real, self.__imag))

    def __add__(self, other):
        o = Complex._as_complex(other)
        if o is None:
            return NotImplemented
        return Complex(self.__real + o.real, self.__imag + o.imag)

    def __sub__(self, other):
        o = Complex._as_complex(other)
        if o is None:
            return NotImplemented
        return Complex(self.__real - o.real, self.__imag - o.imag)

    def __mul__(self, other):                   # (a+bi)(c+di) = (ac-bd) + (ad+bc)i
        o = Complex._as_complex(other)
        if o is None:
            return NotImplemented
        a, b, c, d = self.__real, self.__imag, o.real, o.imag
        return Complex(a * c - b * d, a * d + b * c)

    def __radd__(self, other):
        return self + other

    def __rmul__(self, other):
        return self * other

    def __rsub__(self, other):                  # other - self
        o = Complex._as_complex(other)
        return NotImplemented if o is None else o - self

    def __neg__(self):
        return Complex(-self.__real, -self.__imag)

    def __abs__(self):                          # modulus: square root of a² + b²
        return (self.__real ** 2 + self.__imag ** 2) ** 0.5

    def conjugate(self):
        return Complex(self.__real, -self.__imag)


c1, c2 = Complex(3, 4), Complex(1, -2)
print('c1 =', c1, ' c2 =', c2, ' repr:', repr(c1))
print('c1 + c2 =', c1 + c2, ' c1 - c2 =', c1 - c2, ' c1 * c2 =', c1 * c2)
print('c1 + 2 =', c1 + 2, ' 2 + c1 =', 2 + c1, ' 3 * c2 =', 3 * c2)
print('c1 - 3 =', c1 - 3, ' 3 - c1 =', 3 - c1)
print('-c1 =', -c1, ' abs(c1) =', abs(c1), ' conjugate:', c1.conjugate())
print('c1 * conjugate =', c1 * c1.conjugate())
print('c1 == Complex(3, 4):', c1 == Complex(3, 4), ' Complex(5) == 5:', Complex(5) == 5)
print('Check with complex:', complex(3, 4) * complex(1, -2), 3 - complex(3, 4))
try:
    print(c1 < c2)
except TypeError as err:
    print('TypeError:', err)

The decisions that matter:

  • _as_complex holds the type rule. A real number is a complex number with imaginary part 0; anything else gives None and the operator returns NotImplemented.
  • The product, with the formula from class: (3 + 4i)(1 − 2i) = (3·1 − 4·(−2)) + (3·(−2) + 4·1)i = 11 − 2i. The “Check with complex” line gives (11-2j): the same calculation done by Python (which uses j instead of i).
  • __rsub__ in its order: 3 - c1 = Complex(3) - c1 = (3 − 3) + (0 − 4)i = 0 − 4i. Python also gives -4j.
  • The conjugate multiplies into a real number: (3 + 4i)(3 − 4i) = 9 + 16 = 25, and the imaginary part is 0 (25+0i). And |3 + 4i| = 5.0.
  • No __lt__: Python raises the TypeError by itself, with a message naming both types.
  • __str__ with abs(imag) so as not to write 3+-4i: the sign is added separately.

Solution to exercise 2

class Polynomial:
    SUP = str.maketrans('0123456789', '⁰¹²³⁴⁵⁶⁷⁸⁹')

    def __init__(self, coefficients):
        c = list(coefficients) or [0]              # [1, -3, 2] = 1 - 3x + 2x²
        while len(c) > 1 and c[-1] == 0:            # drop trailing zeros
            c.pop()
        self._c = c

    @property
    def coefficients(self):
        return list(self._c)                        # copy: nobody touches the inner list

    def degree(self):
        return len(self._c) - 1

    def evaluate(self, x):                          # Horner: from highest to lowest degree
        r = 0
        for a in reversed(self._c):
            r = r * x + a
        return r

    @staticmethod
    def _as_poly(x):
        if isinstance(x, Polynomial):
            return x
        if isinstance(x, (int, float)):
            return Polynomial([x])
        return None

    def __add__(self, other):
        o = Polynomial._as_poly(other)
        if o is None:
            return NotImplemented
        n = max(len(self._c), len(o._c))
        a = self._c + [0] * (n - len(self._c))      # pad the shorter one with zeros
        b = o._c + [0] * (n - len(o._c))
        return Polynomial([x + y for x, y in zip(a, b)])

    def __radd__(self, other):
        return self + other

    def __neg__(self):
        return Polynomial([-a for a in self._c])

    def __sub__(self, other):
        o = Polynomial._as_poly(other)
        return NotImplemented if o is None else self + (-o)

    def __rsub__(self, other):                      # other - self
        o = Polynomial._as_poly(other)
        return NotImplemented if o is None else o + (-self)

    def __mul__(self, other):
        o = Polynomial._as_poly(other)
        if o is None:
            return NotImplemented
        r = [0] * (len(self._c) + len(o._c) - 1)
        for i, a in enumerate(self._c):             # each term times all of the other's
            for j, b in enumerate(o._c):
                r[i + j] += a * b
        return Polynomial(r)

    def __rmul__(self, other):
        return self * other

    def __str__(self):
        parts = []
        for g in range(len(self._c) - 1, -1, -1):   # from the highest degree to the lowest
            a = self._c[g]
            if a == 0 and len(self._c) > 1:
                continue
            sign = '-' if a < 0 else '+'
            n = abs(a)
            coef = '' if n == 1 and g > 0 else str(n)
            var = '' if g == 0 else 'x' if g == 1 else 'x' + str(g).translate(Polynomial.SUP)
            parts.append((sign, coef + var))
        s = ('-' if parts[0][0] == '-' else '') + parts[0][1]
        return s + ''.join(f' {sg} {t}' for sg, t in parts[1:])

    def __repr__(self):
        return f'Polynomial({self._c})'


if __name__ == '__main__':
    p1, p2 = Polynomial([1, -3, 2]), Polynomial([2, 1])
    print('p1 =', p1, ' p2 =', p2, ' repr:', repr(p1), ' degree:', p1.degree())
    print('p1(0), p1(1), p1(3):', p1.evaluate(0), p1.evaluate(1), p1.evaluate(3))
    print('p1 + p2 =', p1 + p2, '  p1 - p2 =', p1 - p2)
    print('p1 * p2 =', p1 * p2)
    print('p1 + 5 =', p1 + 5, '  2 * p1 =', 2 * p1)
    print('p1 - 3 =', p1 - 3, '  3 - p1 =', 3 - p1)
    print('p1 - p1 =', p1 - p1, ' degree:', (p1 - p1).degree())
    print('sum:', sum([p1, p2, Polynomial([0, 0, 0, 1])]))

Multiplication is the part with the most work:

def __mul__(self, other):
    o = Polynomial._as_poly(other)
    if o is None:
        return NotImplemented
    r = [0] * (len(self._c) + len(o._c) - 1)
    for i, a in enumerate(self._c):             # each term times all of the other's
        for j, b in enumerate(o._c):
            r[i + j] += a * b
    return Polynomial(r)

Each coefficient a of the first (degree i) times each b of the second (degree j) accumulates in r[i + j]. The diagram does it with p1 · p2:

Multiplying two polynomials term by termA 3-by-2 table: each coefficient of 1 − 3x + 2x² times each one of 2 + x; the product of a[i] and b[j] is added to r[i + j]. Result [2, −5, 1, 2], that is 2x³ + x² − 5x + 2.(2x² − 3x + 1) · (x + 2) → r[i + j] += a[i] · b[j]a[i] \ b[j]b[0] = 2b[1] = 1a[0] = 1+2 → r[0]+1 → r[1]a[1] = -3-6 → r[1]-3 → r[2]a[2] = 2+4 → r[2]+2 → r[3]r[0] = 2r[1] = -5r[2] = 1r[3] = 2r = [2, -5, 1, 2] → 2x³ + x² − 5x + 2
Cells of the same colour go to the same degree: r[1] = 1 − 6 = −5 and r[2] = −3 + 4 = 1. Data from exercise 2.

Let’s check the evaluation of p1 = 2x² − 3x + 1 at x = 3 with Horner (coefficients from highest to lowest: 2, −3, 1):

StepCoefficientr = r · 3 + a
120 · 3 + 2 = 2
2−32 · 3 − 3 = 3
313 · 3 + 1 = 10

And by hand: 2 · 9 − 9 + 1 = 10.

Why it’s built this way:

  • Trailing zeros are removed in the constructor, once: that’s how p1 - p1 ends up as [0], its degree is 0 and __str__ prints 0.
  • coefficients returns a copy: if it returned the inner list, p.coefficients.append(5) would change the polynomial without going through the constructor.
  • __sub__ and __rsub__ lean on __add__ and __neg__: 3 - p1 is Polynomial([3]) + (-p1) = [3, 0, 0] + [−1, 3, −2] = [2, 3, −2], that is −2x² + 3x + 2. A single addition loop for everything.
  • __radd__ calls __add__ and that’s enough for sum(): 0 + p1 becomes Polynomial([0]) + p1.
  • The if __name__ == '__main__': makes the tests run only when this file is executed, not when it’s imported from exercise 3.
  • Superscripts come from str.translate with a '0123456789' → '⁰¹²³⁴⁵⁶⁷⁸⁹' table.

Solution to exercise 3

from polynomial import Polynomial


class PolynomialPlus(Polynomial):                    # inheritance: reuses everything from exercise 2
    def __eq__(self, other):
        o = Polynomial._as_poly(other)
        if o is None:
            return NotImplemented
        return self.coefficients == o.coefficients  # already without trailing zeros

    def __hash__(self):
        return hash(tuple(self._c))

    def __call__(self, x):                          # p(3) like a function
        return self.evaluate(x)

    def derivative(self):                           # a·xⁿ -> n·a·xⁿ⁻¹
        return PolynomialPlus([n * a for n, a in enumerate(self._c)][1:])

    def __pow__(self, n):                           # p ** n with integer n >= 0
        if not isinstance(n, int) or n < 0:
            return NotImplemented
        r = PolynomialPlus([1])
        for _ in range(n):
            r = r * self
        return PolynomialPlus(r.coefficients)       # __mul__ returns Polynomial


p = PolynomialPlus([1, -3, 2])
q = PolynomialPlus([2, 1])
print('p =', p, ' p(0), p(1), p(3):', p(0), p(1), p(3))
print("p' =", p.derivative(), "  p'' =", p.derivative().derivative(), "  q' =", q.derivative())
print('q ** 2 =', q ** 2, '  q ** 0 =', q ** 0)
print('p == [1, -3, 2, 0]:', p == PolynomialPlus([1, -3, 2, 0]), '  p == 1:', p == 1, '  -p =', -p)
print('Roots of p:', [x for x in range(-5, 6) if p(x) == 0], ' p(0.5) =', p(0.5))
print('Set:', len({p, PolynomialPlus([1, -3, 2]), q}))
print('(p * q)(2) =', (p * q).evaluate(2), '= p(2) * q(2) =', p(2) * q(2))

With from polynomial import Polynomial we reuse the whole class from exercise 2 (both files go in the same folder) and only write what’s new. The maths:

  • p(0) = 1, p(1) = 2 − 3 + 1 = 0 and p(3) = 10 (the trace above). Since p(1) = 0, 1 is a root; the other root is 0.5 (p(0.5) = 0.0), which the search misses because it only tries integers.
  • p′ = [1·(−3), 2·2] = [−3, 4], that is 4x − 3, and p″ = 4.
  • q² = (x + 2)(x + 2) = x² + 4x + 4, and q⁰ = 1: the __pow__ loop doesn’t run at all.
  • The set has 2 elements: p and PolynomialPlus([1, -3, 2]) are equal and have the same hash.
  • (p · q)(2) = 12 and p(2) · q(2) = 3 · 4 = 12: the multiplication is right.

Why it’s built this way:

  • Inheritance instead of copying: PolynomialPlus gets addition, subtraction, product, str and evaluate without writing them. It only adds what’s new.
  • __hash__ is required when redefining __eq__ in the subclass, for the same reason as in snippet 1.
  • __call__ makes the object “callable” like a function. It’s not an arithmetic operator, but it’s overloading: brackets are an operator too.
  • The type detail: -p and r * self return Polynomial (that’s what the parent writes). That’s why __pow__ creates a PolynomialPlus at the end. The real fix would be for the parent to use type(self)(…) instead of Polynomial(…): the same idea as type(self).__name__ in the inheritance topic.

—

Exam-style quiz

Now test yourself as in the lab. FP2 quizzes look like this: about 10 questions in 10-15 minutes, single choice, multiple choice (wrong answers subtract), true or false, fill in the blanks, matching and “what does this code print?”. Here are 13 original questions about operator overloading. Answer without running anything, press Check and read the explanation of each one. These questions are also the question bank of the FP2 theory mock exam, which will mix them with those of the other topics.

Question 1Match

Match each operator with the magic method that implements it:

a + b
a - b
a * b
a / b
a // b
a == b

Definitions:

  • __sub__
  • __eq__
  • __truediv__
  • __div__
  • __mul__
  • __add__
  • __floordiv__
Solution
  • a + b → __add__
  • a - b → __sub__
  • a * b → __mul__
  • a / b → __truediv__
  • a // b → __floordiv__
  • a == b → __eq__

/ is «true» division (__truediv__) and // is floor division (__floordiv__). __div__ was Python 2 and isn’t used in Python 3.

Question 2Match

Match each expression with the method Python calls:

a < b
a <= b
a != b
len(a)
a[i]
-a

Definitions:

  • __getitem__
  • __cmp__
  • __le__
  • __lt__
  • __minus__
  • __ne__
  • __len__
  • __neg__
Solution
  • a < b → __lt__
  • a <= b → __le__
  • a != b → __ne__
  • len(a) → __len__
  • a[i] → __getitem__
  • -a → __neg__

lt = less than, le = less or equal, ne = not equal. The minus in front of an object (the unary operator) is __neg__; the binary a - b is __sub__. __cmp__ disappeared in Python 3.

Question 3Short answer

You define __eq__ and __lt__ in your class. Which decorator from the functools module adds <=, > and >= for you?

Solution

Answer: total_ordering

@total_ordering needs __eq__ and one of __lt__, __le__, __gt__ or __ge__, and works out the rest. It goes above the class.

Question 4Short answer

What does this program print?

class Vector:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)

    def __mul__(self, k):
        return Vector(self.x * k, self.y * k)

    def __str__(self):
        return f"<{self.x}, {self.y}>"

v = Vector(1, 2) + Vector(3, 4) * 2
print(v)
Solution

Answer: <7, 10>

Overloaded operators keep the usual precedence: first Vector(3, 4) * 2 = <6, 8>, then <1, 2> + <6, 8> = <7, 10>.

Question 5Single choice

The Vector class above has __mul__ but no __rmul__. What happens with 2 * Vector(1, 2)?

  1. TypeError: int can’t multiply itself by a Vector and there’s no __rmul__
  2. It gives <2, 4>, just like Vector(1, 2) * 2
  3. It gives <1, 2, 1, 2>
  4. The result is NotImplemented
Solution

Answer: TypeError: int can’t multiply itself by a Vector and there’s no __rmul__

To make it work both ways you add the reflected method: def __rmul__(self, k): return self * k.

Question 6Short answer

money + 3 works, but 3 + money gives TypeError. Which method do you need to add to money‘s class?

Solution

Answer: __radd__

With 3 + money, int.__add__ returns NotImplemented and Python tries money.__radd__(3). Every binary operator has its reflected version: __rsub__, __rmul__, __rtruediv__…

Question 7Short answer

What does this program print?

class Coin:
    def __init__(self, c):
        self.c = c

a = Coin(50)
b = Coin(50)
print(a == b, a is b)
Solution

Answer: False False

Without __eq__, == compares identity (like is): they’re two different objects even though they’re worth the same.

Question 8Short answer

Now with __eq__. What does it print?

class Coin:
    def __init__(self, c):
        self.c = c

    def __eq__(self, other):
        return self.c == other.c

print(Coin(50) == Coin(50), Coin(50) != Coin(20))
Solution

Answer: True True

== uses your __eq__ (50 == 50). And although you didn’t write __ne__, in Python 3 != returns the opposite of __eq__: since 50 == 20 is False, != gives True.

Question 9True or false

True or false?

  1. __add__ should return a new object and leave self unchanged.
  2. a += b uses __iadd__ if it exists and, if not, __add__.
  3. If an operator method returns NotImplemented, Python tries the other operand’s reflected method.
  4. Defining __eq__ makes < work too.
  5. With __lt__ defined, sorted() can already sort a list of those objects.
Solution
  • T — __add__ should return a new object and leave self unchanged.
  • T — a += b uses __iadd__ if it exists and, if not, __add__.
  • T — If an operator method returns NotImplemented, Python tries the other operand’s reflected method.
  • F — Defining __eq__ makes < work too.
  • T — With __lt__ defined, sorted() can already sort a list of those objects.

sorted() only needs <. On the other hand, < isn’t deduced from ==: without __lt__ (or @total_ordering), comparing two objects with < gives TypeError.

Question 10Short answer

What does this program print?

class Playlist:
    def __init__(self, *songs):
        self.songs = list(songs)

    def __len__(self):
        return len(self.songs)

    def __getitem__(self, i):
        return self.songs[i]

p = Playlist("a", "b", "c")
print(len(p), p[-1], "b" in p)
Solution

Answer: 3 c True

len() uses __len__ (3). p[-1] calls __getitem__(-1), which delegates to the list ("c"). And in works even without __contains__: Python walks the object with __getitem__ (0, 1, 2…) until it finds it.

Question 11Single choice

The Student class only defines def __lt__(self, other): return self.mark < other.mark. What does sorted(students) do?

  1. It sorts them from lowest to highest mark
  2. It sorts them by name
  3. It gives TypeError because __gt__ is missing
  4. It leaves them as they are
Solution

Answer: It sorts them from lowest to highest mark

To sort from highest to lowest: sorted(students, reverse=True).

Question 12Multiple · wrong ticks subtract

Which of these are good practices when overloading operators?

  1. Returning NotImplemented if the other operand is of a type you can’t handle
  2. Making the operator mean what anyone would expect (+ adds or joins)
  3. Defining __repr__ so you can see objects while debugging
  4. Modifying self inside __add__ and returning it
  5. Comparing with is inside __eq__
Solution

Answer: Returning NotImplemented if the other operand is of a type you can’t handle · Making the operator mean what anyone would expect (+ adds or joins) · Defining __repr__ so you can see objects while debugging

Overloading is there so your class can be used like Python’s built-in types. If + does something surprising, the code becomes hard to read.

Question 13Fill in the gaps

Complete how Python translates operators:

Python turns a + b into ____. If that returns NotImplemented, it tries ____. And a += b tries ____ first.

Words:

  • b.__radd__(a)
  • b.__add__(a)
  • a.__iadd__(b)
  • a.__add__(b)
  • a.__radd__(b)
Solution

Python turns a + b into a.__add__(b). If that returns NotImplemented, it tries b.__radd__(a). And a += b tries a.__iadd__(b) first.

First the left operand with the normal method; if it can’t, the right one with the reflected method (r). The augmented ones (+=, -=…) have their i (in-place) version.

—

Watch it step by step in Python Tutor

Let’s watch snippet 3 live: the __iadd__ without return. On pythontutor.com, choose Python 3 and paste:

class Shelf:
    def __init__(self):
        self.books = []

    def __iadd__(self, title):
        self.books.append(title)


class ShelfFixed(Shelf):
    def __iadd__(self, title):
        self.books.append(title)
        return self


a = Shelf()
a += 'Calculus'
b = ShelfFixed()
b += 'Calculus'
print(a, b.books)

Output:

None ['Calculus']

What to watch step by step:

  1. On a += 'Calculus' it enters __iadd__ with self pointing to the shelf a. Watch the object’s books list get 'Calculus'.
  2. On the way out, the frame ends with Return value None… and the global variable a becomes None. The shelf with its book is still drawn, but nobody points to it any more.
  3. The same happens with b, but the frame returns the object itself and b keeps pointing to the shelf.
  4. In the print, a is None and b.books is ['Calculus'].

—

Cheat sheet: Python operator overloading

The essentials in 30 seconds: an operator is a method call (a + b is a.__add__(b)); if the type doesn’t fit, return NotImplemented (Python tries b.__radd__(a) and, if that fails too, raises TypeError); __eq__ removes the inherited __hash__; @total_ordering fills in the ordering from __eq__ and __lt__; in reflected methods self is on the right, and subtraction and division don’t commute; __iadd__ modifies and returns self.

I want…MethodExample and result
Readable print__str__print(c1) → 3+4i
See the object in lists__repr__[c1] → [Complex(3, 4)]
== and !=__eq__Complex(5) == 5 → True
Use it in a set__hash__hash((self.real, self.imag))
<, <=, >, >=__lt__ + @total_orderingsorted(rationals)
a + b__add__c1 + c2 → 4+2i
2 + a, sum()__radd__2 + c1 → 5+4i
3 - a__rsub__3 - c1 → 0-4i (not 0+4i)
2 * a__rmul__3 * c2 → 3-6i
-a, abs(a)__neg__, __abs__abs(c1) → 5.0
a += b modifying__iadd__return self at the end
len(a), a[i], x in a__len__, __getitem__, __contains__The practice’s shelf
p(3)__call__Polynomial as a function
p ** 2__pow__q ** 2 → x² + 4x + 4

Exercise templates:

PatternSkeleton
Type rule in one placeo = Cls._as_x(other) and if o is None: return NotImplemented
Commutative reflected methoddef __radd__(self, other): return self + other
Non-commutative reflected methoddef __rsub__(self, other): return Cls(other) - self
Subtract by addingdef __sub__(self, other): return self + (-other)
Consistent hashdef __hash__(self): return hash(tuple(self._c))
SymptomLikely cause
v1 == v2 is False with the same data__eq__ is missing (it compares identity)
TypeError: unhashable typeThere’s __eq__ without __hash__
AttributeError when comparing with a number__eq__ without isinstance
2 * v or sum(list) raise TypeError__rmul__ or __radd__ are missing
10 - x comes out with the sign flipped__rsub__ written as self - other
An operation returns None with no errorA path of the method without return (or without return NotImplemented)
'NoneType' object has no attribute … after +=__iadd__ without return self
print([obj]) shows <… object at 0x…>__repr__ is missing

Before you hand it in, ask yourself:

  1. Does each operator return NotImplemented (not None, False or a raise) when the type doesn’t fit?
  2. Have I tested the reflected methods with a non-symmetric case (3 - c, 1 / r)?
  3. If I defined __eq__, do I need __hash__? Does it use the same data?
  4. Do arithmetic operators return new objects without touching the originals, and does __iadd__ return self?

The next topic is Python iterators and generators: __iter__ and __next__, the magic methods that make for work.

Keep goingAll Python articles

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 *