Python operator overloading exercises Complex Polynomial magic methods solutions

Python operator overloading exercises — master magic methods

Python operator overloading exercises are where magic methods become instinct. You’ve seen the theory and built two complete classes. Now it’s time to design and implement your own — a complex number class and a polynomial class — both mathematical structures that become natural to use once operators are defined.

As always: try to solve it yourself, use the hint if stuck for more than 10 minutes, and compare with the commented solution. Use pythontutor.com to step through and watch operator dispatch in action.


Python operator overloading exercises — Basic to Intermediate Level

Exercise 1 — Complex number class

Implement a Complex class that represents a complex number a + bi. Python already has a built-in complex type, but implementing your own is the best way to understand every aspect of operator overloading.

Requirements:

Attributes:
  real       — real part (float)
  imag       — imaginary part (float)

Arithmetic operators:
  __add__    — complex + complex, complex + int/float
  __radd__   — int/float + complex
  __sub__    — complex - complex, complex - int/float
  __rsub__   — int/float - complex
  __mul__    — complex × complex, complex × scalar
  __rmul__   — scalar × complex
  __truediv__ — complex ÷ complex, complex ÷ scalar
  __neg__    — negate both parts
  __abs__    — magnitude: √(a² + b²)
  __pow__    — integer power only (use De Moivre or repeated multiplication)

Properties:
  conjugate  → a - bi
  magnitude  → same as abs()
  phase      → angle in radians (use math.atan2)

Comparison:
  __eq__     — equal if same real and imag
  NO __lt__  — complex numbers aren't ordered (raise TypeError)

Conversion:
  __bool__   — True if magnitude > 0
  __float__  — raise TypeError if imag != 0, else return real
  __complex__ — convert to Python's built-in complex

String:
  __str__    → "3+4i", "3-4i", "3", "4i", "-2+0i"
  __repr__   → "Complex(3, 4)"

Expected output:

=== COMPLEX NUMBERS ===

a = 3+4i
b = 1-2i

a + b = 4+2i
a - b = 2+6i
a * b = 11-2i
a / b = -1+2i

|a| = 5.0
conjugate(a) = 3-4i
phase(a) = 0.9272952180016122 rad

a == Complex(3, 4): True
a == b: False

a ** 2 = -7+24i
a ** 0 = 1

Sorted by magnitude: [1-2i, 3+4i, 3+5i, 5+12i]

💡 Hints:

  • __mul__: (a+bi)(c+di) = (ac-bd) + (ad+bc)i
  • __truediv__: (a+bi)/(c+di) = ((ac+bd) + (bc-ad)i) / (c²+d²)
  • __pow__ for integers: repeated multiplication or convert to polar and back
  • __str__: handle cases where real=0, imag=0, imag=1, imag=-1, negative imag
  • magnitude property: math.sqrt(self.real**2 + self.imag**2)
  • For sorting by magnitude use sorted(numbers, key=abs) — which calls __abs__

Python operator overloading exercises — Final Challenge

Exercise 2 — Polynomial class

Implement a Polynomial class that represents a polynomial like 3x² + 2x – 5. This exercise uses containers, arithmetic overloading and __call__ — the operator that makes an object callable like a function.

Requirements:

Constructor:
  Polynomial(3, 2, -5) → 3x² + 2x - 5
  (coefficients from highest degree to lowest)

Properties:
  degree     → highest power (len - 1)
  coefficients → tuple, highest to lowest

Operators:
  __add__    — add two polynomials
  __sub__    — subtract two polynomials
  __mul__    — polynomial × polynomial or polynomial × scalar
  __rmul__   — scalar × polynomial
  __neg__    — negate all coefficients
  __eq__     — equal if same coefficients (ignoring leading zeros)

Container:
  __len__    — number of coefficients (degree + 1)
  __getitem__ — access coefficient by index
  __iter__   — iterate coefficients (highest to lowest)
  __contains__ — check if a coefficient value exists

Callable:
  __call__(x) — evaluate at x: p(3) evaluates the polynomial at x=3

Conversion:
  __str__    → "3x^2 + 2x - 5", "x^3 - 4", "5"
  __repr__   → "Polynomial(3, 2, -5)"
  __bool__   → False only if all coefficients are 0

Expected output:

=== POLYNOMIAL ===

p = 3x^2 + 2x - 5
q = x^3 - 4

p(0) = -5
p(1) = 0
p(2) = 11
p(-1) = -4

p + q = x^3 + 3x^2 + 2x - 9
p - q = -x^3 + 3x^2 + 2x - 1
p * q = 3x^5 + 2x^4 - 5x^3 - 12x^2 - 8x + 20
3 * p = 9x^2 + 6x - 15

degree(p) = 2
len(p)    = 3
p[0]      = 3  (coefficient of x^2)
5 in p    = False
-5 in p   = True

Derivative of p = 6x + 2

💡 Hints:

  • Store coefficients as list from highest to lowest degree: [3, 2, -5]
  • __call__(x): use Horner’s method: result = 0; for c in self._coeffs: result = result * x + c
  • __add__: pad the shorter list with zeros on the left, then add element-wise
  • __mul__ (polynomial × polynomial): result degree = deg1 + deg2; convolve coefficients
  • __str__: iterate with enumerate to know the degree of each term
  • derivative() method (not operator): new coefficients )]
  • Leading zeros: strip them with while len > 1 and coeffs[0] == 0: pop

Commented solutions

Solution Exercise 1

import math
from functools import total_ordering

class Complex:
    """Complex number a + bi with full operator support."""

    def __init__(self, real=0, imag=0):
        self.real = float(real)
        self.imag = float(imag)

    # ─── Properties ─────────────────────────────────
    @property
    def conjugate(self):
        return Complex(self.real, -self.imag)

    @property
    def magnitude(self):
        return math.sqrt(self.real ** 2 + self.imag ** 2)

    @property
    def phase(self):
        return math.atan2(self.imag, self.real)

    # ─── Arithmetic ─────────────────────────────────
    def __add__(self, other):
        if isinstance(other, Complex):
            return Complex(self.real + other.real, self.imag + other.imag)
        if isinstance(other, (int, float)):
            return Complex(self.real + other, self.imag)
        return NotImplemented

    def __radd__(self, other):
        return self.__add__(other)

    def __sub__(self, other):
        if isinstance(other, Complex):
            return Complex(self.real - other.real, self.imag - other.imag)
        if isinstance(other, (int, float)):
            return Complex(self.real - other, self.imag)
        return NotImplemented

    def __rsub__(self, other):
        if isinstance(other, (int, float)):
            return Complex(other - self.real, -self.imag)
        return NotImplemented

    def __mul__(self, other):
        if isinstance(other, Complex):
            # (a+bi)(c+di) = (ac-bd) + (ad+bc)i
            return Complex(
                self.real * other.real - self.imag * other.imag,
                self.real * other.imag + self.imag * other.real
            )
        if isinstance(other, (int, float)):
            return Complex(self.real * other, self.imag * other)
        return NotImplemented

    def __rmul__(self, other):
        return self.__mul__(other)

    def __truediv__(self, other):
        if isinstance(other, Complex):
            # (a+bi)/(c+di) = ((ac+bd) + (bc-ad)i) / (c²+d²)
            denom = other.real ** 2 + other.imag ** 2
            if denom == 0:
                raise ZeroDivisionError('Cannot divide by zero complex number')
            return Complex(
                (self.real * other.real + self.imag * other.imag) / denom,
                (self.imag * other.real - self.real * other.imag) / denom
            )
        if isinstance(other, (int, float)):
            if other == 0:
                raise ZeroDivisionError('Cannot divide by zero')
            return Complex(self.real / other, self.imag / other)
        return NotImplemented

    def __pow__(self, n):
        if not isinstance(n, int):
            return NotImplemented
        if n < 0:
            return Complex(1, 0) / self ** (-n)
        if n == 0:
            return Complex(1, 0)
        result = Complex(1, 0)
        for _ in range(n):
            result = result * self
        return result

    def __neg__(self):
        return Complex(-self.real, -self.imag)

    def __abs__(self):
        return self.magnitude

    # ─── Comparison ─────────────────────────────────
    def __eq__(self, other):
        if isinstance(other, Complex):
            return (abs(self.real - other.real) < 1e-10 and
                    abs(self.imag - other.imag) < 1e-10)
        if isinstance(other, (int, float)):
            return self.imag == 0 and abs(self.real - other) < 1e-10
        return NotImplemented

    # No __lt__ — complex numbers have no natural ordering

    # ─── Conversion ─────────────────────────────────
    def __bool__(self):
        return self.magnitude > 1e-10

    def __float__(self):
        if abs(self.imag) > 1e-10:
            raise TypeError(
                f'Cannot convert complex number with '
                f'non-zero imaginary part to float: {self}'
            )
        return self.real

    def __complex__(self):
        return complex(self.real, self.imag)

    # ─── String ─────────────────────────────────────
    def __str__(self):
        r = self.real
        i = self.imag

        # Clean up floating point display
        r_str = str(int(r)) if r == int(r) else str(round(r, 10))
        i_str = str(int(abs(i))) if abs(i) == int(abs(i)) else str(round(abs(i), 10))

        if i == 0:
            return r_str
        if r == 0:
            return f'-{i_str}i' if i < 0 else f'{i_str}i'
        if i == 1:
            return f'{r_str}+i'
        if i == -1:
            return f'{r_str}-i'
        if i < 0:
            return f'{r_str}-{i_str}i'
        return f'{r_str}+{i_str}i'

    def __repr__(self):
        r = int(self.real) if self.real == int(self.real) else self.real
        i = int(self.imag) if self.imag == int(self.imag) else self.imag
        return f'Complex({r}, {i})'


# Demo
print('=== COMPLEX NUMBERS ===\n')

a = Complex(3, 4)
b = Complex(1, -2)

print(f'a = {a}')
print(f'b = {b}')
print(f'\na + b = {a + b}')
print(f'a - b = {a - b}')
print(f'a * b = {a * b}')
print(f'a / b = {a / b}')

print(f'\n|a| = {abs(a)}')
print(f'conjugate(a) = {a.conjugate}')
print(f'phase(a) = {a.phase} rad')

print(f'\na == Complex(3, 4): {a == Complex(3, 4)}')
print(f'a == b: {a == b}')

print(f'\na ** 2 = {a ** 2}')
print(f'a ** 0 = {a ** 0}')

# Sorting by magnitude (abs)
numbers = [Complex(5, 12), Complex(3, 4), Complex(3, 5), Complex(1, -2)]
print(f'\nSorted by magnitude: {sorted(numbers, key=abs)}')

# Mixed operations
print(f'\n2 + {a} = {2 + a}')
print(f'{a} * 3 = {a * 3}')
print(f'10 / {Complex(0, 2)} = {10 / Complex(0, 2)}')

Solution Exercise 2

class Polynomial:
    """
    Polynomial with coefficients from highest to lowest degree.
    Polynomial(3, 2, -5) represents 3x^2 + 2x - 5
    """

    def __init__(self, *coefficients):
        if not coefficients:
            coefficients = (0,)
        # Remove leading zeros (but keep at least one coefficient)
        coeffs = list(coefficients)
        while len(coeffs) > 1 and coeffs[0] == 0:
            coeffs.pop(0)
        self._coeffs = coeffs

    @property
    def degree(self):
        return len(self._coeffs) - 1

    @property
    def coefficients(self):
        return tuple(self._coeffs)

    # ─── Callable ───────────────────────────────────
    def __call__(self, x):
        """Evaluate at x using Horner's method."""
        result = 0
        for c in self._coeffs:
            result = result * x + c
        return result

    # ─── Container ──────────────────────────────────
    def __len__(self):
        return len(self._coeffs)

    def __getitem__(self, index):
        return self._coeffs[index]

    def __iter__(self):
        return iter(self._coeffs)

    def __contains__(self, value):
        return value in self._coeffs

    def __bool__(self):
        return any(c != 0 for c in self._coeffs)

    # ─── Arithmetic ─────────────────────────────────
    def __add__(self, other):
        if isinstance(other, Polynomial):
            # Pad shorter with leading zeros
            a = self._coeffs[:]
            b = other._coeffs[:]
            while len(a) < len(b):
                a.insert(0, 0)
            while len(b) < len(a):
                b.insert(0, 0)
            return Polynomial(*[x + y for x, y in zip(a, b)])
        if isinstance(other, (int, float)):
            coeffs = self._coeffs[:]
            coeffs[-1] += other
            return Polynomial(*coeffs)
        return NotImplemented

    def __radd__(self, other):
        return self.__add__(other)

    def __sub__(self, other):
        if isinstance(other, Polynomial):
            return self + (-other)
        if isinstance(other, (int, float)):
            coeffs = self._coeffs[:]
            coeffs[-1] -= other
            return Polynomial(*coeffs)
        return NotImplemented

    def __rsub__(self, other):
        return (-self) + other

    def __mul__(self, other):
        if isinstance(other, Polynomial):
            # Polynomial multiplication (convolution)
            result_len = len(self._coeffs) + len(other._coeffs) - 1
            result = [0] * result_len
            for i, a in enumerate(self._coeffs):
                for j, b in enumerate(other._coeffs):
                    result[i + j] += a * b
            return Polynomial(*result)
        if isinstance(other, (int, float)):
            return Polynomial(*)
        return NotImplemented

    def __rmul__(self, other):
        return self.__mul__(other)

    def __neg__(self):
        return Polynomial(*[-c for c in self._coeffs])

    def __eq__(self, other):
        if isinstance(other, Polynomial):
            return self._coeffs == other._coeffs
        if isinstance(other, (int, float)):
            return self.degree == 0 and self._coeffs[0] == other
        return NotImplemented

    # ─── Utility ────────────────────────────────────
    def derivative(self):
        """Returns the derivative polynomial."""
        if self.degree == 0:
            return Polynomial(0)
        new_coeffs = [
            c * (self.degree - i)
            for i, c in enumerate(self._coeffs[:-1])
        ]
        return Polynomial(*new_coeffs)

    def roots_approx(self, iterations=1000, step=0.01):
        """Simple numerical root finder — not for production."""
        roots = []
        x = -50.0
        prev = self(x)
        while x <= 50.0:
            curr = self(x)
            if prev * curr < 0:    # sign change → root between x-step and x
                roots.append(round(x - step / 2, 4))
            prev = curr
            x = round(x + step, 10)
        return roots

    # ─── String ─────────────────────────────────────
    def __str__(self):
        if all(c == 0 for c in self._coeffs):
            return '0'

        terms = []
        degree = self.degree

        for i, coeff in enumerate(self._coeffs):
            power = degree - i
            if coeff == 0:
                continue

            # Coefficient string
            abs_coeff = abs(coeff)
            if power == 0:
                term = str(abs_coeff) if abs_coeff != int(abs_coeff) \
                       else str(int(abs_coeff))
            elif abs_coeff == 1:
                term = ''
            else:
                term = str(abs_coeff) if abs_coeff != int(abs_coeff) \
                       else str(int(abs_coeff))

            # Variable and power
            if power == 0:
                var = ''
            elif power == 1:
                var = 'x'
            else:
                var = f'x^{power}'

            terms.append((coeff, term + var))

        if not terms:
            return '0'

        result = ''
        for i, (coeff, term) in enumerate(terms):
            if i == 0:
                result += f'-{term}' if coeff < 0 else term
            else:
                result += f' - {term}' if coeff < 0 else f' + {term}'

        return result

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


# Demo
print('=== POLYNOMIAL ===\n')

p = Polynomial(3, 2, -5)     # 3x^2 + 2x - 5
q = Polynomial(1, 0, 0, -4)  # x^3 - 4

print(f'p = {p}')
print(f'q = {q}')

print(f'\np(0) = {p(0)}')
print(f'p(1) = {p(1)}')
print(f'p(2) = {p(2)}')
print(f'p(-1) = {p(-1)}')

print(f'\np + q = {p + q}')
print(f'p - q = {p - q}')
print(f'p * q = {p * q}')
print(f'3 * p = {3 * p}')

print(f'\ndegree(p) = {p.degree}')
print(f'len(p)    = {len(p)}')
print(f'p[0]      = {p[0]}  (coefficient of x^2)')
print(f'5 in p    = {5 in p}')
print(f'-5 in p   = {-5 in p}')

print(f'\nDerivative of p = {p.derivative()}')

# Roots
print(f'\nApproximate roots of p (where p(x)=0):')
roots = p.roots_approx()
for r in roots:
    print(f'  x ≈ {r} → p({r}) = {round(p(r), 4)}')

Visualise with Python Tutor

Copy this code into pythontutor.com and step through it:

class Polynomial:
    def __init__(self, *coeffs):
        self._coeffs = list(coeffs)

    def __call__(self, x):
        result = 0
        for c in self._coeffs:
            result = result * x + c
        return result

    def __add__(self, other):
        a = self._coeffs[:]
        b = other._coeffs[:]
        while len(a) < len(b): a.insert(0, 0)
        while len(b) < len(a): b.insert(0, 0)
        return Polynomial(*[x + y for x, y in zip(a, b)])

    def __str__(self):
        return ' + '.join(
            f'{c}x^{self.degree-i}' if self.degree-i > 0 else str(c)
            for i, c in enumerate(self._coeffs) if c != 0
        )

    @property
    def degree(self):
        return len(self._coeffs) - 1

p = Polynomial(2, -3, 1)   # 2x^2 - 3x + 1

print(f'p = {p}')
print(f'p(0) = {p(0)}')    # __call__
print(f'p(1) = {p(1)}')
print(f'p(3) = {p(3)}')

q = Polynomial(1, 2)       # x + 2
print(f'\np + q = {p + q}')  # __add__

Step through and observe three key moments. When p(3) is called, Python calls p.__call__(3) — Horner’s method result = result * x + c evaluates the polynomial efficiently without computing powers. Watch result go from 0 → 2 → 3 → 10: 0*3+2=2, 2*3-3=3, 3*3+1=10. When p + q is called, Python calls p.__add__(q). The padding step makes q._coeffs from [1, 2] to [0, 1, 2] before adding element-wise with p._coeffs = [2, -3, 1]. The result is [2, -2, 3] = 2x² – 2x + 3. The __call__ method is special — it’s what lets you write p(3) instead of p.evaluate(3). Any object can become callable by defining __call__.


Cheat sheet — Operator overloading

# ============================================
# CHEAT SHEET — Operator Overloading
# Sergio Learns · sergiolearns.com
# ============================================

# COMPARISON (define __eq__ + __lt__, get rest with @total_ordering)
from functools import total_ordering

@total_ordering
class MyClass:
    def __eq__(self, other):
        if isinstance(other, MyClass): return self.val == other.val
        return NotImplemented
    def __lt__(self, other):
        if isinstance(other, MyClass): return self.val < other.val
        return NotImplemented
    # __le__, __gt__, __ge__, __ne__ derived automatically

# ARITHMETIC
def __add__(self, other): ...       # self + other
def __radd__(self, other): ...      # other + self (when other fails)
def __sub__(self, other): ...       # self - other
def __rsub__(self, other): ...      # other - self
def __mul__(self, other): ...       # self * other
def __rmul__(self, other): ...      # other * self
def __truediv__(self, other): ...   # self / other
def __floordiv__(self, other): ...  # self // other
def __mod__(self, other): ...       # self % other
def __pow__(self, exp): ...         # self ** exp
def __neg__(self): ...              # -self
def __abs__(self): ...              # abs(self)

# IN-PLACE — must return self
def __iadd__(self, other):
    # modify self in place
    return self    # always return self

# CONTAINER
def __len__(self): ...              # len(obj)
def __getitem__(self, i): ...       # obj[i]
def __setitem__(self, i, v): ...    # obj[i] = v
def __contains__(self, x): ...      # x in obj
def __iter__(self): ...             # for x in obj:
def __bool__(self): ...             # if obj: / bool(obj)

# CALLABLE
def __call__(self, *args): ...      # obj(args) — makes obj callable

# CONVERSION
def __int__(self): ...              # int(obj)
def __float__(self): ...            # float(obj)
def __complex__(self): ...          # complex(obj)
def __str__(self): ...              # str(obj), print(obj)
def __repr__(self): ...             # repr(obj), shell

# KEY RULES
# 1. Always check isinstance(other, ExpectedType)
# 2. Return NotImplemented (not None, not raise) for unknown types
# 3. In-place operators MUST return self
# 4. Reflected (__r*) needed for: scalar OP myobj
# 5. @total_ordering needs only __eq__ + __lt__
# 6. __call__ makes instances callable like functions

# NotImplemented vs NotImplementedError
def __add__(self, other):
    if isinstance(other, MyClass):
        return MyClass(self.val + other.val)
    return NotImplemented          # correct: tells Python to try reflected
    # NOT: raise NotImplementedError  — that's for abstract methods

# HORNER'S METHOD for polynomials
def evaluate(coeffs, x):
    result = 0
    for c in coeffs:
        result = result * x + c   # efficient: no power calculation
    return result

# COMPLEX MULTIPLICATION
# (a+bi)(c+di) = (ac-bd) + (ad+bc)i
def __mul__(self, other):
    return Complex(
        self.real * other.real - self.imag * other.imag,
        self.real * other.imag + self.imag * other.real
    )

# POLYNOMIAL ADDITION
# Pad shorter list, then add element-wise
a = [3, 2, -5]    # 3x² + 2x - 5
b = [1, 2]        # x + 2 → padded to [0, 1, 2]
# result: [3, 3, -3] = 3x² + 3x - 3

# COMPILE AND TEST
# always test: forward op, reflected op, in-place op
# check NotImplemented propagation
# test edge cases: zero, negative, type mismatch

Similar Posts

Leave a Reply

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