Python operator overloading practice Rational Money classes arithmetic FP2

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.

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.

Similar Posts

One Comment

Leave a Reply

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