Composition vs. Inheritance in Python OOP
In Object-Oriented Programming, two fundamental patterns exist for building relationships between classes and reusing code: Inheritance (the "is-a" relationship) and Composition (the "has-a" relationship). While inheritance is often the first tool developers learn, composition frequently produces cleaner, more flexible, and more maintainable code. Understanding when to choose each is one of the most important design decisions in any OOP system.
Key Takeaways
- Inheritance creates an "is-a" relationship; composition creates a "has-a" relationship
- Inheritance is rigid—the relationship is fixed at design time and cannot change at runtime
- Composition enables flexibility—you can swap components at runtime and build complex objects from simple pieces
- The Python community favors composition over inheritance for most real-world problems
- Avoid deep inheritance hierarchies; they become brittle and difficult to reason about
Understanding Inheritance: The "Is-A" Relationship
Inheritance models a clear hierarchical relationship where a child class is a specific type of the parent class.
How Inheritance Works
A Dog is an Animal. The Dog class inherits all attributes and methods from Animal:
class Animal:
def __init__(self, name):
self.name = name
def make_sound(self):
return "Generic animal sound"
class Dog(Animal):
def make_sound(self):
return "Woof!"
dog = Dog("Buddy")
print(dog.name) # Inherited from Animal
print(dog.make_sound()) # Overridden in Dog
When Inheritance Works Well
- Clear hierarchies: A
Squaretruly is aShape; inheritance fits naturally - Shared behavior: The parent class provides substantial reusable code
- Polymorphism: You want to treat different subclasses uniformly through the parent interface
- "Is-a" is unambiguous: The relationship is clear and unlikely to change
When Inheritance Becomes Problematic
Rigidity: Once defined, the inheritance relationship cannot change at runtime:
class PaymentProcessor:
def process(self, amount):
pass
class CreditCardProcessor(PaymentProcessor):
def process(self, amount):
return f"Processing ${amount} via credit card"
# Problem: What if we need to change from CreditCard to PayPal?
# We cannot. The relationship is locked in at class definition time.
processor = CreditCardProcessor()
Fragile base class problem: Changes to the parent class can silently break children:
class Parent:
def calculate(self):
return self.helper()
def helper(self):
return 10
class Child(Parent):
def helper(self): # Override the helper
return 20
# Later, Parent is refactored and 'helper' is removed or renamed
# Now Child breaks silently, inheriting broken behavior
Deep hierarchies become unmaintainable:
# A real-world anti-pattern: too many levels
Animal -> Mammal -> Carnivore -> Feline -> DomesticCat -> PersianCat
# Understanding what PersianCat does requires reading 6 class definitions!
Understanding Composition: The "Has-A" Relationship
Composition models relationships where one object has another object as a component. Instead of inheriting behavior, a container class delegates tasks to its component objects.
How Composition Works
A Robot doesn't inherit from Arm or Leg. Instead, it has an Arm and two Leg objects:
class GripperArm:
"""A reusable component representing a robot's arm."""
def pick_up(self, item):
return f"Picking up {item} with gripper"
class Leg:
"""A reusable component representing a robot's leg."""
def move_forward(self, distance):
return f"Moving forward {distance} meters"
class Robot:
"""Composed of arm and leg components."""
def __init__(self, name):
self.name = name
# The Robot "has-a" GripperArm and two Legs
self.arm = GripperArm()
self.left_leg = Leg()
self.right_leg = Leg()
def grab(self, item):
"""Delegates the grab task to the arm component."""
print(f"{self.name} is grabbing...")
return self.arm.pick_up(item)
def walk(self, distance):
"""Delegates the walk task to leg components."""
print(f"{self.name} is walking...")
self.left_leg.move_forward(distance)
self.right_leg.move_forward(distance)
# Usage
my_robot = Robot("Bender")
print(my_robot.grab("a shiny object"))
print(my_robot.walk(10))
Advantages of Composition
Flexibility: Components can be swapped at runtime:
class StandardArm:
def pick_up(self, item):
return f"Standard arm picks up {item}"
class PowerArm:
def pick_up(self, item):
return f"POWER arm lifts {item} effortlessly!"
robot = Robot("Bender")
print(robot.grab("box")) # Uses GripperArm
# Upgrade the robot's arm at runtime!
robot.arm = PowerArm()
print(robot.grab("box")) # Now uses PowerArm
Simplicity: Fewer levels of indirection; easier to understand code.
Reusability: Components are independent and can be used in many different contexts.
Avoiding duplication: A component can be reused across many container classes without inheritance chains.
Inheritance vs. Composition: Head-to-Head Comparison
Let's model an employee system using both approaches and see the trade-offs.
Approach 1: Inheritance (Less Flexible)
class Worker:
def work(self):
return "I am working"
class SalariedEmployee(Worker):
def calculate_pay(self, hours=40):
return hours * 50 # Fixed rate
class ContractEmployee(Worker):
def calculate_pay(self, hours):
return hours * 75 # Higher rate, no benefits
# Problem: What if an employee switches from salaried to contract?
# We cannot change their class at runtime. We'd have to recreate the object.
emp = SalariedEmployee()
print(emp.work()) # Works
print(emp.calculate_pay()) # $2000 for 40 hours
# To switch to contract, we'd need:
emp = ContractEmployee() # Lost all previous state!
Approach 2: Composition (More Flexible)
class SalariedPayBehavior:
def calculate_pay(self, hours=40):
return hours * 50
class ContractPayBehavior:
def calculate_pay(self, hours):
return hours * 75
class Employee:
def __init__(self, name, pay_behavior):
self.name = name
self.pay_behavior = pay_behavior # "Has-a" pay behavior
self.hours_worked = 0
def work(self, hours):
self.hours_worked += hours
return f"{self.name} worked {hours} hours"
def calculate_pay(self):
return self.pay_behavior.calculate_pay(self.hours_worked)
# Create an employee with salaried behavior
emp = Employee("Alice", SalariedPayBehavior())
print(emp.work(40))
print(emp.calculate_pay()) # $2000
# Promote Alice to contract work—just swap the behavior!
emp.pay_behavior = ContractPayBehavior()
print(emp.calculate_pay()) # $3000 for same 40 hours
# All state (name, hours_worked) is preserved!
The composition approach allows runtime behavior changes without recreating the object or losing state.
Deciding Between Composition and Inheritance
Ask yourself these questions:
| Question | Answer: Use Inheritance | Answer: Use Composition |
|---|---|---|
| Can the relationship change at runtime? | No (is immutable) | Yes (swap components) |
| Is this a "true type" relationship? | Yes (Cat is an Animal) | No (Robot has an Arm) |
| Will the hierarchy exceed 2-3 levels? | No (keep it shallow) | Yes (build complex systems) |
| Do you need polymorphism across this family? | Yes (treat as parent type) | Maybe (use duck typing instead) |
| Will the parent class change frequently? | No (stable base class) | Yes (independent components) |
Real-World Example: A Flexible Payment System
Here's a practical system that builds flexibility through composition:
class CreditCardPayment:
def process(self, amount):
return f"Charged ${amount} to credit card"
class PayPalPayment:
def process(self, amount):
return f"Sent ${amount} via PayPal"
class BankTransferPayment:
def process(self, amount):
return f"Transferred ${amount} via bank"
class Order:
def __init__(self, amount, payment_method):
self.amount = amount
self.payment_method = payment_method # Flexible component
def checkout(self):
return self.payment_method.process(self.amount)
# Create an order with credit card
order1 = Order(100, CreditCardPayment())
print(order1.checkout())
# Same order object, different payment method—no class change needed!
order1.payment_method = PayPalPayment()
print(order1.checkout())
# Works with any payment method, even ones created later
order1.payment_method = BankTransferPayment()
print(order1.checkout())
Frequently Asked Questions
Should I never use inheritance?
No. Use inheritance when the "is-a" relationship is clear and permanent. Examples: Square is a Shape, Dog is an Animal. But avoid inheritance for loose relationships or when you anticipate runtime changes.
What's the difference between composition and delegation?
Composition is the structural relationship (an object has another object). Delegation is the behavioral pattern (forwarding method calls to the component). Composition is usually implemented via delegation.
Can I combine inheritance and composition?
Yes, and you should when appropriate. Use inheritance for the core "is-a" hierarchy, and composition for cross-cutting behavior:
class Vehicle:
pass
class Car(Vehicle): # Inheritance: Car is a Vehicle
def __init__(self, engine):
self.engine = engine # Composition: Car has an Engine
How do I decide between super() and composition in Python?
If you're using super() to reuse code, consider composition instead. If you're implementing a true type relationship (polymorphism), inheritance with super() is appropriate.
What does "favor composition over inheritance" really mean?
It's a design guideline from the Gang of Four (a seminal OOP book). It means: when in doubt, start with composition. Only use inheritance when the relationship is unambiguously "is-a" and will be stable.