Inheritance and Composition
Last updated on 2026-06-26 | Edit this page
Estimated time: 12 minutes
Overview
Questions
- How does Composition differ from Inheritance?
- When should I use Composition over Inheritance?
Objectives
- Explain the difference between Inheritance and Composition
- Use Composition to build classes that contain instances of other classes
Composition
In the previous episode, we saw how to use Inheritance to create a specialized version of an existing class. But there’s another strategy we can use to build classes: Composition. Composition is a design principle where a class is composed of one or more objects from other classes, rather than inheriting from them. This allows us to create complex functionality by combining several smaller, simpler classes.
Back to the Bank Account Example
Let’s revisit our BankAccount example from the Class
Objects episode. As a reminder, our class looked like this:
But what if we wanted to add more functionality to our
BankAccount class? We talked about in the previous episode
how we could use Inheritance to create specialized versions of the
BankAccount class, like this:

The code might look something like this:
PYTHON
class BankAccount:
# Static property to keep track of the next available account number
next_account_number = 10000
def __init__(self, account_holder, balance = 0.0, interest_calculator = None):
self.account_holder = account_holder
BankAccount.next_account_number += 1
self.account_number = BankAccount.next_account_number
self._balance = balance
# ... the rest of the BankAccount class ...
class SavingsAccount(BankAccount):
def __init__(self, account_holder, balance = 0.0, interest_rate = 0.01):
super().__init__(account_holder=account_holder, balance=balance)
self.interest_rate = interest_rate
def apply_interest(self):
self._balance += self._balance * self.interest_rate
class CheckingAccount(BankAccount):
def __init__(self, account_holder, balance = 0.0, overdraft_limit = 500.0):
super().__init__(account_holder=account_holder, balance=balance)
self.overdraft_limit = overdraft_limit
def withdraw(self, amount):
if self._balance - amount < -self.overdraft_limit:
raise ValueError("Withdrawal would exceed overdraft limit.")
self._balance -= amount
But what happens if we start adding lots more kinds of accounts? Or
if our accounts start getting more complex, with different properties
and methods? What if we have a BusinessAccount that has
different rules for interest and overdraft, or a
StudentAccount that has different rules for fees? We could
start creating a whole hierarchy of classes, like
BankAccount -> SavingsAccount -> BusinessSavingsAccount,
but this can quickly become unwieldy and hard to maintain. This is where
Composition comes in.
Instead, we can use a Compositional approach by making a new kind of
class called InterestCalculator, and then including an
instance of that class as a property of the BankAccount
class. This way, we can create different kinds of interest calculators
as separate classes, and then use them in our BankAccount
class without having to create a new subclass for each one. Here’s how
that would look:
Need to remake this image 
PYTHON
class BankAccount:
# Static property to keep track of the next available account number
next_account_number = 10000
def __init__(self, account_holder, balance = 0.0, interest_calculator = None):
self.account_holder = account_holder
BankAccount.next_account_number += 1
self.account_number = BankAccount.next_account_number
self._balance = balance
self.interest_calculator = interest_calculator or InterestCalculator()
def deposit(self, amount):
if amount > 0:
self.balance += amount
else:
raise ValueError("Deposit amount must be positive")
def withdraw(self, amount):
if amount > 0:
if self.balance >= amount:
self.balance -= amount
else:
raise ValueError("Insufficient funds")
else:
raise ValueError("Withdrawal amount must be positive")
def get_balance(self):
return self.balance
def apply_interest(self): # This method is now a part of the BankAccount
self.balance = self.interest_calculator.calculate_interest(self.balance)
class InterestCalculator:
def __init__(self, interest_rate = 0.00):
self.interest_rate = interest_rate
def calculate_interest(self, balance):
return balance + (balance * self.interest_rate)
At first glance this might look even more complicated, but it has several advantages:
-
Separation of Concerns: The
InterestCalculatorclass is responsible for interest-specific behavior, while theBankAccountclass focuses on account-specific behavior. This makes the code easier to understand and maintain. -
Reusability: The
InterestCalculatorclass can be reused in other contexts - we can create accounts that use different interest calculators without having to create new subclasses ofBankAccount. -
Flexibility: We can easily add new types of
interest calculators by creating new subclasses of
InterestCalculator, without having to modify theBankAccountclass or create new subclasses ofBankAccount.
Think about if we wanted to add more functionality to our
BankAccount class, like certain accounts that might have
different rules for overdraft, or have additional features, like fraud
detection or transaction history. We could create new classes for each
of these features, and then include them as properties of the
BankAccount class, rather than creating a new subclass for
each combination of features:
PYTHON
class BankAccount:
next_account_number = 10000
def __init__(
self,
account_holder,
balance=0.0,
interest_calculator=None,
overdraft_protection=None,
transaction_logger=None,
fraud_detector=None,
):
BankAccount.next_account_number += 1
self.account_number = BankAccount.next_account_number
self.account_holder = account_holder
self._balance = balance
self.interest_calculator = interest_calculator or InterestCalculator()
self.overdraft_protection = overdraft_protection or OverdraftProtection()
self.transaction_logger = transaction_logger or TransactionLogger()
self.fraud_detector = fraud_detector or FraudDetector()
# ... the rest of the BankAccount class ...
class InterestCalculator:
def __init__(self, interest_rate = 0.00):
self.interest_rate = interest_rate
def calculate_interest(self, balance):
return balance + (balance * self.interest_rate)
class OverdraftProtection:
def __init__(self, limit=500.0):
self.limit = limit
def allows_withdrawal(self, balance, amount):
return (balance - amount) >= -self.limit
class FraudDetector:
def __init__(self, max_single_transaction=10_000.0):
self.max_single_transaction = max_single_transaction
def is_suspicious(self, amount):
return amount > self.max_single_transaction
class TransactionLogger:
def __init__(self, active=True):
self.active = active
self.history = []
def log(self, action, amount):
if self.active:
self.history.append(f"{action}: ${amount:.2f}")
Then, when it comes time to create a new BankAccount, we
can choose which features we want to include and how they should behave,
without having to create a new subclass for every combination of
features:
PYTHON
basic_account = BankAccount(account_holder="Michael", balance=100.0)
savings_account = BankAccount(
account_holder="Christian",
balance=1000.0,
interest_calculator=InterestCalculator(interest_rate=0.02),
)
premium_account = BankAccount(
account_holder="Heath",
balance=5000.0,
interest_calculator=InterestCalculator(interest_rate=0.05),
overdraft_protection=OverdraftProtection(limit=1000.0),
transaction_logger=TransactionLogger(active=True),
fraud_detector=FraudDetector(max_single_transaction=50_000.0),
)
This also makes it much easier to test our BankAccount
class, since we can create mock versions of the other classes and pass
them in, allowing us to isolate the BankAccount class and
test its behavior without having to worry about how it interacts with
the other classes. Likewise, this means that we can isolate and test the
behaviors of the other classes without having to worry about how they
interact with the BankAccount class.
Finally, this also helps with organizing code. Each class can be in its own file, and we can group related classes together in a directory.
Need to remake this image 
Refactoring our Car Example
Let’s take this concept and apply it to our Car example
from previous episodes. We can create a new class called
Engine that is responsible for handling the engine-specific
noises, and then include an instance of that as a property of the
Car class. This way, we can create cars with different
kinds of engines without having to create a new subclass for each
one;
To start with, let’s create a directory for our engines called
src/vehicle_module/engines, and then create a base class
called BaseEngine in a file called
engines/base_engine.py. This will be our “abstract base
class” for all engines, and will define for us the exact methods that
all engines must implement:
PYTHON
from abc import ABC, abstractmethod
class BaseEngine(ABC):
def __init__(self, acceleration, top_speed, fuel_type):
self.acceleration = acceleration
self.top_speed = top_speed
self.fuel_type = fuel_type
@abstractmethod
def make_engine_noise(self, rpm):
pass
Next we’ll make a pair of engine classes that inherit from
BaseEngine, one for a GasEngine and one for an
ElectricEngine. We’ll put these in files called
engines/gas_engine.py and
engines/electric_engine.py, respectively:
engines/gas_engine.py
PYTHON
from .base_engine import BaseEngine
class GasEngine(BaseEngine):
def __init__(self, acceleration=20, top_speed=100, fuel_type="gasoline"):
super().__init__(acceleration, top_speed, fuel_type)
def make_engine_noise(self):
return "putt putt"
engines/electric_engine.py
PYTHON
from .base_engine import BaseEngine
class ElectricEngine(BaseEngine):
def __init__(self, acceleration=50, top_speed=100, fuel_type="electric"):
super().__init__(acceleration, top_speed, fuel_type)
def make_engine_noise(self):
return "hmmmmmm!"
Now, in our Car class, we can include an instance of
BaseEngine as a property, and then use that to make the
engine noise:
PYTHON
class Car(Vehicle):
def __init__(self, make, model, year, color="grey", engine=None):
super().__init__()
self.make = make
self.model = model
self.year = year
self.color = color
self.engine = engine or GasEngine()
self.handling = 2 # Add this in case you don't get through the challenges!
def honk_horn(self):
return "Honk! Honk!"
def paint(self, new_color):
self.color = new_color
def make_engine_noise(self):
return self.engine.make_engine_noise()
def __str__(self):
return f"A {self.color} {self.year} {self.make} {self.model} that runs on {self.engine.fuel_type}."
@property
def age(self):
current_year = datetime.now().year
return current_year - self.year
@property
def fuel(self):
return self.engine.fuel_type
@property
def glyph_file(self):
return "car.glyph"
Finally, we can remove the GasolineCar and
ElectricCar classes from our car.py file - if
we want to create a gasoline car, we can just create a Car
instance with a GasEngine, and if we want to create an
electric car, we can create a Car instance with an
ElectricEngine
Revising our Tests
Making this change means that our tests will need to be updated as
well. To start with, we can remove the tests for the
GasolineCar and ElectricCar classes, since
those classes no longer exist.
Mocking The Engine
Of the things we test in our Car, the two things that
now rely on the engine are the make_engine_noise method and
the fuel property. We can easily test this by creating a
mock engine class just for our tests that looks exactly like an engine
implementation, but returns exactly the values we want to test:
PYTHON
class MockEngine(BaseEngine):
def __init__(self, acceleration=999, top_speed=9999, fuel_type="TEST FUEL"):
super().__init__(acceleration, top_speed, fuel_type)
def make_engine_noise(self):
return "TEST NOISE"
This MockEngine class will slot into our Car class exactly like our real engines, but we can limit the behavior to exactly what we want to test. This allows us to isolate the Car class and test its behavior without having to worry about how it interacts with the engine classes.
PYTHON
class MockEngine(BaseEngine):
def __init__(self, acceleration=999, top_speed=9999, fuel_type="TEST FUEL"):
super().__init__(acceleration, top_speed, fuel_type)
def make_engine_noise(self):
return "TEST NOISE"
@pytest.fixture
def my_car():
return Car(make="Toyota", model="Corolla", year=2020, engine=MockEngine())
def test_car(my_car):
assert my_car.make == "Toyota"
assert my_car.model == "Corolla"
assert my_car.year == 2020
assert my_car.color == "grey"
assert my_car.fuel == "TEST FUEL"
def test_car_noises(my_car):
assert my_car.honk_horn() == "Honk! Honk!"
assert my_car.make_engine_noise() == "TEST NOISE"
Testing The Engines
Now, we can create a separate test file for our engines, testing the
behavior of the GasEngine and ElectricEngine
classes without having to worry about how they interact with the
Car class:
tests/engines/test_gas_engine.py
PYTHON
from vehicle_module.engines.gas_engine import GasEngine
def test_gas_engine():
engine = GasEngine(acceleration=30, top_speed=150, fuel_type="gasoline")
assert engine.acceleration == 30
assert engine.top_speed == 150
assert engine.fuel_type == "gasoline"
assert engine.make_engine_noise() == "putt putt"
tests/engines/test_electric_engine.py
PYTHON
from vehicle_module.engines.electric_engine import ElectricEngine
def test_electric_engine():
engine = ElectricEngine(acceleration=10, top_speed=120, fuel_type="electricity")
assert engine.acceleration == 10
assert engine.top_speed == 120
assert engine.fuel_type == "electricity"
assert engine.make_engine_noise() == "hmmmmmm!"
Key Points
Ok, that’s a lot of changes. So what was that all about?
- Modularity: Our code is now made up of smaller, more focused classes. The code responsible for doing engine things is separate from the code that represents a car.
-
Extensibility: We can easily add support for new
engine types by creating new engine classes that inherit from
BaseEngine, without having to modify theCarclass. - Maintainability: Each class has a single responsibility, making it easier to understand and maintain.
- Reusability: The engine classes can be reused in other contexts, such as in a different application that needs to simulate vehicles.
Challenge 1: Writing a New Engine
We want to add two new engine types to our project:
DieselEngine and HybridEngine that inherit
from BaseEngine. Write them so that the pass the following
tests:
src/vehicle_module/engines/diesel_engine.py
PYTHON
import pytest
from vehicle_module.engines.diesel_engine import DieselEngine
@pytest.fixture
def my_diesel_engine():
return DieselEngine(acceleration=15, top_speed=80, fuel_type="diesel")
def test_diesel_engine(my_diesel_engine):
assert my_diesel_engine.acceleration == 15
assert my_diesel_engine.top_speed == 80
assert my_diesel_engine.fuel_type == "diesel"
assert my_diesel_engine.make_engine_noise() == "grrrrrr"
src/vehicle_module/engines/hybrid_engine.py
PYTHON
import pytest
from vehicle_module.engines.hybrid_engine import HybridEngine
@pytest.fixture
def my_hybrid_engine():
return HybridEngine(acceleration=20, top_speed=100, fuel_type="electric")
def test_hybrid_engine(my_hybrid_engine):
assert my_hybrid_engine.acceleration == 20
assert my_hybrid_engine.top_speed == 100
assert my_hybrid_engine.fuel_type == "electric"
assert my_hybrid_engine.make_engine_noise() == "hmmmmmm"
def test_hybrid_engine_switch_mode(my_hybrid_engine):
assert my_hybrid_engine.fuel_type == "electric"
my_hybrid_engine.switch_mode()
assert my_hybrid_engine.fuel_type == "gasoline"
def test_hybrid_engine_mode_engine_noise(my_hybrid_engine):
assert my_hybrid_engine.make_engine_noise() == "hmmmmmm"
my_hybrid_engine.switch_mode()
assert my_hybrid_engine.make_engine_noise() == "putt putt"
For the HybridEngine, you will need to implement a
method called switch_mode that changes the
fuel_type property from “electric” to “gasoline” and vice
versa. The make_engine_noise method will then need to
return different values depending on the current
fuel_type.
src/vehicle_module/engines/diesel_engine.py
PYTHON
from .base_engine import BaseEngine
class DieselEngine(BaseEngine):
def __init__(self, acceleration=15, top_speed=80, fuel_type="diesel"):
super().__init__(acceleration, top_speed, fuel_type)
def make_engine_noise(self):
return "grrrrrr"
src/vehicle_module/engines/hybrid_engine.py
PYTHON
from .base_engine import BaseEngine
class HybridEngine(BaseEngine):
def __init__(self, acceleration=5, top_speed=40, fuel_type="hybrid"):
super().__init__(acceleration, top_speed, fuel_type)
def make_engine_noise(self):
if self.fuel_type == "electric":
return "hmmmmmm"
else:
return "putt putt"
def switch_mode(self):
if self.fuel_type == "electric":
self.fuel_type = "gasoline"
else:
self.fuel_type = "electric"
Challenge 2: Creating a New Car Component
In addition to engines, we want to add a new component to our
Car class: a Wheel class that represents the
wheels of the car.
- Create a new abstract base class called
BaseWheelthat defines the interface for all wheels. It should have the following properties and methods:-
pressure: The air pressure in the wheels (in BAR) -
material: The material of the wheel (e.g., rubber, alloy)
-
- The
BaseWheelclass should implement the following method:
PYTHON
def get_handling_score(self):
# Ideal pressure is 2.3 BAR, so the score is based off of how far the current pressure is from 2.3.
pressure_score = abs(self.pressure - 2.3) / 2.3
# The handling score is a 50/50 mix of the pressure score and tread depth.
handling = (1 - pressure_score) * 0.5 + (self.tread / 10) * 0.5
# Ensure that handling is between 0 and 3
handling = min(3, round(handling * 3))
return handling
- Create two subclasses of
BaseWheel:RacingWheelandOffRoadWheel. You can set the default values for pressure and tread in the constructors of these classes. - Create a test file for
src/vehicle_module/wheelsthat tests the behavior of theBaseWheel. Ensure that “pressure” is a positive floating point number, and that tread is an integer between 0 and 10, and that it raises a ValueError if the values are incorrect. - Add tests for
get_handling_scoreto ensure that it always returns a value between 0 and 3.
src/vehicle_module/wheels/base_wheel.py
PYTHON
from abc import ABC, abstractmethod
class BaseWheels(ABC):
def __init__(self, pressure, tread):
if not isinstance(pressure, (int, float)) or pressure <= 0:
raise ValueError("Tire pressure must be a positive number.")
if not isinstance(tread, int) or not (1 <= tread <= 10):
raise ValueError("Tread must be an integer between 1 and 10.")
self.pressure = pressure
self.tread = tread
def get_handling_score(self):
# Ideal pressure is 2.3 BAR, so the score is based off of how far the current pressure is from 2.3.
pressure_score = abs(self.pressure - 2.3) / 2.3
# The handling score is a 50/50 mix of the pressure score and tread depth.
handling = (1 - pressure_score) * 0.5 + (self.tread / 10) * 0.5
# Ensure that handling is between 0 and 3
handling = min(3, round(handling * 3))
return handling
src/vehicle_module/wheels/racing_wheel.py
PYTHON
from .base_wheel import BaseWheels
class RacingWheel(BaseWheels):
def __init__(self, pressure=2.5, tread=8):
super().__init__(pressure, tread)
src/vehicle_module/wheels/off_road_wheel.py
PYTHON
from .base_wheel import BaseWheels
class OffRoadWheel(BaseWheels):
def __init__(self, pressure=2.0, tread=10):
super().__init__(pressure, tread)
tests/wheels/test_base_wheel.py
PYTHON
import pytest
from vehicle_module.wheels.base_wheel import BaseWheels
def test_base_wheel_valid_initialization():
wheel = BaseWheels(pressure=2.5, tread=8)
assert wheel.pressure == 2.5
assert wheel.tread == 8
def test_base_wheel_invalid_pressure():
with pytest.raises(ValueError):
BaseWheels(pressure=-1, tread=5)
with pytest.raises(ValueError):
BaseWheels(pressure=0, tread=5)
with pytest.raises(ValueError):
BaseWheels(pressure="high", tread=5)
def test_base_wheel_invalid_tread():
with pytest.raises(ValueError):
BaseWheels(pressure=2.5, tread=0)
with pytest.raises(ValueError):
BaseWheels(pressure=2.5, tread=11)
with pytest.raises(ValueError):
BaseWheels(pressure=2.5, tread="deep")
def test_get_handling_score():
wheel = BaseWheels(pressure=2.3, tread=10)
assert 0 <= wheel.get_handling_score() <= 3
wheel = BaseWheels(pressure=1.0, tread=5)
assert 0 <= wheel.get_handling_score() <= 3
wheel = BaseWheels(pressure=3.0, tread=1)
assert 0 <= wheel.get_handling_score() <= 3
- Composition allows us to build complex functionality by combining several smaller, simpler classes
- Composition promotes separation of concerns, reusability, flexibility, and maintainability
- By using abstract base classes, we can define interfaces that subclasses must implement, allowing for flexibility in our code design