Inheritance vs. Composition in Python- When to Use Which

Master Python's inheritance and composition, how they work, when to use each, with clear examples. Elevate your OOP skills effectively.

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.

PyInteractive Python
# 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.

PyInteractive Python
# 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.

PyInteractive Python
# 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.

PyInteractive Python
# 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.

PyInteractive Python
# 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.

PyInteractive Python
# 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

PyInteractive Python
# 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", "john@example.com") # 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.

PyInteractive Python
# 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.

PyInteractive Python
# 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.

PyInteractive Python
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.

PyInteractive Python
# 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.

PyInteractive Python
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.

PyInteractive Python
# 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.

PyInteractive Python
# 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.

PyInteractive Python
# 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.

PyInteractive Python
# 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.

PyInteractive Python
# 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.

PyInteractive Python
# 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.

PyInteractive Python
# 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.

PyInteractive Python
# 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.

PyInteractive Python
# 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.

PyInteractive Python
# 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.

PyInteractive Python
# 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.

PyInteractive Python
# 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.

PyInteractive Python
# 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.