Python inheritance practice — 3 real class hierarchies with polymorphism
In the previous article we covered Python inheritance theory. Now it’s time to build real hierarchies. In this article we implement three complete systems — an employee payroll system, a vehicle fleet and a product store — each one using inheritance, super(), method overriding and polymorphism in situations where they genuinely simplify the design.
Table of Contents
Python inheritance practice — Program 1: Employee payroll system
This hierarchy models different types of employees with different salary calculation methods. It’s a perfect case for an abstract base class — every employee has a salary, but the way it’s calculated depends on the type.
from abc import ABC, abstractmethod
class Employee(ABC):
"""Abstract base class for all employee types."""
def __init__(self, employee_id, name, department):
self.employee_id = employee_id
self.name = name
self.department = department
self._active = True
@abstractmethod
def monthly_salary(self):
"""Each subclass must implement its own salary calculation."""
pass
@property
def is_active(self):
return self._active
def deactivate(self):
self._active = False
def annual_salary(self):
return self.monthly_salary() * 12
def payslip(self):
print(f'\n--- Payslip: {self.name} ({self.employee_id}) ---')
print(f'Department: {self.department}')
print(f'Type: {self.__class__.__name__}')
print(f'Monthly salary: €{self.monthly_salary():.2f}')
print(f'Annual salary: €{self.annual_salary():.2f}')
self._extra_payslip_info()
def _extra_payslip_info(self):
"""Hook for subclasses to add extra payslip lines."""
pass
def __str__(self):
status = 'Active' if self._active else 'Inactive'
return (f'[{self.employee_id}] {self.name} '
f'({self.__class__.__name__}) — '
f'€{self.monthly_salary():.2f}/month — {status}')
def __repr__(self):
return (f"{self.__class__.__name__}(id='{self.employee_id}', "
f"name='{self.name}')")
def __lt__(self, other):
return self.monthly_salary() < other.monthly_salary()
def __eq__(self, other):
return isinstance(other, Employee) and self.employee_id == other.employee_id
class FullTimeEmployee(Employee):
"""Fixed monthly salary regardless of hours."""
def __init__(self, employee_id, name, department, base_salary):
super().__init__(employee_id, name, department)
self._base_salary = base_salary
@property
def base_salary(self):
return self._base_salary
@base_salary.setter
def base_salary(self, value):
if value < 0:
raise ValueError(f'Salary cannot be negative: {value}')
self._base_salary = round(value, 2)
def monthly_salary(self):
return self._base_salary
def give_raise(self, percentage):
if not 0 < percentage <= 100:
raise ValueError(f'Raise must be 1-100%: {percentage}')
increase = round(self._base_salary * percentage / 100, 2)
self._base_salary = round(self._base_salary + increase, 2)
return increase
def _extra_payslip_info(self):
print(f'Contract: Full-time, fixed salary')
class HourlyEmployee(Employee):
"""Paid per hour worked."""
def __init__(self, employee_id, name, department,
hourly_rate, hours_per_month=160):
super().__init__(employee_id, name, department)
self._hourly_rate = hourly_rate
self._hours = hours_per_month
@property
def hourly_rate(self):
return self._hourly_rate
def log_hours(self, hours):
if hours < 0:
raise ValueError(f'Hours cannot be negative: {hours}')
self._hours = hours
def monthly_salary(self):
return round(self._hourly_rate * self._hours, 2)
def _extra_payslip_info(self):
print(f'Hours worked: {self._hours}h × €{self._hourly_rate:.2f}/h')
class CommissionEmployee(Employee):
"""Base salary plus commission on sales."""
def __init__(self, employee_id, name, department,
base_salary, commission_rate):
super().__init__(employee_id, name, department)
self._base_salary = base_salary
self._commission_rate = commission_rate # percentage
self._monthly_sales = 0
def log_sales(self, amount):
if amount < 0:
raise ValueError(f'Sales cannot be negative: {amount}')
self._monthly_sales = round(amount, 2)
@property
def commission(self):
return round(self._monthly_sales * self._commission_rate / 100, 2)
def monthly_salary(self):
return round(self._base_salary + self.commission, 2)
def _extra_payslip_info(self):
print(f'Base salary: €{self._base_salary:.2f}')
print(f'Monthly sales: €{self._monthly_sales:.2f}')
print(f'Commission ({self._commission_rate}%): €{self.commission:.2f}')
class Manager(FullTimeEmployee):
"""Full-time employee with team management bonus."""
def __init__(self, employee_id, name, department,
base_salary, team_size):
super().__init__(employee_id, name, department, base_salary)
self._team_size = team_size
self._team = []
@property
def team_size(self):
return self._team_size
def add_to_team(self, employee):
if employee not in self._team:
self._team.append(employee)
self._team_size = len(self._team)
def team_payroll(self):
return sum(e.monthly_salary() for e in self._team)
def monthly_salary(self):
# Base salary + €500 per team member
bonus = self._team_size * 500
return round(super().monthly_salary() + bonus, 2)
def _extra_payslip_info(self):
super()._extra_payslip_info()
print(f'Team size: {self._team_size} employees')
print(f'Management bonus: €{self._team_size * 500:.2f}')
# Usage
print('=== PAYROLL SYSTEM ===\n')
# Create employees
ceo = Manager('M001', 'Elena Torres', 'Management', 4000, 0)
sergio = FullTimeEmployee('F001', 'Sergio Medina', 'Engineering', 2500)
maria = HourlyEmployee('H001', 'María García', 'Support', 15.0, 160)
carlos = CommissionEmployee('C001', 'Carlos Ruiz', 'Sales', 1200, 5)
# Set up manager's team
ceo.add_to_team(sergio)
ceo.add_to_team(maria)
ceo.add_to_team(carlos)
# Log hours and sales
maria.log_hours(172)
carlos.log_sales(45000)
# Print all payslips
all_employees = [ceo, sergio, maria, carlos]
for emp in all_employees:
emp.payslip()
# Polymorphism — total payroll
print('\n=== PAYROLL SUMMARY ===')
total = sum(e.monthly_salary() for e in all_employees)
print(f'Total monthly payroll: €{total:.2f}')
# Sorting by salary
print('\nRanked by salary:')
for i, emp in enumerate(sorted(all_employees, reverse=True), 1):
print(f' {i}. {emp}')
# isinstance checks
print('\nManagers:', [e.name for e in all_employees if isinstance(e, Manager)])
print('Full-time:', [e.name for e in all_employees if isinstance(e, FullTimeEmployee)])
Output:
--- Payslip: Elena Torres (M001) --- Department: Management Type: Manager Monthly salary: €5500.00 Annual salary: €66000.00 Contract: Full-time, fixed salary Team size: 3 employees Management bonus: €1500.00 --- Payslip: Sergio Medina (F001) --- ...Monthly salary: €2500.00 --- Payslip: María García (H001) --- ...Hours worked: 172h × €15.00/h Monthly salary: €2580.00 --- Payslip: Carlos Ruiz (C001) --- ...Monthly sales: €45000.00 Commission (5%): €2250.00 Monthly salary: €3450.00 === PAYROLL SUMMARY === Total monthly payroll: €14030.00 Ranked by salary: 1. [M001] Elena Torres (Manager) — €5500.00/month — Active 2. [C001] Carlos Ruiz (CommissionEmployee) — €3450.00/month — Active ... Managers: ['Elena Torres'] Full-time: ['Elena Torres', 'Sergio Medina']
Notice that Manager inherits from FullTimeEmployee which inherits from Employee — a three-level hierarchy. Manager.monthly_salary() calls super().monthly_salary() (which is FullTimeEmployee‘s version) and adds the team bonus on top. isinstance(ceo, FullTimeEmployee) returns True because Manager is a subclass of FullTimeEmployee. The _extra_payslip_info hook method lets each subclass add its own payslip lines without overriding the entire payslip() method.
Python inheritance practice — Program 2 — Vehicle fleet
This hierarchy models a vehicle fleet with different propulsion types. It introduces the concept of using abstract methods to enforce an interface while sharing common vehicle logic.
from abc import ABC, abstractmethod
class Vehicle(ABC):
def __init__(self, plate, make, model, year, max_speed):
self.plate = plate.upper()
self.make = make
self.model = model
self.year = year
self.max_speed = max_speed
self._odometer = 0.0
self._trips = []
@abstractmethod
def fuel_type(self):
pass
@abstractmethod
def refuel(self, amount):
pass
@abstractmethod
def range_remaining(self):
"""Returns km remaining with current fuel/charge."""
pass
def drive(self, km):
if km <= 0:
raise ValueError(f'Distance must be positive: {km}')
if km > self.range_remaining():
raise ValueError(
f'Cannot drive {km}km — only {self.range_remaining():.0f}km remaining'
)
self._consume_fuel(km)
self._odometer = round(self._odometer + km, 1)
self._trips.append(km)
@abstractmethod
def _consume_fuel(self, km):
"""Internal fuel consumption — implemented by each subclass."""
pass
@property
def odometer(self):
return self._odometer
@property
def trips(self):
return len(self._trips)
def summary(self):
print(f'\n{self}')
print(f' Fuel type: {self.fuel_type()}')
print(f' Odometer: {self._odometer}km')
print(f' Range left: {self.range_remaining():.0f}km')
print(f' Trips: {self.trips}')
def __str__(self):
return (f'{self.plate} — {self.make} {self.model} {self.year} '
f'(max {self.max_speed}km/h)')
def __lt__(self, other):
return self.max_speed < other.max_speed
class PetrolVehicle(Vehicle):
CONSUMPTION_PER_100KM = 7.0 # litres
def __init__(self, plate, make, model, year, max_speed,
tank_capacity, initial_fuel=None):
super().__init__(plate, make, model, year, max_speed)
self._tank_capacity = tank_capacity
self._fuel = initial_fuel if initial_fuel is not None else tank_capacity * 0.5
def fuel_type(self):
return f'Petrol (tank: {self._tank_capacity}L)'
def refuel(self, litres):
if litres <= 0:
raise ValueError(f'Litres must be positive: {litres}')
space = self._tank_capacity - self._fuel
added = min(litres, space)
self._fuel = round(self._fuel + added, 2)
print(f' Refuelled {added:.1f}L → {self._fuel:.1f}L '
f'({self._fuel/self._tank_capacity*100:.0f}%)')
def range_remaining(self):
return round(self._fuel / self.CONSUMPTION_PER_100KM * 100, 1)
def _consume_fuel(self, km):
self._fuel = round(
self._fuel - km * self.CONSUMPTION_PER_100KM / 100, 2
)
class ElectricVehicle(Vehicle):
CONSUMPTION_PER_100KM = 15.0 # kWh
def __init__(self, plate, make, model, year, max_speed,
battery_capacity, initial_charge=None):
super().__init__(plate, make, model, year, max_speed)
self._battery_capacity = battery_capacity # kWh
self._charge = initial_charge if initial_charge is not None \
else battery_capacity * 0.8
def fuel_type(self):
return f'Electric (battery: {self._battery_capacity}kWh)'
def refuel(self, kwh):
"""For electric vehicles, refuel means charging."""
if kwh <= 0:
raise ValueError(f'kWh must be positive: {kwh}')
space = self._battery_capacity - self._charge
added = min(kwh, space)
self._charge = round(self._charge + added, 2)
percent = self._charge / self._battery_capacity * 100
print(f' Charged {added:.1f}kWh → {self._charge:.1f}kWh ({percent:.0f}%)')
def range_remaining(self):
return round(self._charge / self.CONSUMPTION_PER_100KM * 100, 1)
def _consume_fuel(self, km):
self._charge = round(
self._charge - km * self.CONSUMPTION_PER_100KM / 100, 2
)
@property
def charge_percent(self):
return round(self._charge / self._battery_capacity * 100, 1)
class HybridVehicle(PetrolVehicle, ElectricVehicle):
"""
Hybrid: uses electric first, switches to petrol when battery low.
Inherits from both PetrolVehicle and ElectricVehicle.
"""
ELECTRIC_THRESHOLD = 20.0 # switch to petrol below 20km range
def __init__(self, plate, make, model, year, max_speed,
tank_capacity, battery_capacity):
PetrolVehicle.__init__(self, plate, make, model, year, max_speed,
tank_capacity, tank_capacity * 0.5)
# Add battery manually (avoid double super().__init__)
self._battery_capacity = battery_capacity
self._charge = battery_capacity * 0.8
def fuel_type(self):
return (f'Hybrid (petrol: {self._tank_capacity}L + '
f'electric: {self._battery_capacity}kWh)')
def range_remaining(self):
electric_range = self._charge / ElectricVehicle.CONSUMPTION_PER_100KM * 100
petrol_range = self._fuel / PetrolVehicle.CONSUMPTION_PER_100KM * 100
return round(electric_range + petrol_range, 1)
def _consume_fuel(self, km):
electric_range = self._charge / ElectricVehicle.CONSUMPTION_PER_100KM * 100
if electric_range > self.ELECTRIC_THRESHOLD:
kwh_needed = km * ElectricVehicle.CONSUMPTION_PER_100KM / 100
if kwh_needed <= self._charge:
self._charge = round(self._charge - kwh_needed, 2)
return
# Fall back to petrol
PetrolVehicle._consume_fuel(self, km)
def refuel(self, amount, fuel_type='petrol'):
if fuel_type == 'electric':
ElectricVehicle.refuel(self, amount)
else:
PetrolVehicle.refuel(self, amount)
# Usage
print('=== VEHICLE FLEET ===\n')
yaris = PetrolVehicle('1234ABC', 'Toyota', 'Yaris', 2022, 170, 45)
tesla = ElectricVehicle('5678XYZ', 'Tesla', 'Model 3', 2023, 225, 75)
prius = HybridVehicle('9012DEF', 'Toyota', 'Prius', 2022, 180, 43, 8.8)
fleet = [yaris, tesla, prius]
# Polymorphism — same interface for all vehicle types
for vehicle in fleet:
vehicle.summary()
# Drive all vehicles the same way
print('\n--- All driving 50km ---')
for vehicle in fleet:
try:
vehicle.drive(50)
print(f' ✓ {vehicle.plate}: {vehicle.range_remaining():.0f}km remaining')
except ValueError as err:
print(f' ✗ {vehicle.plate}: {err}')
# Refuel — same method, different meaning per type
print('\n--- Refuelling ---')
yaris.refuel(20) # adds 20 litres of petrol
tesla.refuel(30) # adds 30 kWh of electricity
prius.refuel(15, 'petrol') # adds 15 litres of petrol
# Sorting by max speed
print('\nRanked by top speed:')
for i, v in enumerate(sorted(fleet, reverse=True), 1):
print(f' {i}. {v}')
# isinstance checks
print('\nElectric or hybrid:',
[v.plate for v in fleet if isinstance(v, ElectricVehicle)])
Output:
=== VEHICLE FLEET === 1234ABC — Toyota Yaris 2022 (max 170km/h) Fuel type: Petrol (tank: 45L) Odometer: 0km Range left: 321km Trips: 0 ... --- All driving 50km --- ✓ 1234ABC: 286km remaining ✓ 5678XYZ: 340km remaining ✓ 9012DEF: 409km remaining --- Refuelling --- Refuelled 20.0L → 38.5L (86%) Charged 30.0kWh → 62.5kWh (83%) Refuelled 15.0L → 36.5L (85%) Ranked by top speed: 1. 5678XYZ — Tesla Model 3 2023 (max 225km/h) 2. 9012DEF — Toyota Prius 2022 (max 180km/h) 3. 1234ABC — Toyota Yaris 2022 (max 170km/h) Electric or hybrid: ['5678XYZ', '9012DEF']
Program 3 — Product store with type hierarchy
This hierarchy models a product catalogue where different product types have different pricing rules and stock management.
from abc import ABC, abstractmethod
from datetime import date
class Product(ABC):
vat_rate = 0.21
def __init__(self, code, name, base_price):
self.code = code
self.name = name
self._base_price = base_price
@abstractmethod
def final_price(self):
"""Price including all adjustments — implemented by each subclass."""
pass
@abstractmethod
def is_available(self):
pass
@property
def base_price(self):
return self._base_price
@property
def price_with_vat(self):
return round(self.final_price() * (1 + self.vat_rate), 2)
def __str__(self):
status = '✓' if self.is_available() else '✗'
return (f'{status} [{self.code}] {self.name} — '
f'€{self.final_price():.2f} (€{self.price_with_vat:.2f} VAT)')
def __lt__(self, other):
return self.final_price() < other.final_price()
def __eq__(self, other):
return isinstance(other, Product) and self.code == other.code
class PhysicalProduct(Product):
"""Product with physical stock."""
def __init__(self, code, name, base_price, stock=0):
super().__init__(code, name, base_price)
self._stock = stock
@property
def stock(self):
return self._stock
def add_stock(self, quantity):
if quantity <= 0:
raise ValueError(f'Quantity must be positive: {quantity}')
self._stock += quantity
def sell(self, quantity=1):
if quantity > self._stock:
raise ValueError(
f'Insufficient stock: {self._stock} available, {quantity} requested'
)
self._stock -= quantity
def is_available(self):
return self._stock > 0
def final_price(self):
return round(self._base_price, 2)
def __str__(self):
return super().__str__() + f' — stock: {self._stock}'
class DiscountedProduct(PhysicalProduct):
"""Physical product with a percentage discount."""
def __init__(self, code, name, base_price, stock, discount_pct):
super().__init__(code, name, base_price, stock)
self._discount = discount_pct
@property
def discount(self):
return self._discount
@discount.setter
def discount(self, value):
if not 0 <= value <= 100:
raise ValueError(f'Discount must be 0-100%: {value}')
self._discount = value
def final_price(self):
return round(self._base_price * (1 - self._discount / 100), 2)
def savings(self):
return round(self._base_price - self.final_price(), 2)
def __str__(self):
return super().__str__() + f' (was €{self._base_price:.2f}, -{self._discount}%)'
class DigitalProduct(Product):
"""Downloadable product — unlimited copies."""
def __init__(self, code, name, base_price, file_size_mb, format_):
super().__init__(code, name, base_price)
self.file_size_mb = file_size_mb
self.format = format_
self._downloads = 0
def download(self):
self._downloads += 1
return f'Downloading {self.name} ({self.file_size_mb}MB {self.format})...'
def is_available(self):
return True # digital products never run out
def final_price(self):
return round(self._base_price, 2)
def __str__(self):
return super().__str__() + f' — {self.format}, {self.file_size_mb}MB'
class SubscriptionProduct(DigitalProduct):
"""Digital product billed monthly."""
def __init__(self, code, name, monthly_price, features):
super().__init__(code, name, monthly_price, 0, 'subscription')
self.features = features
self._subscribers = 0
def subscribe(self, customer):
self._subscribers += 1
return f'{customer} subscribed to {self.name}'
def final_price(self):
return round(self._base_price, 2) # monthly price
def annual_price(self):
return round(self._base_price * 10, 2) # 2 months free
def __str__(self):
base = f'✓ [{self.code}] {self.name} — €{self.final_price():.2f}/month'
return base + f' ({self._subscribers} subscribers)'
class Store:
def __init__(self, name):
self.name = name
self._catalogue = {}
def add(self, product):
self._catalogue[product.code] = product
def find(self, code):
if code not in self._catalogue:
raise ValueError(f'Product not found: {code}')
return self._catalogue
def available_products(self):
return [p for p in self._catalogue.values() if p.is_available()]
def by_type(self, product_type):
return [p for p in self._catalogue.values()
if isinstance(p, product_type)]
def catalogue(self):
print(f'\n=== {self.name} Catalogue ===')
for product in sorted(self._catalogue.values()):
print(f' {product}')
def total_stock_value(self):
total = 0
for p in self._catalogue.values():
if isinstance(p, PhysicalProduct):
total += p.final_price() * p.stock
return round(total, 2)
# Usage
print('=== PRODUCT STORE ===\n')
store = Store('Sergio Learns Shop')
# Add products
laptop = PhysicalProduct('LAP001', 'Laptop Pro', 999.99, 10)
mouse = DiscountedProduct('MOU001', 'Wireless Mouse', 39.99, 50, 25)
ebook = DigitalProduct('EBK001', 'Python FP2 Guide', 14.99, 8.5, 'PDF')
premium = SubscriptionProduct('SUB001', 'Premium Access', 9.99,
['All articles', 'AI tutor', 'No ads'])
for product in [laptop, mouse, ebook, premium]:
store.add(product)
store.catalogue()
# Polymorphism — same operations for all product types
print('\n--- Available products ---')
for p in store.available_products():
print(f' {p.name}: €{p.final_price():.2f}')
# Type-specific operations
print('\n--- Physical products ---')
for p in store.by_type(PhysicalProduct):
if isinstance(p, DiscountedProduct):
print(f' {p.name}: save €{p.savings():.2f} with {p.discount}% discount')
else:
print(f' {p.name}: €{p.final_price():.2f}')
print('\n--- Digital products ---')
for p in store.by_type(DigitalProduct):
if isinstance(p, SubscriptionProduct):
print(f' {p.name}: €{p.final_price():.2f}/month '
f'or €{p.annual_price():.2f}/year (2 months free)')
else:
print(f' {p.name}: {p.download()}')
# Sales
print('\n--- Sales ---')
try:
laptop.sell(2)
print(f' ✓ Sold 2 laptops → {laptop.stock} remaining')
print(f' ✓ {ebook.download()}')
print(f' ✓ {premium.subscribe("Sergio")}')
mouse.sell(100) # only 50 in stock
except ValueError as err:
print(f' ✗ {err}')
print(f'\nTotal stock value: €{store.total_stock_value():.2f}')
Output:
=== PRODUCT STORE === === Sergio Learns Shop Catalogue === ✓ [EBK001] Python FP2 Guide — €14.99 (€18.14 VAT) — PDF, 8.5MB ✓ [MOU001] Wireless Mouse — €29.99 (€36.29 VAT) — stock: 50 (was €39.99, -25%) ✓ [SUB001] Premium Access — €9.99/month (0 subscribers) ✓ [LAP001] Laptop Pro — €999.99 (€1209.99 VAT) — stock: 10 --- Available products --- Python FP2 Guide: €14.99 Wireless Mouse: €29.99 Premium Access: €9.99 Laptop Pro: €999.99 --- Physical products --- Wireless Mouse: save €10.00 with 25% discount Laptop Pro: €999.99 --- Digital products --- Python FP2 Guide: Downloading Python FP2 Guide (8.5MB PDF)... Premium Access: €9.99/month or €99.90/year (2 months free) --- Sales --- ✓ Sold 2 laptops → 8 remaining ✓ Downloading Python FP2 Guide (8.5MB PDF)... ✓ Sergio subscribed to Premium Access ✗ Insufficient stock: 50 available, 100 requested Total stock value: €8239.42
Visualise with Python Tutor
Copy this code into pythontutor.com and step through it:
from abc import ABC, abstractmethod
class Shape(ABC):
def __init__(self, colour):
self.colour = colour
@abstractmethod
def area(self):
pass
def describe(self):
return f'{self.colour}: area={self.area():.2f}'
class Circle(Shape):
def __init__(self, colour, radius):
super().__init__(colour)
self.radius = radius
def area(self):
return 3.14159 * self.radius ** 2
class Rectangle(Shape):
def __init__(self, colour, w, h):
super().__init__(colour)
self.w = w
self.h = h
def area(self):
return self.w * self.h
shapes = [Circle('red', 5), Rectangle('blue', 4, 6)]
for s in shapes:
print(s.describe())
Step through and watch three key moments. When Circle('red', 5) is created, Circle.__init__ runs and immediately calls super().__init__(colour) — Python jumps up to Shape.__init__ to set self.colour, then returns to Circle.__init__ to set self.radius. When describe() is called on the Circle, Python looks up describe in Circle — not found, so it goes up to Shape — found. Inside Shape.describe, self.area() is called — but self is a Circle object, so Python calls Circle.area(), not Shape.area(). That's polymorphism — the parent's method calls the child's implementation. Try adding a new shape class and watch it slot into the loop without any changes.
Summary and next step
In this article you practised Python inheritance with three complete hierarchies. You used abstract base classes to enforce interfaces, super() to initialise and extend parent behaviour, method overriding with and without calling super(), isinstance() for type-specific processing, multiple inheritance in the hybrid vehicle, and the hook method pattern for extending behaviour without full override.
In the next article you'll find exercises to solve on your own.

One Comment