Python inheritance exercises — master class hierarchies
Python inheritance exercises are where object-oriented design stops being abstract and becomes a tool you reach for naturally. You’ve seen the theory and built three complete hierarchies. Now it’s time to design and implement your own — an animal system and a bank account hierarchy.
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 how super() and polymorphism work in practice.
Table of Contents
Python inheritance exercises — Basic to Intermediate Level
Exercise 1 — Animal system
Design and implement a complete animal hierarchy using abstract base classes, inheritance and polymorphism.
Required hierarchy:
Animal (ABC)
├── WildAnimal
│ ├── Predator
│ └── Herbivore
└── DomesticAnimal
├── Pet
└── WorkingAnimal
Requirements for each class:
Animal (ABC): - name, age, weight - abstract: sound(), move(), diet() - concrete: describe(), __str__, __lt__ (by weight) WildAnimal(Animal): - habitat, territory_km2 - is_endangered (bool) - concrete: sound() returns generic wild sound - mark_endangered() Predator(WildAnimal): - prey (list of str) - hunt_success_rate (0-100%) - override sound() — specific predator sound - hunt() — returns description of hunt attempt Herbivore(WildAnimal): - plants_eaten (list of str) - override sound() and move() DomesticAnimal(Animal): - owner, vaccinated (bool) - abstract: purpose() - concrete: vaccinate() Pet(DomesticAnimal): - breed, tricks (list) - override sound() and purpose() - learn_trick(trick) WorkingAnimal(DomesticAnimal): - job, training_level (1-5) - override sound() and purpose() - work() — returns description of work
Expected output:
=== ANIMAL SYSTEM === All animals: Lion (Predator, 5y, 190kg) — savanna — Roar! Elephant (Herbivore, 12y, 4500kg) — savanna — Trumpet! Rex (Pet, 3y, 30kg) — owner: Sergio — Woof! Bolt (WorkingAnimal, 4y, 28kg) — police dog — Woof woof! Wild animals in danger: Lion — endangered ⚠️ Pets and their tricks: Rex knows: sit, shake, roll over Working animals: Bolt — job: police dog, training level: 4/5 Sorted by weight (lightest first): 1. Rex — 30kg 2. Bolt — 28kg ...
💡 Hints:
- Import
ABC, abstractmethodfromabc Animal.__lt__comparesself.weight < other.weightself.__class__.__name__gives the actual class name in__str__Predator.hunt()usesrandom.random() < self.hunt_success_rate/100Pet.learn_trick()appends toself.trickslistisinstance()to filter by type in the demo
Python inheritance exercises — Final Challenge
Exercise 2 — Bank account hierarchy
Design and implement a complete bank account hierarchy. This is the most complex exercise — it requires careful use of super(), abstract methods and polymorphism across four account types.
Required hierarchy:
BankAccount (ABC) ├── StandardAccount │ └── PremiumAccount ├── SavingsAccount └── BusinessAccount
Requirements:
BankAccount (ABC): - account_id, owner, _balance (private) - abstract: account_type(), interest_rate(), monthly_fee() - concrete: deposit(amount), statement() - abstract: withdraw(amount) — each type has different rules - __str__, __eq__ (by account_id), __lt__ (by balance) - apply_monthly_charges() — deducts fee, adds interest StandardAccount(BankAccount): - overdraft_limit (default 0 — cannot go negative) - monthly_fee() → €3.00 - interest_rate() → 0.5% annual - withdraw() — allows overdraft up to limit PremiumAccount(StandardAccount): - monthly_fee() → €0 (waived if balance > €5000, else €10) - interest_rate() → 2.0% annual - withdraw() — calls super() + logs withdrawal SavingsAccount(BankAccount): - monthly_withdrawals counter, max_monthly_withdrawals = 3 - monthly_fee() → €0 - interest_rate() → 3.5% annual - withdraw() — raises ValueError after 3 withdrawals/month - reset_monthly_withdrawals() — resets counter BusinessAccount(BankAccount): - business_name, transaction_fee (per withdrawal) - monthly_fee() → €15.00 - interest_rate() → 1.0% annual - withdraw() — deducts transaction_fee from each withdrawal
Expected output:
=== BANK ACCOUNT HIERARCHY === All accounts: [S001] Sergio — StandardAccount — €1500.00 [P001] María — PremiumAccount — €6000.00 [V001] Carlos — SavingsAccount — €2500.00 [B001] Tech SL — BusinessAccount — €15000.00 --- Sergio (Standard) --- Withdraw €200 → €1300.00 Monthly charges: fee -€3.00, interest +€0.54 → €1297.54 --- María (Premium) --- Withdraw €500 → €5500.00 Monthly fee: €0 (balance above €5000 threshold) --- Carlos (Savings) --- Withdraw €100 → €2400.00 (withdrawal 1/3) Withdraw €200 → €2200.00 (withdrawal 2/3) Withdraw €300 → €1900.00 (withdrawal 3/3) Withdraw €400 → ✗ Monthly withdrawal limit reached (3/3) --- Business (Tech SL) --- Withdraw €1000 → €13985.00 (€5.00 transaction fee applied) Sorted by balance (highest first): 1. Tech SL — €13985.00 2. María — €5500.00+ ... isinstance checks: Standard accounts (including Premium): Sergio, María Savings accounts: Carlos
💡 Hints:
PremiumAccount.monthly_fee():return 0 if self._balance >= 5000 else 10PremiumAccount.withdraw()callssuper().withdraw(amount)then logsSavingsAccount.withdraw()checksself._monthly_withdrawals >= self.max_monthly_withdrawalsbefore proceedingBusinessAccount.withdraw()deductsself.transaction_feein addition toamountapply_monthly_charges()in base class:self._balance -= self.monthly_fee(); self._balance += self._balance * self.interest_rate() / 100 / 12isinstance(account, StandardAccount)returns True for bothStandardAccountANDPremiumAccount
Commented solutions
Solution Exercise 1
from abc import ABC, abstractmethod
import random
class Animal(ABC):
def __init__(self, name, age, weight):
self.name = name
self.age = age
self.weight = weight
@abstractmethod
def sound(self): pass
@abstractmethod
def move(self): pass
@abstractmethod
def diet(self): pass
def describe(self):
return (f'{self.name} ({self.__class__.__name__}, '
f'{self.age}y, {self.weight}kg) — {self.sound()}')
def __str__(self):
return self.describe()
def __lt__(self, other):
return self.weight < other.weight
def __repr__(self):
return f"{self.__class__.__name__}('{self.name}', {self.age})"
class WildAnimal(Animal):
def __init__(self, name, age, weight, habitat, territory_km2):
super().__init__(name, age, weight)
self.habitat = habitat
self.territory_km2 = territory_km2
self.is_endangered = False
def sound(self): return 'Wild sound'
def move(self): return 'moves through the wild'
def diet(self): return 'wild food'
def mark_endangered(self):
self.is_endangered = True
def describe(self):
base = super().describe()
danger = ' ⚠️ endangered' if self.is_endangered else ''
return f'{base} — {self.habitat}{danger}'
class Predator(WildAnimal):
def __init__(self, name, age, weight, habitat, territory_km2,
prey, hunt_success_rate):
super().__init__(name, age, weight, habitat, territory_km2)
self.prey = prey
self.hunt_success_rate = hunt_success_rate
def sound(self): return 'Roar!'
def move(self): return 'stalks prey'
def diet(self): return f'carnivore — hunts: {", ".join(self.prey)}'
def hunt(self):
target = random.choice(self.prey)
if random.random() < self.hunt_success_rate / 100:
return f'{self.name} successfully hunts a {target}!'
return f'{self.name} misses the {target} — tries again later'
class Herbivore(WildAnimal):
def __init__(self, name, age, weight, habitat, territory_km2, plants):
super().__init__(name, age, weight, habitat, territory_km2)
self.plants_eaten = plants
def sound(self): return 'Trumpet!'
def move(self): return 'grazes peacefully'
def diet(self): return f'herbivore — eats: {", ".join(self.plants_eaten)}'
class DomesticAnimal(Animal):
def __init__(self, name, age, weight, owner):
super().__init__(name, age, weight)
self.owner = owner
self.vaccinated = False
def vaccinate(self):
self.vaccinated = True
@abstractmethod
def purpose(self): pass
def describe(self):
base = super().describe()
return f'{base} — owner: {self.owner}'
class Pet(DomesticAnimal):
def __init__(self, name, age, weight, owner, breed):
super().__init__(name, age, weight, owner)
self.breed = breed
self.tricks = []
def sound(self): return 'Woof!'
def move(self): return 'plays'
def diet(self): return 'pet food'
def purpose(self): return 'companion'
def learn_trick(self, trick):
if trick not in self.tricks:
self.tricks.append(trick)
class WorkingAnimal(DomesticAnimal):
def __init__(self, name, age, weight, owner, job, training_level):
super().__init__(name, age, weight, owner)
self.job = job
self._training_level = min(5, max(1, training_level))
def sound(self): return 'Woof woof!'
def move(self): return 'works'
def diet(self): return 'high-performance feed'
def purpose(self): return f'working — {self.job}'
@property
def training_level(self): return self._training_level
def work(self):
return (f'{self.name} performs {self.job} duties '
f'(training level {self._training_level}/5)')
# Demo
print('=== ANIMAL SYSTEM ===\n')
lion = Predator('Lion', 5, 190, 'savanna', 50,
['zebra', 'wildebeest'], 30)
elephant = Herbivore('Elephant', 12, 4500, 'savanna', 200,
['grass', 'leaves', 'bark'])
rex = Pet('Rex', 3, 30, 'Sergio', 'German Shepherd')
bolt = WorkingAnimal('Bolt', 4, 28, 'Police Dept', 'police dog', 4)
lion.mark_endangered()
rex.learn_trick('sit')
rex.learn_trick('shake')
rex.learn_trick('roll over')
all_animals = [lion, elephant, rex, bolt]
print('All animals:')
for a in all_animals:
print(f' {a}')
print('\nWild animals in danger:')
for a in all_animals:
if isinstance(a, WildAnimal) and a.is_endangered:
print(f' {a.name} — endangered ⚠️')
print('\nPets and their tricks:')
for a in all_animals:
if isinstance(a, Pet) and a.tricks:
print(f' {a.name} knows: {", ".join(a.tricks)}')
print('\nWorking animals:')
for a in all_animals:
if isinstance(a, WorkingAnimal):
print(f' {a.name} — job: {a.job}, '
f'training level: {a.training_level}/5')
print('\nSorted by weight (lightest first):')
for i, a in enumerate(sorted(all_animals), 1):
print(f' {i}. {a.name} — {a.weight}kg')
print(f'\nHunt: {lion.hunt()}')
Solution Exercise 2
from abc import ABC, abstractmethod
class BankAccount(ABC):
def __init__(self, account_id, owner, initial_balance=0):
self.account_id = account_id
self.owner = owner
self._balance = initial_balance
self._transactions = []
@property
def balance(self):
return self._balance
@abstractmethod
def account_type(self): pass
@abstractmethod
def interest_rate(self): pass
@abstractmethod
def monthly_fee(self): pass
@abstractmethod
def withdraw(self, amount): pass
def deposit(self, amount):
if amount <= 0:
raise ValueError(f'Deposit must be positive: {amount}')
self._balance = round(self._balance + amount, 2)
self._transactions.append(('deposit', amount, self._balance))
def apply_monthly_charges(self):
fee = self.monthly_fee()
if fee > 0:
self._balance = round(self._balance - fee, 2)
print(f' Monthly fee: -€{fee:.2f}')
monthly_interest = round(
self._balance * self.interest_rate() / 100 / 12, 2
)
if monthly_interest > 0:
self._balance = round(self._balance + monthly_interest, 2)
print(f' Interest ({self.interest_rate()}% annual): '
f'+€{monthly_interest:.2f}')
print(f' Balance after charges: €{self._balance:.2f}')
def statement(self):
print(f'\n[{self.account_id}] {self.owner} — '
f'{self.account_type()} — €{self._balance:.2f}')
def __str__(self):
return (f'[{self.account_id}] {self.owner} — '
f'{self.account_type()} — €{self._balance:.2f}')
def __eq__(self, other):
return (isinstance(other, BankAccount) and
self.account_id == other.account_id)
def __lt__(self, other):
return self._balance < other._balance
class StandardAccount(BankAccount):
def __init__(self, account_id, owner, initial_balance=0,
overdraft_limit=0):
super().__init__(account_id, owner, initial_balance)
self.overdraft_limit = overdraft_limit
def account_type(self): return 'StandardAccount'
def interest_rate(self): return 0.5
def monthly_fee(self): return 3.0
def withdraw(self, amount):
if amount <= 0:
raise ValueError(f'Amount must be positive: {amount}')
if amount > self._balance + self.overdraft_limit:
raise ValueError(
f'Exceeds available funds + overdraft limit: '
f'€{self._balance:.2f} + €{self.overdraft_limit:.2f}'
)
self._balance = round(self._balance - amount, 2)
self._transactions.append(('withdrawal', amount, self._balance))
return self._balance
class PremiumAccount(StandardAccount):
def __init__(self, account_id, owner, initial_balance=0):
super().__init__(account_id, owner, initial_balance,
overdraft_limit=500)
def account_type(self): return 'PremiumAccount'
def interest_rate(self): return 2.0
def monthly_fee(self):
return 0 if self._balance >= 5000 else 10.0
def withdraw(self, amount):
result = super().withdraw(amount)
print(f' Premium withdrawal logged: €{amount:.2f} → €{result:.2f}')
return result
class SavingsAccount(BankAccount):
max_monthly_withdrawals = 3
def __init__(self, account_id, owner, initial_balance=0):
super().__init__(account_id, owner, initial_balance)
self._monthly_withdrawals = 0
def account_type(self): return 'SavingsAccount'
def interest_rate(self): return 3.5
def monthly_fee(self): return 0.0
def withdraw(self, amount):
if amount <= 0:
raise ValueError(f'Amount must be positive: {amount}')
if self._monthly_withdrawals >= self.max_monthly_withdrawals:
raise ValueError(
f'Monthly withdrawal limit reached '
f'({self.max_monthly_withdrawals}/{self.max_monthly_withdrawals})'
)
if amount > self._balance:
raise ValueError(
f'Insufficient funds: €{self._balance:.2f}'
)
self._balance = round(self._balance - amount, 2)
self._monthly_withdrawals += 1
self._transactions.append(('withdrawal', amount, self._balance))
print(f' Withdrawal {self._monthly_withdrawals}/'
f'{self.max_monthly_withdrawals}: '
f'-€{amount:.2f} → €{self._balance:.2f}')
return self._balance
def reset_monthly_withdrawals(self):
self._monthly_withdrawals = 0
class BusinessAccount(BankAccount):
def __init__(self, account_id, business_name, initial_balance=0,
transaction_fee=5.0):
super().__init__(account_id, business_name, initial_balance)
self.business_name = business_name
self.transaction_fee = transaction_fee
def account_type(self): return 'BusinessAccount'
def interest_rate(self): return 1.0
def monthly_fee(self): return 15.0
def withdraw(self, amount):
if amount <= 0:
raise ValueError(f'Amount must be positive: {amount}')
total = amount + self.transaction_fee
if total > self._balance:
raise ValueError(
f'Insufficient funds for withdrawal + fee: '
f'€{amount:.2f} + €{self.transaction_fee:.2f} fee'
)
self._balance = round(self._balance - total, 2)
self._transactions.append(('withdrawal', total, self._balance))
print(f' Business withdrawal: -€{amount:.2f} '
f'(+€{self.transaction_fee:.2f} fee) → €{self._balance:.2f}')
return self._balance
# Demo
print('=== BANK ACCOUNT HIERARCHY ===\n')
sergio = StandardAccount('S001', 'Sergio', 1500)
maria = PremiumAccount('P001', 'María', 6000)
carlos = SavingsAccount('V001', 'Carlos', 2500)
tech = BusinessAccount('B001', 'Tech SL', 15000)
all_accounts = [sergio, maria, carlos, tech]
print('All accounts:')
for acc in all_accounts:
print(f' {acc}')
print('\n--- Sergio (Standard) ---')
sergio.withdraw(200)
print(f' Balance: €{sergio.balance:.2f}')
print(' Applying monthly charges:')
sergio.apply_monthly_charges()
print('\n--- María (Premium) ---')
maria.withdraw(500)
fee = maria.monthly_fee()
print(f' Monthly fee: €{fee:.2f} '
f'({"free — balance above €5000" if fee == 0 else "€10 applied"})')
print('\n--- Carlos (Savings) ---')
for amount in [100, 200, 300, 400]:
try:
carlos.withdraw(amount)
except ValueError as err:
print(f' ✗ €{amount}: {err}')
print('\n--- Tech SL (Business) ---')
tech.withdraw(1000)
print('\nSorted by balance (highest first):')
for i, acc in enumerate(sorted(all_accounts, reverse=True), 1):
print(f' {i}. {acc.owner} — €{acc.balance:.2f}')
print('\nisinstance checks:')
standard_accs = [a for a in all_accounts
if isinstance(a, StandardAccount)]
print(f' Standard (incl. Premium): '
f'{[a.owner for a in standard_accs]}')
savings_accs = [a for a in all_accounts if isinstance(a, SavingsAccount)]
print(f' Savings: {[a.owner for a in savings_accs]}')
Visualise with Python Tutor
Copy this code into pythontutor.com and step through it:
from abc import ABC, abstractmethod
class Account(ABC):
def __init__(self, owner, balance):
self.owner = owner
self._balance = balance
@abstractmethod
def fee(self): pass
def apply_fee(self):
self._balance -= self.fee()
return self._balance
def __str__(self):
return f'{self.owner}: €{self._balance:.2f}'
class Standard(Account):
def fee(self): return 3.0
class Premium(Standard):
def fee(self):
return 0 if self._balance >= 1000 else 5.0
accounts = [Standard('Sergio', 500), Premium('María', 1200)]
for acc in accounts:
print(f'Before: {acc} — fee: €{acc.fee():.2f}')
acc.apply_fee()
print(f'After: {acc}')
print(isinstance(accounts[1], Standard)) # True — Premium IS A Standard
print(isinstance(accounts[0], Premium)) # False
Step through and observe three key moments. When apply_fee() is called on a Premium object, Python looks up fee() starting from the object’s actual class — Premium — and finds it there. For Standard, Python also starts at the actual class and finds Standard.fee(). Even though apply_fee() is defined in Account, self.fee() dispatches to the right subclass implementation — that’s polymorphism in action. Then isinstance(accounts[1], Standard) returns True even though accounts[1] is a Premium — because Premium inherits from Standard. This is the key insight: isinstance checks the entire inheritance chain, not just the immediate class.
Cheat sheet — Python inheritance
# ============================================
# CHEAT SHEET — Python Inheritance
# Sergio Learns · sergiolearns.com
# ============================================
# BASIC INHERITANCE
class Child(Parent):
def __init__(self, child_val, *parent_args):
super().__init__(*parent_args) # always first
self.child_val = child_val
# super() — access parent
super().__init__(args) # parent constructor
super().method() # parent's method version
# METHOD OVERRIDE
class Child(Parent):
def method(self): # replaces parent
result = super().method() # optionally call parent
return result + ' extended'
# ABSTRACT CLASSES
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self): pass # subclasses MUST implement
def describe(self): # concrete — inherited
return f'Area: {self.area()}'
# Shape() → TypeError — cannot instantiate
# Circle(Shape) with area() → OK
# POLYMORPHISM
for obj in [Dog(), Cat(), Duck()]:
print(obj.sound()) # each calls its own implementation
# isinstance() — runtime type check
isinstance(obj, Dog) # True if Dog or any subclass
isinstance(obj, Animal) # True if Dog inherits from Animal
issubclass(Dog, Animal) # class-level check
# MULTIPLE INHERITANCE
class Duck(Animal, Flyable, Swimmable):
pass
Duck.__mro__ # Method Resolution Order
# WHEN TO USE INHERITANCE
# IS-A: Student IS A Person → inheritance
# HAS-A: Car HAS AN Engine → composition
# HOOK METHOD PATTERN
class Base:
def template_method(self):
self._step1()
self._hook() # optional override point
self._step2()
def _hook(self): pass # subclasses override only what they need
# self.__class__.__name__ — actual class name at runtime
# useful in base class __str__ without overriding it everywhere
# COMMON ERRORS
# 1. Forgetting super().__init__() → parent attrs missing
# 2. Not implementing all @abstractmethod → TypeError
# 3. isinstance vs type():
# isinstance(premium, Standard) → True (correct)
# type(premium) == Standard → False (wrong — it's Premium)
# 4. Calling super() after setting self attrs that depend on parent
# 5. Diamond problem in multiple inheritance — use MRO and super()
# ABSTRACT METHOD ENFORCEMENT
# If subclass misses any @abstractmethod:
# TypeError: Can't instantiate abstract class X
# with abstract methods method1, method2

One Comment