Python inheritance exercises solutions super polymorphism hierarchy cheat sheet

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.


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, abstractmethod from abc
  • Animal.__lt__ compares self.weight < other.weight
  • self.__class__.__name__ gives the actual class name in __str__
  • Predator.hunt() uses random.random() < self.hunt_success_rate/100
  • Pet.learn_trick() appends to self.tricks list
  • isinstance() 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 10
  • PremiumAccount.withdraw() calls super().withdraw(amount) then logs
  • SavingsAccount.withdraw() checks self._monthly_withdrawals >= self.max_monthly_withdrawals before proceeding
  • BusinessAccount.withdraw() deducts self.transaction_fee in addition to amount
  • apply_monthly_charges() in base class: self._balance -= self.monthly_fee(); self._balance += self._balance * self.interest_rate() / 100 / 12
  • isinstance(account, StandardAccount) returns True for both StandardAccount AND PremiumAccount

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

Similar Posts

One Comment

Leave a Reply

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