Composition vs Inheritance in Python OOP
Python for AI, data science and machine learning Day 10
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.
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
- 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.
- 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.
- 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.
- 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 declareget_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
andbob
), 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:
- 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.
- Modularity: Using composition helps in building highly modular code. Each component can be developed and tested independently, reducing dependencies across the codebase.
- 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.
- 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:
- 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.
- 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.
- 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.
- 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.
Complete series:
Further resources: