Python · lesson 17 of 34
Beginner
16- 1 Introduction to Python
- 2 Setting Up Python
- 3 Python Variables and Type System
- 4 Python Data Types
- 5 Python Operators
- 6 Python Strings
- 7 Control Flow in Python
- 8 Python Functions and Parameters
- 9 Python Data Structures
- 10 Python Collections
- 11 Python Modules and Packages
- 12 Object-Oriented Programming in Python
- 13 Encapsulation and Name Mangling in Python
- 14 Polymorphism and Duck Typing in Python
- 15 Abstraction with Abstract Base Classes in Python
- 21 Building a Python CLI Tool
Intermediate
12- 16 Multiple Inheritance and MRO in Python
- 17 Composition vs Inheritance in Python
- 18 Python Dataclasses
- 19 Error Handling in Python
- 20 File I/O, pathlib, and JSON in Python
- 22 Python Decorators and Closures
- 23 Python Generators
- 24 Python Context Managers
- 25 Python Concurrency
- 27 Testing Python Code
- 28 Python Security
- 34 Python Interview Prep
Advanced
6Composition vs Inheritance in Python
Compare Python's has-a composition against is-a inheritance, and see how composition avoids the fragile base class problem.
By SkillByExample · Updated
Inheritance asks “is this a specialized version of that?” Composition asks “does this own one of those?” Reaching for inheritance by default, even when the relationship is really “has-a,” leads to classes that expose more than they should. Output below is from Python 3.13.
Has-a: Building With Composition
Car doesn’t become an Engine — it owns one, and delegates to it.
class Engine:
def __init__(self, horsepower):
self.horsepower = horsepower
def start(self):
return f"Engine ({self.horsepower} hp) starting..."
class Car:
def __init__(self, make, horsepower):
self.make = make
self.engine = Engine(horsepower) # "has-a" - Car owns an Engine, doesn't become one
def start(self):
return f"{self.make}: {self.engine.start()}"
car = Car("Tata", 120)
print(car.start())
# Swap the composed part at runtime - impossible with inheritance, trivial with composition
car.engine = Engine(300)
print(car.start())
$ python composition.py
Tata: Engine (120 hp) starting...
Tata: Engine (300 hp) starting...
Car only exposes what it chooses to — here, start(). Swapping car.engine for a different Engine instance changes behavior instantly, with no new class and no subclassing.
The Fragile Base Class Problem
Inheriting from a built-in like list to reuse its behavior looks convenient, but it inherits everything, including methods that break the abstraction you were trying to build.
class Stack(list): # inheritance: Stack "is-a" list - inherits EVERYTHING
def push(self, item):
self.append(item)
s = Stack()
s.push(1)
s.push(2)
s.insert(0, 99) # list's insert() leaked through - breaks stack discipline
print(list(s))
class SafeStack: # composition: SafeStack "has-a" list, exposes only what it wants
def __init__(self):
self._items = []
def push(self, item):
self._items.append(item)
def pop(self):
return self._items.pop()
def __repr__(self):
return f"SafeStack({self._items})"
safe = SafeStack()
safe.push(1)
safe.push(2)
print(safe)
print(hasattr(safe, "insert")) # no leaked list methods to misuse
$ python fragile_base.py
[99, 1, 2]
SafeStack([1, 2])
False
Stack(list) was supposed to only support push, but subclassing list inherited insert(), sort(), remove(), and everything else list has — nothing stops a caller from using insert(0, ...) to violate stack order. SafeStack wraps a list internally instead of becoming one, so it only offers push/pop — hasattr(safe, "insert") confirms nothing extra leaked through.
Practice
1. Stack(list) already inherits list.pop() for free. Call it, then confirm it also inherits list.reverse(), another method a real stack shouldn’t expose.
Solution
class Stack(list):
def push(self, item):
self.append(item)
s = Stack()
s.push(1)
s.push(2)
print(s.pop()) # inherited list.pop() happens to behave like a stack
s.push(3)
s.push(4)
s.reverse() # also inherited, and definitely not stack behavior
print(list(s))
$ python leaked_methods.py
2
[4, 3, 1]
pop() works by luck: list.pop() removes the last item, which is what a stack wants. reverse() comes along too and silently flips the order, so the item that was on top is now at the bottom.
2. Give Car a second composed part, Transmission, and have start() report both.
Solution
class Engine:
def __init__(self, horsepower):
self.horsepower = horsepower
def start(self):
return f"Engine ({self.horsepower} hp) starting..."
class Transmission:
def __init__(self, kind):
self.kind = kind
class Car:
def __init__(self, make, horsepower, transmission_kind):
self.make = make
self.engine = Engine(horsepower)
self.transmission = Transmission(transmission_kind)
def start(self):
return f"{self.make}: {self.engine.start()} ({self.transmission.kind} transmission)"
print(Car("Tata", 120, "automatic").start())
$ python transmission.py
Tata: Engine (120 hp) starting... (automatic transmission)
A car “has an” engine and “has a” transmission; neither relationship is “is-a”, so each becomes an attribute rather than a base class.
3. Give SafeStack a __len__ and an is_empty() method, keeping the same “only expose what’s needed” discipline.
Solution
class SafeStack:
def __init__(self):
self._items = []
def push(self, item):
self._items.append(item)
def pop(self):
return self._items.pop()
def is_empty(self):
return len(self._items) == 0
def __len__(self):
return len(self._items)
s = SafeStack()
print(s.is_empty(), len(s))
s.push(1)
print(s.is_empty(), len(s))
$ python safe_stack_len.py
True 0
False 1
Defining __len__ is what lets the built-in len() work on the wrapper. Each capability is opted into explicitly, unlike the subclass, which got all of list at once.
4. Give a new ElectricMotor class the same start() method as Engine, then show Car works with either one interchangeably.
Solution
class Engine:
def __init__(self, horsepower):
self.horsepower = horsepower
def start(self):
return f"Engine ({self.horsepower} hp) starting..."
class ElectricMotor:
def __init__(self, kwh):
self.kwh = kwh
def start(self):
return f"Electric motor ({self.kwh} kWh) silently starting..."
class Car:
def __init__(self, make, horsepower):
self.make = make
self.engine = Engine(horsepower)
def start(self):
return f"{self.make}: {self.engine.start()}"
car = Car("Tata", 120)
print(car.start())
car.engine = ElectricMotor(75) # Car never checks the type, only that .start() exists
print(car.start())
$ python electric.py
Tata: Engine (120 hp) starting...
Tata: Electric motor (75 kWh) silently starting...
Car never inspects what self.engine is; it only calls .start(). That is duck typing from the polymorphism lesson, and it is why composed parts are easy to swap.