Python operator overloading practice β 2 real classes with all operators
In the previous article we covered Python operator overloading theory. Now it’s time to build complete classes that use every operator category. In this article we implement two classes from scratch β a Rational number that supports full arithmetic, and a Money class that handles currencies correctly. Both are real-world design problems where operator overloading is the natural solution, not a trick.
Table of Contents
Python operator overloading practice β Program 1: Rational number (fraction arithmetic)
A rational number is a number expressed as a fraction p/q. Building this class properly requires arithmetic operators, comparison operators, reflected operators, conversion methods and @total_ordering β the complete toolkit.
from math import gcd
from functools import total_ordering
@total_ordering
class Rational:
"""
Rational number p/q with automatic simplification.
Supports all arithmetic and comparison operations.
"""
def __init__(self, numerator, denominator=1):
if not isinstance(numerator, int):
raise TypeError(f'Numerator must be int, got {type(numerator).__name__}')
if not isinstance(denominator, int):
raise TypeError(f'Denominator must be int, got {type(denominator).__name__}')
if denominator == 0:
raise ZeroDivisionError('Denominator cannot be zero')
# Normalise sign β negative always in numerator
if denominator < 0:
numerator = -numerator
denominator = -denominator
# Simplify automatically
common = gcd(abs(numerator), abs(denominator))
self._num = numerator // common
self._den = denominator // common
# βββ Properties βββββββββββββββββββββββββββββββββ
@property
def numerator(self):
return self._num
@property
def denominator(self):
return self._den
@property
def is_integer(self):
return self._den == 1
@property
def sign(self):
if self._num > 0: return 1
if self._num < 0: return -1
return 0
# βββ Arithmetic operators βββββββββββββββββββββββ
def __add__(self, other):
if isinstance(other, Rational):
return Rational(
self._num * other._den + other._num * self._den,
self._den * other._den
)
if isinstance(other, int):
return Rational(self._num + other * self._den, self._den)
return NotImplemented
def __radd__(self, other):
return self.__add__(other)
def __sub__(self, other):
if isinstance(other, Rational):
return Rational(
self._num * other._den - other._num * self._den,
self._den * other._den
)
if isinstance(other, int):
return Rational(self._num - other * self._den, self._den)
return NotImplemented
def __rsub__(self, other):
if isinstance(other, int):
return Rational(other * self._den - self._num, self._den)
return NotImplemented
def __mul__(self, other):
if isinstance(other, Rational):
return Rational(self._num * other._num, self._den * other._den)
if isinstance(other, int):
return Rational(self._num * other, self._den)
return NotImplemented
def __rmul__(self, other):
return self.__mul__(other)
def __truediv__(self, other):
if isinstance(other, Rational):
if other._num == 0:
raise ZeroDivisionError('Cannot divide by zero fraction')
return Rational(self._num * other._den, self._den * other._num)
if isinstance(other, int):
if other == 0:
raise ZeroDivisionError('Cannot divide by zero')
return Rational(self._num, self._den * other)
return NotImplemented
def __rtruediv__(self, other):
if isinstance(other, int):
if self._num == 0:
raise ZeroDivisionError('Cannot divide by zero fraction')
return Rational(other * self._den, self._num)
return NotImplemented
def __pow__(self, exponent):
if isinstance(exponent, int):
if exponent >= 0:
return Rational(self._num ** exponent, self._den ** exponent)
else: # negative exponent: (p/q)^-n = (q/p)^n
return Rational(self._den ** (-exponent), self._num ** (-exponent))
return NotImplemented
def __neg__(self):
return Rational(-self._num, self._den)
def __pos__(self):
return Rational(self._num, self._den)
def __abs__(self):
return Rational(abs(self._num), self._den)
# βββ In-place operators βββββββββββββββββββββββββ
def __iadd__(self, other):
result = self.__add__(other)
if result is NotImplemented:
return NotImplemented
self._num = result._num
self._den = result._den
return self
def __isub__(self, other):
result = self.__sub__(other)
if result is NotImplemented:
return NotImplemented
self._num = result._num
self._den = result._den
return self
def __imul__(self, other):
result = self.__mul__(other)
if result is NotImplemented:
return NotImplemented
self._num = result._num
self._den = result._den
return self
# βββ Comparison operators βββββββββββββββββββββββ
def __eq__(self, other):
if isinstance(other, Rational):
# Already simplified β equal if same numerator and denominator
return self._num == other._num and self._den == other._den
if isinstance(other, int):
return self._den == 1 and self._num == other
return NotImplemented
def __lt__(self, other):
if isinstance(other, Rational):
return self._num * other._den < other._num * self._den
if isinstance(other, int):
return self._num < other * self._den
return NotImplemented
# βββ Conversion operators βββββββββββββββββββββββ
def __int__(self):
return self._num // self._den # truncates toward zero
def __float__(self):
return self._num / self._den
def __bool__(self):
return self._num != 0
def __round__(self, n=0):
return round(float(self), n)
# βββ String representation βββββββββββββββββββββββ
def __str__(self):
if self._den == 1:
return str(self._num)
return f'{self._num}/{self._den}'
def __repr__(self):
return f'Rational({self._num}, {self._den})'
def __format__(self, spec):
if spec == 'f':
return f'{float(self):.6f}'
if spec.endswith('f') or spec.endswith('e'):
return format(float(self), spec)
return str(self)
# βββ Demo ββββββββββββββββββββββββββββββββββββββββββββ
print('=== RATIONAL NUMBER ARITHMETIC ===\n')
a = Rational(1, 2) # 1/2
b = Rational(1, 3) # 1/3
c = Rational(3, 6) # 3/6 β simplifies to 1/2
d = Rational(2, 1) # 2 (integer)
# Basic arithmetic
print(f'{a} + {b} = {a + b}') # β 1/2 + 1/3 = 5/6
print(f'{a} - {b} = {a - b}') # β 1/2 - 1/3 = 1/6
print(f'{a} * {b} = {a * b}') # β 1/2 * 1/3 = 1/6
print(f'{a} / {b} = {a / b}') # β 1/2 / 1/3 = 3/2
print(f'{a} ** 3 = {a ** 3}') # β (1/2)^3 = 1/8
print(f'-{a} = {-a}') # β -1/2
print(f'|{-a}| = {abs(-a)}') # β 1/2
# Automatic simplification
print(f'\n{c} == {a}: {c == a}') # β True (3/6 simplifies to 1/2)
# Mixed operations with int
print(f'\n{a} + 1 = {a + 1}') # β 1/2 + 1 = 3/2
print(f'1 + {a} = {1 + a}') # β 1 + 1/2 = 3/2 (uses __radd__)
print(f'2 * {a} = {2 * a}') # β 1 (uses __rmul__)
print(f'1 / {a} = {1 / a}') # β 2 (uses __rtruediv__)
# In-place operators
r = Rational(1, 4)
print(f'\nr = {r}')
r += Rational(1, 4)
print(f'r += 1/4 β {r}') # β 1/2
r *= 3
print(f'r *= 3 β {r}') # β 3/2
# Comparisons β all work via @total_ordering
fractions = [Rational(3, 4), Rational(1, 2), Rational(1, 3),
Rational(2, 3), Rational(1, 4)]
print(f'\nSorted: {sorted(fractions)}')
print(f'Min: {min(fractions)}')
print(f'Max: {max(fractions)}')
# Conversion
print(f'\nfloat({a}) = {float(a)}') # β 0.5
print(f'int({Rational(7,2)}) = {int(Rational(7,2))}') # β 3
# Format
print(f'{a:f}') # β 0.500000
print(f'{a:.4f}') # β 0.5000
# Boolean
print(f'\nbool(1/2) = {bool(Rational(1,2))}') # β True
print(f'bool(0/1) = {bool(Rational(0,1))}') # β False
Output:
=== RATIONAL NUMBER ARITHMETIC === 1/2 + 1/3 = 5/6 1/2 - 1/3 = 1/6 1/2 * 1/3 = 1/6 1/2 / 1/3 = 3/2 1/2 ** 3 = 1/8 -1/2 = -1/2 |-1/2| = 1/2 3/6 == 1/2: True 1/2 + 1 = 3/2 1 + 1/2 = 3/2 2 * 1/2 = 1 1 / 1/2 = 2 r = 1/4 r += 1/4 β 1/2 r *= 3 β 3/2 Sorted: [1/4, 1/3, 1/2, 2/3, 3/4] Min: 1/4 Max: 3/4 float(1/2) = 0.5 int(7/2) = 3 0.500000 0.5000 bool(1/2) = True bool(0) = False
The @total_ordering decorator is doing real work here β from just __eq__ and __lt__ Python derives >, >=, <= and !=. The automatic simplification in __init__ using gcd means Rational(3, 6) and Rational(1, 2) are equal without any extra logic β they simplify to the same canonical form. The reflected operators (__radd__, __rmul__, __rtruediv__) let you write 1 + fraction and 2 * fraction naturally β Python first tries int.__add__(1, fraction), gets NotImplemented, then tries fraction.__radd__(1).
Python operator overloading practice β Program 2: Money class with currency handling
Money is one of the most practical operator overloading examples because arithmetic with money has real constraints β you can’t add dollars to euros, you can’t have negative prices in some contexts, and display matters.
from functools import total_ordering
from decimal import Decimal, ROUND_HALF_UP
@total_ordering
class Money:
"""
Monetary value with currency.
Uses Decimal internally to avoid floating-point errors.
"""
# Supported currencies and their decimal places
CURRENCIES = {
'EUR': 2, 'USD': 2, 'GBP': 2,
'JPY': 0, 'CHF': 2, 'CAD': 2,
'AUD': 2, 'CNY': 2
}
SYMBOLS = {
'EUR': 'β¬', 'USD': '$', 'GBP': 'Β£',
'JPY': 'Β₯', 'CHF': 'CHF', 'CAD': 'C$',
'AUD': 'A$', 'CNY': 'Β₯'
}
def __init__(self, amount, currency='EUR'):
currency = currency.upper()
if currency not in self.CURRENCIES:
raise ValueError(
f'Unsupported currency: {currency}. '
f'Supported: {", ".join(self.CURRENCIES)}'
)
# Use Decimal for precision
if isinstance(amount, float):
amount = str(amount) # avoid float imprecision
self._amount = Decimal(str(amount))
self._currency = currency
# Round to currency's decimal places
places = self.CURRENCIES[currency]
quantize_str = '0.' + '0' * places if places > 0 else '0'
self._amount = self._amount.quantize(
Decimal(quantize_str), rounding=ROUND_HALF_UP
)
# βββ Properties βββββββββββββββββββββββββββββββββ
@property
def amount(self):
return float(self._amount)
@property
def currency(self):
return self._currency
@property
def symbol(self):
return self.SYMBOLS.get(self._currency, self._currency)
def _check_currency(self, other):
"""Raise error if currencies don't match."""
if self._currency != other._currency:
raise TypeError(
f'Cannot operate on different currencies: '
f'{self._currency} and {other._currency}. '
f'Convert first.'
)
# βββ Arithmetic operators βββββββββββββββββββββββ
def __add__(self, other):
if isinstance(other, Money):
self._check_currency(other)
return Money(self._amount + other._amount, self._currency)
if isinstance(other, (int, float, Decimal)):
return Money(self._amount + Decimal(str(other)), self._currency)
return NotImplemented
def __radd__(self, other):
if isinstance(other, (int, float, Decimal)):
return Money(Decimal(str(other)) + self._amount, self._currency)
return NotImplemented
def __sub__(self, other):
if isinstance(other, Money):
self._check_currency(other)
return Money(self._amount - other._amount, self._currency)
if isinstance(other, (int, float, Decimal)):
return Money(self._amount - Decimal(str(other)), self._currency)
return NotImplemented
def __mul__(self, scalar):
"""Multiply money by a scalar (not by another Money β that's priceΓqty)."""
if isinstance(scalar, (int, float, Decimal)):
return Money(self._amount * Decimal(str(scalar)), self._currency)
return NotImplemented
def __rmul__(self, scalar):
return self.__mul__(scalar)
def __truediv__(self, divisor):
"""Divide money by a scalar β e.g. split a bill."""
if isinstance(divisor, (int, float)):
if divisor == 0:
raise ZeroDivisionError('Cannot divide money by zero')
return Money(self._amount / Decimal(str(divisor)), self._currency)
if isinstance(divisor, Money):
# Money / Money = ratio (dimensionless)
self._check_currency(divisor)
if divisor._amount == 0:
raise ZeroDivisionError('Cannot divide by zero money')
return float(self._amount / divisor._amount)
return NotImplemented
def __floordiv__(self, divisor):
"""Split into N equal parts β returns (Money, remainder)."""
if isinstance(divisor, int):
if divisor <= 0:
raise ValueError(f'Parts must be positive: {divisor}')
each = Money(self._amount // divisor, self._currency)
remainder = self - each * divisor
return each, remainder
return NotImplemented
def __mod__(self, other):
"""Remainder after even split."""
if isinstance(other, int):
result = self.__floordiv__(other)
if result is NotImplemented:
return NotImplemented
_, remainder = result
return remainder
return NotImplemented
def __neg__(self):
return Money(-self._amount, self._currency)
def __abs__(self):
return Money(abs(self._amount), self._currency)
# βββ In-place operators βββββββββββββββββββββββββ
def __iadd__(self, other):
result = self.__add__(other)
if result is NotImplemented:
return NotImplemented
self._amount = result._amount
return self
def __isub__(self, other):
result = self.__sub__(other)
if result is NotImplemented:
return NotImplemented
self._amount = result._amount
return self
def __imul__(self, scalar):
result = self.__mul__(scalar)
if result is NotImplemented:
return NotImplemented
self._amount = result._amount
return self
# βββ Comparison operators βββββββββββββββββββββββ
def __eq__(self, other):
if isinstance(other, Money):
self._check_currency(other)
return self._amount == other._amount
if isinstance(other, (int, float)):
return self._amount == Decimal(str(other))
return NotImplemented
def __lt__(self, other):
if isinstance(other, Money):
self._check_currency(other)
return self._amount < other._amount
if isinstance(other, (int, float)):
return self._amount < Decimal(str(other))
return NotImplemented
# βββ Boolean and conversion ββββββββββββββββββββββ
def __bool__(self):
return self._amount != 0
def __float__(self):
return float(self._amount)
def __int__(self):
return int(self._amount)
def __round__(self, n=2):
return Money(round(self._amount, n), self._currency)
# βββ String representation βββββββββββββββββββββββ
def __str__(self):
places = self.CURRENCIES[self._currency]
formatted = f'{self._amount:.{places}f}'
return f'{self.symbol}{formatted}'
def __repr__(self):
return f"Money({float(self._amount)}, '{self._currency}')"
def __format__(self, spec):
if spec == 'plain':
return f'{float(self._amount):.{self.CURRENCIES[self._currency]}f}'
return str(self)
# βββ Utility methods βββββββββββββββββββββββββββββ
def split(self, n):
"""Split into n equal parts β handles penny rounding correctly."""
if n <= 0:
raise ValueError(f'Cannot split into {n} parts')
each, remainder = self // n
parts = [each] * n
# Distribute remainder pennies one by one
places = self.CURRENCIES[self._currency]
one_cent = Money(Decimal('0.' + '0' * (places - 1) + '1')
if places > 0 else Decimal('1'), self._currency)
remaining_cents = int(remainder._amount / one_cent._amount)
for i in range(remaining_cents):
parts[i] += one_cent
return parts
def apply_tax(self, rate_percent):
"""Returns (amount_before_tax, tax_amount, total)."""
tax = self * (rate_percent / 100)
return self, tax, self + tax
def apply_discount(self, percent):
"""Returns (original, discount, final)."""
discount = self * (percent / 100)
return self, discount, self - discount
# βββ Demo ββββββββββββββββββββββββββββββββββββββββββββ
print('=== MONEY CLASS ===\n')
price = Money(99.99, 'EUR')
shipping = Money(4.99, 'EUR')
discount = Money(10.00, 'EUR')
# Basic arithmetic
total = price + shipping
print(f'Price + shipping: {price} + {shipping} = {total}')
total_discounted = total - discount
print(f'After discount: {total} - {discount} = {total_discounted}')
# Multiply by quantity
qty = 3
order_total = price * qty
print(f'\n{qty} Γ {price} = {order_total}')
print(f'{qty} Γ {price} = {qty * price}') # __rmul__
# VAT
net, vat, gross = price.apply_tax(21)
print(f'\n--- VAT breakdown ---')
print(f'Net: {net}')
print(f'VAT: {vat}')
print(f'Gross: {gross}')
# Discount
orig, disc_amount, final = price.apply_discount(15)
print(f'\n--- 15% discount ---')
print(f'Original: {orig}')
print(f'Discount: -{disc_amount}')
print(f'Final: {final}')
# Splitting a bill
bill = Money(100.00, 'EUR')
parts = bill.split(3)
print(f'\n--- Split β¬100 three ways ---')
for i, part in enumerate(parts, 1):
print(f' Person {i}: {part}')
print(f' Total: {sum(parts[1:], parts[0])}') # sum with Money
# Comparisons
prices = [Money(15.99), Money(8.49), Money(24.99), Money(8.49)]
print(f'\n--- Sorted prices ---')
for p in sorted(prices):
print(f' {p}')
print(f'Cheapest: {min(prices)}')
print(f'Most expensive: {max(prices)}')
# Currency error
usd = Money(50.00, 'USD')
eur = Money(50.00, 'EUR')
try:
result = usd + eur
except TypeError as err:
print(f'\nβ {err}')
# Division: ratio between two amounts
total_budget = Money(1000.00, 'EUR')
spent = Money(350.00, 'EUR')
ratio = spent / total_budget # returns float
print(f'\nSpent {ratio:.1%} of budget')
# Boolean
empty = Money(0, 'EUR')
print(f'\nbool(β¬0.00) = {bool(empty)}')
print(f'bool(β¬10.00) = {bool(Money(10))}')
# Negative money
debt = -Money(500.00, 'EUR')
print(f'\nDebt: {debt}')
print(f'Absolute: {abs(debt)}')
Output:
=== MONEY CLASS === Price + shipping: β¬99.99 + β¬4.99 = β¬104.98 After discount: β¬104.98 - β¬10.00 = β¬94.98 3 Γ β¬99.99 = β¬299.97 3 Γ β¬99.99 = β¬299.97 --- VAT breakdown --- Net: β¬99.99 VAT: β¬21.00 Gross: β¬120.99 --- 15% discount --- Original: β¬99.99 Discount: -β¬15.00 Final: β¬84.99 --- Split β¬100 three ways --- Person 1: β¬33.34 Person 2: β¬33.33 Person 3: β¬33.33 Total: β¬100.00 --- Sorted prices --- β¬8.49 β¬8.49 β¬15.99 β¬24.99 Cheapest: β¬8.49 Most expensive: β¬24.99 β Cannot operate on different currencies: USD and EUR. Convert first. Spent 35.0% of budget bool(β¬0.00) = False bool(β¬10.00) = True Debt: -β¬500.00 Absolute: β¬500.00
The Decimal type is the key design decision β never use float for money because 0.1 + 0.2 = 0.30000000000000004 in floating point. Decimal gives exact decimal arithmetic. The split() method handles the classic penny distribution problem β if you split β¬100 into 3 parts, one person gets β¬33.34 and the other two get β¬33.33, totalling exactly β¬100.00. The currency check in _check_currency is called before any arithmetic between two Money objects β adding dollars to euros raises a TypeError immediately.
Visualise with Python Tutor
Copy this code into pythontutor.com and step through it:
from functools import total_ordering
@total_ordering
class Money:
def __init__(self, amount, currency='EUR'):
self.amount = round(amount, 2)
self.currency = currency
def __add__(self, other):
if isinstance(other, Money):
if self.currency != other.currency:
raise TypeError('Different currencies')
return Money(self.amount + other.amount, self.currency)
return NotImplemented
def __mul__(self, scalar):
if isinstance(scalar, (int, float)):
return Money(round(self.amount * scalar, 2), self.currency)
return NotImplemented
def __rmul__(self, scalar):
return self.__mul__(scalar)
def __eq__(self, other):
if isinstance(other, Money):
return self.amount == other.amount and self.currency == other.currency
return NotImplemented
def __lt__(self, other):
if isinstance(other, Money):
return self.amount < other.amount
return NotImplemented
def __str__(self):
return f'β¬{self.amount:.2f}'
a = Money(10.00)
b = Money(5.99)
print(a + b) # __add__
print(a * 3) # __mul__
print(3 * a) # __rmul__
print(a > b) # derived from __lt__ via @total_ordering
print(sorted([a, b, Money(7.50)]))
Step through and observe three key moments. When a + b is called Python calls a.__add__(b) which checks currencies match, then creates and returns a new Money object β a and b are unchanged. When 3 * a is called, Python first tries int.__mul__(3, a) β returns NotImplemented because int doesn’t know about Money. Python then tries a.__rmul__(3) which delegates to __mul__. When sorted([a, b, Money(7.50)]) is called, Python uses __lt__ to compare all pairs β @total_ordering makes this work without defining >, >= or <= explicitly.
Summary and next step
In this article you practised Python operator overloading with two complete classes. The Rational class showed arithmetic operators, reflected operators, @total_ordering, in-place operators and type conversion. The Money class showed currency validation, Decimal for precision, bill splitting with remainder distribution, and the full operator suite in a real financial context. Both classes demonstrate the same principle: operator overloading makes your classes feel like first-class Python citizens β natural to use, natural to read.
In the next article you’ll find exercises to solve on your own.

One Comment