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.
Table of Contents
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 imagmagnitudeproperty: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 withenumerateto know the degree of each termderivative()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
