Menu

Inheritance vs. Composition in Python- When to Use Which

Join thousands of students who advanced their careers with MachineLearningPlus. Go from Beginner to Data Science (AI/ML/Gen AI) Expert through a structured pathway of 9 core specializations and build industry grade projects.

Inheritance means creating new classes based on existing ones, where the new class automatically gets all the methods and attributes from the parent class. Composition means building classes by combining other objects, where you create instances of other classes and use them as components inside your class.

If you’ve been writing Python classes, you’ve probably encountered situations where you need to reuse code or share functionality between different classes.

There are two main ways to achieve this in object-oriented programming: inheritance and composition. Understanding these concepts is fundamental to writing clean, maintainable Python code.

The concepts of inheritance and composition come from object-oriented programming principles established in the 1960s. This principle emerged because developers kept running into the same problems – inheritance hierarchies that grew too complex, classes that were tightly coupled, and code that was brittle to change.

1. Understanding Inheritance

Inheritance lets you create a new class based on an existing class. The new class gets all the methods and attributes from the parent class.

Let me show you with a simple example

Let’s create a parent class called Vehicle with basic attributes like brand and model, then create child classes that inherit from it to represent specific vehicle types.

# Base vehicle class
class Vehicle:
    def __init__(self, brand, model):
        self.brand = brand
        self.model = model
    
    def start(self):
        print(f"{self.brand} {self.model} is starting")
    
    def stop(self):
        print(f"{self.brand} {self.model} has stopped")

Now let’s create a child class called Car that inherits from the Vehicle parent class, adding car-specific features while keeping all the basic vehicle functionality.

# Car inherits from Vehicle
class Car(Vehicle):
    def __init__(self, brand, model, doors):
        super().__init__(brand, model)
        self.doors = doors
    
    def honk(self):
        print(f"{self.brand} {self.model} is honking!")

The Car class inherits everything from Vehicle and adds car-specific functionality. This is inheritance – Car “is-a” Vehicle.

# Test the inheritance
my_car = Car("Toyota", "Camry", 4)
my_car.start()  # Inherited method
my_car.honk()   # Car-specific method
my_car.stop()   # Inherited method
Toyota Camry is starting
Toyota Camry is honking!
Toyota Camry has stopped

2. Understanding Composition

Composition means building classes by including other objects as attributes. Instead of inheriting, you create instances of other classes inside your class.

Let’s create two separate component classes, Engine for powering vehicles and GPS for navigation. These will be building blocks that we’ll combine later.

# Component classes
class Engine:
    def __init__(self, horsepower):
        self.horsepower = horsepower
        self.running = False
    
    def start(self):
        self.running = True
        print(f"Engine with {self.horsepower}HP started")
    
    def stop(self):
        self.running = False
        print("Engine stopped")

class GPS:
    def navigate_to(self, destination):
        print(f"Navigating to {destination}")

These are our building blocks. Each component class handles one specific responsibility – Engine manages power, GPS handles navigation.

Now let’s create an SmartCar class that uses composition by containing both an Engine object and a GPS object, rather than inheriting from them.

# SmartCar using composition
class SmartCar:
    def __init__(self, brand, horsepower):
        self.brand = brand
        # Composition: SmartCar HAS an Engine and HAS a GPS
        self.engine = Engine(horsepower)
        self.gps = GPS()
    
    def start_journey(self, destination):
        self.engine.start()
        self.gps.navigate_to(destination)
        print(f"{self.brand} is ready to go!")
    
    def end_journey(self):
        self.engine.stop()
        print(f"{self.brand} journey completed")

Here’s where composition shines. The SmartCar class doesn’t inherit from Engine or GPS, it contains instances of them and delegates specific tasks to specialized objects.

# Test composition
smart_car = SmartCar("Tesla", 400)
smart_car.start_journey("San Francisco")
smart_car.end_journey()
Engine with 400HP started
Navigating to San Francisco
Tesla is ready to go!
Engine stopped
Tesla journey completed

3. Python’s Object Super Class and Duck Typing

Every class in Python automatically inherits from object. This is why you can use methods like __str__() even when you don’t define them.

Let’s see how these inherited methods work in practice by creating a simple User class and using methods we never defined

# Simple class that doesn't define __str__ or __repr__
class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email

# Create a user and use inherited methods
user = User("John", "[email protected]")

# These methods come from object class, not our User class
print(str(user))        # Uses inherited __str__()
print(repr(user))       # Uses inherited __repr__()
print(user.__class__)   # Uses inherited __class__
<__main__.User object at 0x000002AADABBB690>
<__main__.User object at 0x000002AADABBB690>
<class '__main__.User'>

Python also supports “duck typing” – if it walks like a duck and quacks like a duck, it’s a duck. This means you don’t need formal inheritance to use objects interchangeably.

Consider a Duck class and Robot class both having a make_sound() method. Even though they’re completely unrelated classes, they can be used interchangeably because they implement the same interface, demonstrating Python’s duck typing.

# Two completely different classes with the same interface
class Duck:
    def make_sound(self):
        return "Quack!"

class Robot:
    def make_sound(self):
        return "Beep!"

# They can be used interchangeably
animals = [Duck(), Robot()]
for animal in animals:
    print(animal.make_sound())  # Works for both!
Quack!
Beep!

This flexibility is one of Python’s strengths, but it also means we need to be thoughtful about when to use inheritance versus composition.

4. When to Use Inheritance

Use inheritance when you have a clear “is-a” relationship. Here are the key scenarios:

Scenario 1: Natural Hierarchies

This scenario shows inheritance at its best. Consider modeling different types of files that share common behavior but have specialized implementations.

Let’s create a parent class called File with attributes like filename and size.

# Base file class
class File:
    def __init__(self, filename, size):
        self.filename = filename
        self.size = size
    
    def get_info(self):
        return f"File: {self.filename} ({self.size} KB)"
    
    def process(self):
        raise NotImplementedError("Subclass must implement")

Now let’s create two child classes: TextFile and ImageFile that both inherit from the File parent class, each implementing their own version of the process method.

class TextFile(File):
    def __init__(self, filename, size, encoding="utf-8"):
        super().__init__(filename, size)
        self.encoding = encoding
    
    def process(self):
        return f"Reading text file {self.filename} with {self.encoding} encoding"

class ImageFile(File):
    def __init__(self, filename, size, format_type):
        super().__init__(filename, size)
        self.format_type = format_type
    
    def process(self):
        return f"Processing {self.format_type} image {self.filename}"

Notice how both derived classes override the process() method to provide their own specific implementation while still inheriting the basic file attributes.

# Testing the file hierarchy
text_file = TextFile("document.txt", 150, "utf-8")
image_file = ImageFile("photo.jpg", 2048, "JPEG")

print(text_file.get_info())  # Inherited method
print(text_file.process())   # TextFile-specific method

print(image_file.get_info()) # Inherited method  
print(image_file.process())  # ImageFile-specific method
File: document.txt (150 KB)
Reading text file document.txt with utf-8 encoding
File: photo.jpg (2048 KB)
Processing JPEG image photo.jpg

This works because TextFile “is-a” File and ImageFile “is-a” File. They share common behavior but have specialized implementations.

Scenario 2: Abstract Base Classes

What are Abstract Classes? Think of an abstract class as a blueprint or template that defines what methods a class must have, but doesn’t implement them. It’s like saying “every shape must have an area() method, but I won’t tell you how to calculate it – each specific shape will figure that out.” You can’t create objects directly from abstract classes – you must create child classes that implement the required methods first.

We’ll learn more on this in the next session.

Let’s create a simple abstract parent class called Shape that requires all shapes to have an area() method, then create concrete child classes Circle and Rectangle that must implement this method.

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        pass  # Child classes must implement this

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius
    
    def area(self):
        return 3.14159 * self.radius ** 2

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height
    
    def area(self):
        return self.width * self.height

Both concrete classes must implement the abstract methods from Shape. This guarantees that any Shape object will have an area() method.

# You can't instantiate the abstract base class
# shape = Shape()  # This would raise TypeError

# But you can create concrete shapes
circle = Circle(5)
rectangle = Rectangle(4, 6)

print(f"Circle area: {circle.area()}")
print(f"Rectangle area: {rectangle.area()}")

# Both shapes can be used interchangeably
shapes = [circle, rectangle]
total_area = sum(shape.area() for shape in shapes)
print(f"Total area: {total_area}")
Circle area: 78.53975
Rectangle area: 24
Total area: 102.53975

Abstract base classes ensure that all derived classes implement required methods. This is useful when you want to guarantee a certain interface.

5. When to Use Composition

Use composition when you have “has-a” relationships or when you want more flexibility.

Scenario 1: Building Complex Objects

This scenario demonstrates how composition helps you build complex systems by combining simpler, focused components. Each component has a single responsibility.

Let’s create three separate component classes: FileWriter for saving data, Calculator for computations, and Logger for tracking operations.

# Component classes
class FileWriter:
    def write(self, filename, content):
        print(f"Writing to {filename}: {content}")

class Calculator:
    def add(self, a, b):
        return a + b
    
    def multiply(self, a, b):
        return a * b

class Logger:
    def log(self, message):
        print(f"LOG: {message}")

Now let’s create a ReportGenerator class that uses composition by containing instances of FileWriter, Calculator, and Logger objects, rather than inheriting from them. This generator will coordinate all three components to create reports.

# ReportGenerator using composition
class ReportGenerator:
    def __init__(self):
        # Composition: ReportGenerator HAS these components
        self.file_writer = FileWriter()
        self.calculator = Calculator()
        self.logger = Logger()
    
    def generate_sales_report(self, sales_data):
        self.logger.log("Starting sales report generation")
        
        total = sum(sales_data)
        average = total / len(sales_data)
        
        report = f"Sales Report\nTotal: {total}\nAverage: {average}"
        self.file_writer.write("sales_report.txt", report)
        
        self.logger.log("Sales report completed")
        return report

The ReportGenerator class orchestrates all the components. Instead of being a FileWriter, Calculator, or Logger, it contains these objects and delegates specific tasks to them.

# Testing the report generator
generator = ReportGenerator()
sales = [1000, 1500, 2000, 1200]
report = generator.generate_sales_report(sales)
LOG: Starting sales report generation
Writing to sales_report.txt: Sales Report
Total: 5700
Average: 1425.0
LOG: Sales report completed

Scenario 2: Policy-Based Design with Composition

This scenario shows how composition enables flexible runtime behavior by allowing objects to use different strategies or policies without changing their core structure.

Let’s create three separate payment strategy classes: CreditCard, PayPal, and BankTransfer, each implementing their own payment method.

# Different payment strategies
class CreditCard:
    def process_payment(self, amount):
        return f"Charged ${amount} to credit card"

class PayPal:
    def process_payment(self, amount):
        return f"Paid ${amount} via PayPal"

class BankTransfer:
    def process_payment(self, amount):
        return f"Transferred ${amount} via bank"

Now let’s create a CheckoutService class that uses composition by containing a payment strategy object. This service can change its payment processing behavior at runtime by switching between different strategy objects.

# CheckoutService that can use different payment strategies
class CheckoutService:
    def __init__(self):
        self.payment_method = CreditCard()  # Default payment method
    
    def set_payment_method(self, payment_method):
        self.payment_method = payment_method
        print(f"Payment method changed to {payment_method.__class__.__name__}")
    
    def process_order(self, amount):
        result = self.payment_method.process_payment(amount)
        print(f"Order processed: {result}")
        return result

The checkout service delegates its payment processing to a strategy object. This makes it easy to swap payment methods without changing the checkout service itself.

# Test strategy changes at runtime
checkout = CheckoutService()

# Process with credit card (default)
checkout.process_order(100.00)

# Switch to PayPal
checkout.set_payment_method(PayPal())
checkout.process_order(75.50)

# Switch to bank transfer
checkout.set_payment_method(BankTransfer())
checkout.process_order(200.00)
Order processed: Charged $100.0 to credit card
Payment method changed to PayPal
Order processed: Paid $75.5 via PayPal
Payment method changed to BankTransfer
Order processed: Transferred $200.0 via bank
'Transferred $200.0 via bank'

6. Runtime Behavior Changes with Composition

One of composition’s biggest advantages is the ability to change behavior at runtime.

Let’s create three separate notification classes: EmailNotifier, SMSNotifier, and PushNotifier, each implementing their own notification method.

# Different notification methods
class EmailNotifier:
    def send(self, message):
        return f"Email sent: {message}"

class SMSNotifier:
    def send(self, message):
        return f"SMS sent: {message}"

class PushNotifier:
    def send(self, message):
        return f"Push notification sent: {message}"

Now let’s create a NotificationService class that uses composition by containing a notifier object. This service can change its notification behavior at runtime by switching between different notifier objects.

# NotificationService that can change notification method
class NotificationService:
    def __init__(self):
        self.notifier = EmailNotifier()  # Default notifier
    
    def set_notifier(self, notifier):
        self.notifier = notifier
        print(f"Notification method changed to {notifier.__class__.__name__}")
    
    def notify_user(self, message):
        result = self.notifier.send(message)
        print(result)
        return result

The notification service delegates its sending behavior to a notifier object. This makes it easy to swap notification methods without changing the service itself.

# Test notification method changes at runtime
service = NotificationService()

# Send with email (default)
service.notify_user("Welcome to our service!")

# Switch to SMS
service.set_notifier(SMSNotifier())
service.notify_user("Your order has shipped!")

# Switch to push notification
service.set_notifier(PushNotifier())
service.notify_user("New message received!")
Email sent: Welcome to our service!
Notification method changed to SMSNotifier
SMS sent: Your order has shipped!
Notification method changed to PushNotifier
Push notification sent: New message received!
'Push notification sent: New message received!'

With inheritance, you’d need to create different character classes. With composition, you just swap strategies at runtime.

7. The Liskov Substitution Principle

The Liskov Substitution Principle states that objects of a derived class should be able to replace objects of the base class without breaking the program. This is a key test for proper inheritance.

Let’s create a parent class called Document with save and load methods, then create child classes PDFDocument and WordDocument that inherit from it and override the methods while maintaining the same interface.

# Good inheritance example that follows LSP
class Document:
    def __init__(self, title):
        self.title = title
    
    def save(self):
        return f"Saving document: {self.title}"
    
    def load(self):
        return f"Loading document: {self.title}"

class PDFDocument(Document):
    def save(self):
        return f"Saving PDF: {self.title}"
    
    def load(self):
        return f"Loading PDF: {self.title}"

class WordDocument(Document):
    def save(self):
        return f"Saving Word doc: {self.title}"
    
    def load(self):
        return f"Loading Word doc: {self.title}"

# Function that works with any Document
def process_document(doc):
    saved = doc.save()
    loaded = doc.load()
    return saved, loaded

# Both work perfectly - LSP is maintained
pdf_doc = PDFDocument("Report.pdf")
word_doc = WordDocument("Letter.docx")

print("PDF:", process_document(pdf_doc))
print("Word:", process_document(word_doc))
PDF: ('Saving PDF: Report.pdf', 'Loading PDF: Report.pdf')
Word: ('Saving Word doc: Letter.docx', 'Loading Word doc: Letter.docx')

This demonstrates proper inheritance – both derived classes can be used anywhere a Document is expected, and the program works correctly.

Now let’s see an example that violates LSP. We’ll create a parent class Storage that can save and load, then create a child class ReadOnlyStorage that inherits from it but can’t actually save, breaking the expected behavior.

# Bad example that violates LSP
class Storage:
    def __init__(self, name):
        self.name = name
    
    def save(self, data):
        return f"Saved data to {self.name}"
    
    def load(self):
        return f"Loading data from {self.name}"

class ReadOnlyStorage(Storage):  # Problem: can't save!
    def save(self, data):
        raise Exception("Cannot save to read-only storage!")
    
    def load(self):
        return f"Loading data from read-only {self.name}"

Here’s a classic example of violating LSP. ReadOnlyStorage inherits from Storage, but it can’t fulfill the contract that all storage can save data.

# Function expecting any storage to be writable
def backup_data(storage, data):
    return storage.save(data)

# This violates LSP
regular_storage = Storage("disk")
readonly_storage = ReadOnlyStorage("cdrom")

print(backup_data(regular_storage, "important data"))  # Works
# print(backup_data(readonly_storage, "important data"))  # Crashes! LSP violated
Saved data to disk

When inheritance violates LSP, composition is usually a better choice.

Scroll to Top
Course Preview

Machine Learning A-Z™: Hands-On Python & R In Data Science

Free Sample Videos:

Machine Learning A-Z™: Hands-On Python & R In Data Science

Machine Learning A-Z™: Hands-On Python & R In Data Science

Machine Learning A-Z™: Hands-On Python & R In Data Science

Machine Learning A-Z™: Hands-On Python & R In Data Science

Machine Learning A-Z™: Hands-On Python & R In Data Science

Scroll to Top