Composition vs Inheritance in Python OOP

Python for AI, data science and machine learning Day 10

Gianpiero Andrenacci
Data Bistrot
12 min readMay 9, 2024

--

Composition is a fundamental concept in object-oriented programming (OOP), crucial for designing robust and adaptable software systems. It models a “has-a” relationship between classes, enabling more flexible and loosely coupled designs compared to traditional inheritance-based approaches.

Python for AI, data science and machine learning series

What is Composition?

At its core, composition involves constructing complex objects by including objects of other classes within them, known as components.

A class that incorporates one or more such objects is referred to as a composite class.

This design pattern allows a composite class to utilize the functionalities of its component classes, without inheriting from them directly. Essentially, the composite class “has a” component (or many components) of another class.

Advantages of Using Composition

  1. Reusability: Composition fosters reusability by allowing the composite class to leverage the implementation of its components. This means that instead of rewriting code, a class can simply include objects of existing classes to use their functionality.
  2. Loose Coupling: The relationship between the composite and component classes in composition is characterized by loose coupling. This implies that changes in the component class have minimal impact on the composite class, enhancing the system’s maintainability and flexibility.
  3. Ease of Change: Due to the loose coupling, software systems designed with composition are easier to modify and extend. New functionalities can be introduced or existing ones modified with little to no effect on the other parts of the system.
  4. Flexibility over Inheritance: While inheritance establishes a rigid “is-a” relationship, composition provides a more flexible “has-a” framework. This flexibility often makes composition a preferable choice in situations where software requirements are likely to evolve over time.

Composition vs. Inheritance

Inheritance and composition are both powerful design tools in OOP, but they serve different purposes. Inheritance is best suited for cases where a class should extend the functionality of another class, implying a strong relationship. On the other hand, composition is ideal for cases where a class simply needs to utilize functionalities of other classes without extending them, indicating a weaker relationship.

A design based on composition is generally more flexible and adaptable to change than one based on inheritance. With composition, it’s easier to modify how components are used or switch out components altogether without disrupting the design of the composite class.

Composition offers a dynamic and robust framework for designing software systems, promoting reusability, ease of modification, and loose coupling between components. By preferring composition over inheritance where appropriate, developers can achieve greater flexibility in their designs, making it easier to adapt to new requirements and changes. This makes composition an invaluable tool in the arsenal of object-oriented programming and design.

Example of Composition in Python

Let’s illustrate the concept of composition in Python with a simple example. Consider a scenario where we’re building a software system for a school. In this system, we have classes representing Teachers and Departments. Instead of using inheritance, which would imply a teacher "is a" department, we use composition to model a more realistic "has a" relationship, where a department "has" teachers.

Defining the Components

First, we define a Teacher class. Each teacher has a name and a subject they teach.

class Teacher:
def __init__(self, name, subject):
self.name = name
self.subject = subject

def get_details(self):
return f"{self.name} teaches {self.subject}."

Next, we define the Department class. A department is composed of multiple teachers. Thus, we use composition to include instances of Teacher within a Department.

Creating the Composite Class

class Department:
def __init__(self, name):
self.name = name
self.teachers = [] # Composition happens here

def add_teacher(self, teacher):
self.teachers.append(teacher)

def get_department_details(self):
details = f"Department: {self.name}\n"
details += "Teachers:\n"
for teacher in self.teachers:
details += f"- {teacher.get_details()}\n"
return details

Using Composition in Practice

Now, let’s create some Teacher instances and a Department instance to see composition in action.

# Creating teacher instances
teacher1 = Teacher("Alice Smith", "Mathematics")
teacher2 = Teacher("Bob Johnson", "Science")

# Creating a department and adding teachers to it
math_science_department = Department("Math & Science")
math_science_department.add_teacher(teacher1)
math_science_department.add_teacher(teacher2)

# Displaying department details
print(math_science_department.get_department_details())

Output:

Department: Math & Science
Teachers:
- Alice Smith teaches Mathematics.
- Bob Johnson teaches Science.

In this example, we’ve demonstrated how composition allows the Department class to use instances of the Teacher class without inheriting from it. By aggregating Teacher objects, the Department class can represent a more complex entity composed of simpler ones. This approach enhances modularity, promotes reusability, and maintains a loose coupling between classes, embodying the advantages of using composition in object-oriented design.

Example of Composition with Abstract Base Classes in Python

In this example, we’ll demonstrate how to use the abc module (Abstract Base Classes) in Python to create a flexible and maintainable system that leverages both composition and abstraction. We'll design a system for a simple media player that can play different types of media files, showcasing how composition (via class relationships) and abstraction (through ABCs) work together.

Defining the Abstract Base Class

First, we define an abstract base class MediaFile that outlines the structure for different types of media files. This class will use the abc module to declare an abstract method play, which subclasses must implement.

from abc import ABC, abstractmethod

class MediaFile(ABC):
def __init__(self, name):
self.name = name

@abstractmethod
def play(self):
pass

Implementing Concrete Classes

Next, we create concrete implementations of the MediaFile class for different media types, such as AudioFile and VideoFile.

class AudioFile(MediaFile):
def play(self):
return f"Playing audio file: {self.name}"

class VideoFile(MediaFile):
def play(self):
return f"Playing video file: {self.name}"

Creating the Composite Class

Now, we design the MediaPlayer class that uses composition to include multiple media files, regardless of their type, demonstrating the system's flexibility.

class MediaPlayer:
def __init__(self):
self.playlist = []

def add_media(self, media_file: MediaFile):
self.playlist.append(media_file)

def play_all(self):
for media in self.playlist:
print(media.play())

Demonstrating Composition and Abstraction

Finally, we instantiate our media files and a media player, then add the files to the player’s playlist to demonstrate how our system operates.

# Creating instances of media files
audio1 = AudioFile("song1.mp3")
video1 = VideoFile("video1.mp4")

# Creating the media player
player = MediaPlayer()

# Adding media files to the player's playlist
player.add_media(audio1)
player.add_media(video1)

# Playing all media in the playlist
player.play_all()

Output:

Playing audio file: song1.mp3
Playing video file: video1.mp4

In this system, composition allows the MediaPlayer class to aggregate different types of media files, showcasing its flexibility to handle various media types without being tightly coupled to their concrete implementations. Abstraction, through the use of abstract base classes, ensures that each media type adheres to a common interface, allowing the MediaPlayer to interact with them through a unified method (play). This design not only promotes reusability and maintainability but also illustrates how composition and abstraction can be combined to create a robust and extendable system in Python.

Example: Advanced Compensation System for Data Science Projects

In the realm of software development, particularly in data science, effectively managing and compensating employees according to their project contributions is crucial for maintaining motivation and ensuring fair compensation. This example introduces a sophisticated system designed to handle these aspects by leveraging the concepts of Object-Oriented Programming (OOP), specifically focusing on abstraction and composition.

Overview

The system is built around a series of classes that model data science project tasks and the employees who work on them. By abstracting project tasks into a hierarchy of classes and using composition to associate these tasks with employees, the system can calculate compensation in a dynamic and flexible manner.

Abstract Base Class: ProjectTask

At the core of the system is an abstract base class, ProjectTask, which defines a generic interface for all tasks in a data science project. This class utilizes the Abstract Base Classes (ABC) module from Python's standard library to enforce the implementation of the get_effort_estimate method in all subclassing tasks. This method is crucial as it returns the estimated effort needed to complete the task, forming the basis for calculating employee compensation.

from abc import ABC, abstractmethod

class ProjectTask(ABC):
"""Represents a task within a data science project."""

@abstractmethod
def get_effort_estimate(self) -> float:
"""Returns the effort estimate to complete the task."""
  • ProjectTask is an abstract base class (ABC) that represents a generic task within a data science project.
  • It uses the @abstractmethod decorator to declare get_effort_estimate as an abstract method. This method is intended to return an effort estimate for completing the task, but its implementation must be provided by subclasses.

Concrete Task Classes

Subclasses of ProjectTask represent specific types of tasks encountered in data science projects, such as DataCollectionTask, AnalysisTask, and ModelingTask. Each class implements the get_effort_estimate method, providing a unique effort estimate calculation based on task-specific parameters (e.g., the number of data sources, complexity level, or the number of models to be developed).

DataCollectionTask:

class DataCollectionTask(ProjectTask):
"""Task related to data collection efforts."""

def __init__(self, data_sources: int):
self.data_sources = data_sources

def get_effort_estimate(self) -> float:
return 2.0 * self.data_sources
  • A specific task related to data collection efforts.
  • The __init__ method initializes the object with the number of data sources.
  • Implements the get_effort_estimate method by returning an estimate based on the number of data sources.

AnalysisTask

class AnalysisTask(ProjectTask):
"""Task for data analysis."""

def __init__(self, complexity_level: int):
self.complexity_level = complexity_level

def get_effort_estimate(self) -> float:
return 5.0 * self.complexity_level
  • Represents a data analysis task.
  • The __init__ method initializes the object with a complexity level.
  • Implements the get_effort_estimate by returning an estimate based on the task's complexity level.
class ModelingTask(ProjectTask):
"""Machine Learning modeling task."""

def __init__(self, number_of_models: int):
self.number_of_models = number_of_models

def get_effort_estimate(self) -> float:
return 10.0 * self.number_of_models
  • Represents a machine learning modeling task.
  • Initialized with the number of models to be developed.
  • The get_effort_estimate method returns an estimate based on the number of models.

DataScienceEmployee Class

The DataScienceEmployee class represents employees working on data science projects. It is composed of multiple ProjectTask instances, reflecting the various tasks an employee might be assigned. Besides basic attributes like name and ID, it includes a project_tasks list for task assignments and fields for base_salary and an optional bonus. The compute_compensation method calculates the total compensation for an employee based on their assigned tasks and efforts, incorporating both the base salary and any bonuses tied to task completion.

from typing import Optional, List

class DataScienceEmployee:
"""Represents an employee working on data science projects."""

def __init__(self, name: str, id: int, project_tasks: List[ProjectTask], base_salary: float, bonus: Optional[float] = None):
self.name = name
self.id = id
self.project_tasks = project_tasks
self.base_salary = base_salary
self.bonus = bonus

def compute_compensation(self) -> float:
total_effort = sum(task.get_effort_estimate() for task in self.project_tasks)
compensation = self.base_salary
if self.bonus is not None:
compensation += self.bonus * total_effort
return compensation
  • Represents an employee working on data science projects.
  • Initialized with a name, ID, a list of project tasks (ProjectTask instances), a base salary, and an optional bonus.
  • The compute_compensation method calculates the total compensation based on the base salary and additional bonuses tied to the effort estimates of the assigned tasks.

The Main Function

The main function showcases how to utilize this system by creating instances of task classes, assigning them to employees, and then calculating and displaying the employees' total compensation. This practical example demonstrates the system's ability to handle diverse project tasks and calculate compensation in a way that reflects the actual contributions of each employee.

def main():
"""Demonstrate the data science project management system."""

alice_tasks = [DataCollectionTask(data_sources=5), AnalysisTask(complexity_level=3)]
alice = DataScienceEmployee(name="Alice", id=101, project_tasks=alice_tasks, base_salary=70000, bonus=150)

bob_tasks = [ModelingTask(number_of_models=2)]
bob = DataScienceEmployee(name="Bob", id=102, project_tasks=bob_tasks, base_salary=85000, bonus=300)

print(f"{alice.name} has tasks with a total effort estimate of {sum(task.get_effort_estimate() for task in alice_tasks)} and total compensation of ${alice.compute_compensation()}.")
print(f"{bob.name} has tasks with a total effort estimate of {sum(task.get_effort_estimate() for task in bob_tasks)} and total compensation of ${bob.compute_compensation()}.")

if __name__ == "__main__":
main()
  • Creates tasks and employees (alice and bob), assigning different tasks to each.
  • Computes and prints the total compensation for each employee based on their tasks and efforts.

The complete code:

from abc import ABC, abstractmethod
from typing import Optional, List

class ProjectTask(ABC):
"""Represents a task within a data science project."""

@abstractmethod
def get_effort_estimate(self) -> float:
"""Returns the effort estimate to complete the task."""

class DataCollectionTask(ProjectTask):
"""Task related to data collection efforts."""

def __init__(self, data_sources: int):
self.data_sources = data_sources

def get_effort_estimate(self) -> float:
# Assume each data source requires a fixed amount of effort
return 2.0 * self.data_sources

class AnalysisTask(ProjectTask):
"""Task for data analysis."""

def __init__(self, complexity_level: int):
self.complexity_level = complexity_level

def get_effort_estimate(self) -> float:
# Higher complexity increases effort linearly
return 5.0 * self.complexity_level

class ModelingTask(ProjectTask):
"""Machine Learning modeling task."""

def __init__(self, number_of_models: int):
self.number_of_models = number_of_models

def get_effort_estimate(self) -> float:
# Assume each model requires a substantial amount of effort
return 10.0 * self.number_of_models

class DataScienceEmployee:
"""Represents an employee working on data science projects."""

def __init__(self, name: str, id: int, project_tasks: List[ProjectTask], base_salary: float, bonus: Optional[float] = None):
self.name = name
self.id = id
self.project_tasks = project_tasks
self.base_salary = base_salary
self.bonus = bonus

def compute_compensation(self) -> float:
"""Compute the total compensation including base salary and bonus for task completion."""
total_effort = sum(task.get_effort_estimate() for task in self.project_tasks)
compensation = self.base_salary
if self.bonus is not None:
compensation += self.bonus * total_effort
return compensation

def main():
"""Demonstrate the data science project management system."""

alice_tasks = [DataCollectionTask(data_sources=5), AnalysisTask(complexity_level=3)]
alice = DataScienceEmployee(name="Alice", id=101, project_tasks=alice_tasks, base_salary=70000, bonus=150)

bob_tasks = [ModelingTask(number_of_models=2)]
bob = DataScienceEmployee(name="Bob", id=102, project_tasks=bob_tasks, base_salary=85000, bonus=300)

print(f"{alice.name} has tasks with a total effort estimate of {sum(task.get_effort_estimate() for task in alice_tasks)} and total compensation of ${alice.compute_compensation()}.")
print(f"{bob.name} has tasks with a total effort estimate of {sum(task.get_effort_estimate() for task in bob_tasks)} and total compensation of ${bob.compute_compensation()}.")

if __name__ == "__main__":
main()

Conclusion

This advanced compensation system for this data science projects illustrates the power of OOP principles such as abstraction and composition. By abstracting project tasks into a flexible class hierarchy and using composition to associate these tasks with employees, the system offers a dynamic and scalable solution for managing project contributions and compensating employees. This approach enhances the modularity and maintainability of the codebase, making it easier to adapt and extend as new project tasks emerge.

Example Scenario: Predictive Modeling

Using composition in a data science project can increase modularity, enhance code reusability, and simplify maintenance. This is particularly useful in data science projects where the behavior of components might need to be flexible or interchangeable.

Let’s consider another scenario in a data science project where we are building various predictive models. We need to create models that can be trained, evaluated, and have the ability to predict outcomes. Instead of inheriting from a base model class, we use composition to plug in different preprocessing and evaluation strategies.

Step 1: Define Strategy Interfaces

First, we define the interfaces for preprocessing and evaluation strategies. These interfaces will dictate what methods must be implemented by any strategy that conforms to them.

class PreprocessingStrategy:
def preprocess(self, data):
raise NotImplementedError("Subclasses should implement this!")

class EvaluationStrategy:
def evaluate(self, model, X_test, y_test):
raise NotImplementedError("Subclasses should implement this!")

Step 2: Implement Specific Strategies

We then implement specific strategies that conform to these interfaces.

import numpy as np
from sklearn.preprocessing import StandardScaler, MinMaxScaler

class StandardScalerPreprocessing(PreprocessingStrategy):
def preprocess(self, data):
scaler = StandardScaler()
return scaler.fit_transform(data)

class MinMaxScalerPreprocessing(PreprocessingStrategy):
def preprocess(self, data):
scaler = MinMaxScaler()
return scaler.fit_transform(data)

class RMSEEvaluation(EvaluationStrategy):
def evaluate(self, model, X_test, y_test):
predictions = model.predict(X_test)
mse = np.mean((predictions - y_test) ** 2)
return np.sqrt(mse)

Step 3: Create the Model Class

Now, we’ll define our model class which uses these strategies. We will compose our PredictiveModel class using the strategies rather than inheriting from them.

Step 4: Usage Example

Let’s see how we can use our PredictiveModel class with different strategies. This allows us to change preprocessing or evaluation strategies without modifying the model class itself.

from sklearn.datasets import load_boston
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LinearRegression

# Load and split data
data = load_boston()
X_train, X_test, y_train, y_test = train_test_split(data.data, data.target, test_size=0.2, random_state=42)

# Initialize model with strategies
model = PredictiveModel(LinearRegression(), StandardScalerPreprocessing(), RMSEEvaluation())

# Train and evaluate the model
model.train(X_train, y_train)
rmse = model.evaluate(X_test, y_test)
print(f"RMSE: {rmse}")

This approach demonstrates the flexibility and modularity provided by composition. It allows the data science team to easily swap out or modify components without affecting others, adapting to various data processing or evaluation needs as projects evolve.

To sum up:

When and Why to Use Composition Over Inheritance in Python

Why Use Composition?

Composition offers several benefits over inheritance, making it a preferred choice in many scenarios:

  1. Flexibility: Composition provides greater flexibility in code structure by allowing runtime changes to the components’ behavior. You can easily switch out parts of your system without redesigning the interfaces between them.
  2. Modularity: Using composition helps in building highly modular code. Each component can be developed and tested independently, reducing dependencies across the codebase.
  3. Ease of maintenance: Systems designed with composition are usually easier to maintain and extend, as changes in one part of the system do not necessarily ripple through to other parts.
  4. Avoids class hierarchy complexity: Inheritance can lead to a deep and complicated class hierarchy that might become difficult to navigate and manage. Composition sidesteps this by encouraging simpler and flatter structure.

When to Use Composition?

Composition should be considered:

  1. When you need dynamic behaviors: If your application needs to alter its behavior at runtime, composition allows you to easily replace components without restructuring the entire system.
  2. When components can be used across multiple contexts: If the same functionality is needed in disparate parts of the application, composition allows you to reuse a component in various contexts without inheriting from it.
  3. To avoid the diamond problem in inheritance: This is a common issue in languages supporting multiple inheritance, where a class inherits from two classes, both of which inherit from a common base class. Composition eliminates this by allowing controlled and clear component use.
  4. When you want to encapsulate a complex structure: Composition can simplify the management of complex systems by breaking down functionality into smaller, manageable parts that are easier to understand and use.

In summary, prefer composition over inheritance when you require flexibility, ease of maintenance, and modularity in your applications. It helps keep the system decoupled and promotes cleaner, more understandable code.

--

--

Gianpiero Andrenacci
Data Bistrot

AI & Data Science Solution Manager. Avid reader. Passionate about ML, philosophy, and writing. Ex-BJJ master competitor, national & international titleholder.