Coding best practices – Part 2
The idea of this article is to clearly explain a difficult concept in object-oriented programming named SOLID. it’s an acronym that I’m going to detail here. There are allready a lot of articles explaining this term, but I find that the way it’s presented isn’t very clear generally. It’s a concept that talks about abstraction, and the words and examples need to be clear.
The SOLID principles are guidelines for designing software so that it is easy to maintain and extend. They are particularly useul for object-oriented programming.
SRP Single Responsability Principle
The Single Responsability Principle (SRP) states that a class should have one, and only one reason to change. This means that each class should focus on a single task or responsability.
Here’s a Python example to illustrate this principle. Suppose we have an application to manage a journal.
A class that does not adhere to SRP might look like this:
class Journal:
def __init__(self):
self.entries = []
def add_entry(self, text):
self.entries.append(text)
def remove_entry(self, index):
self.entries.pop(index)
def save_to_file(self, filename):
with open(filename, 'w') as f:
f.write('\n'.join(self.entries))
def load_from_file(self, filename):
with open(filename, 'r') as f:
self.entries = f.readlines()
Explanation: The Journal class is responsible for 2 things: managing journal entities and handling data persistence (saving and loading files).
This violates SRP because it has multiple reasons to change (e.g. changes in managing entries or changes in file format).
To adhere to SRP, we can separate these responsibilities into 2 distinct classes:
class Journal:
def __init__(self):
self.entries = []
def add_entry(self, text):
self.entries.append(text)
def remove_entry(self, index):
self.entries.pop(index)
class PersistenceManager:
@staticmethod
def save_to_file(journal, filename):
with open(filename, 'w') as f:
f.write('\n'.join(journal.entries))
@staticmethod
def load_from_file(journal, filename):
with open(filename, 'r') as f:
journal.entries = f.readlines()
Moreover, we can easily understand that this separation allows us to use the PesistenceManager as a global object that can be reused in many cases, regardless of how we add or remove entries.
OCP Open/Closed Principle
The Open/Closed Principle (OCP) states that software entities (such as classes, modules, functions) should be open for extension but closed for modification.
This means you should be able to add new functionality without changing existing code.
Here’s a Python example to illustrate this principle.
Imagine we have a system to calculate the area of different shapes. We might have a class like this:
class AreaCalculator:
def calculate_area(self, shape):
if isinstance(shape, Rectangle):
return shape.width * shape.height
elif isinstance(shape, Circle):
return 3.14 * (shape.radius ** 2)
Explanation: This class violates OCP because if we want to add a new shape (e.g. Triangle), we need to modify the calculuate_area method. This is not ideal because modifying existing code can introduce bugs.
It is clear that adding a new ‘if’ statement for each new shape is not a good practice at all.
To adhere to OCP, we can use polymorphism to extend functionality without changing existing code:
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
pass
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14 * (self.radius ** 2)
class AreaCalculator:
def calculate_area(self, shape: Shape):
return shape.area()
Explanation:
- The
Shapeclass is an abstract class wit han abstract methodarea. This defines a contract that any subclass must implement the area method. RectangleandCircleclasses inherit fromShapeand provide their own implementations of theareamethod.- The
AreaCalculatorclass now uses theShapeinterface to calculate the area. If a new shape is added, we can extend the Shape class without modifying theAreaCalculatorclass.
For instance, to add a new shape Triangle, we only create a new class:
class Triangle(Shape):
def __init__(self, base, height):
self.base = base
self.height = height
def area(self):
return 0.5 * self.base * self.height
Now Triangle can be used with AreaCalculator without any modification to the existing code:
triangle = Triangle(10, 5) calculator = AreaCalculator() print(calculator.calculate_area(triangle))
By using inheritance and polymorphism, we can extend the functionality of our system (e.g. adding new shapes) without modifying existing code. This makes the system more robust and easier to maintain.
LSP Liskov Substitution Principle
The Liskov Substitution Principle (LSP) states that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program.
Essentially, subclasses should be substitutable for their base classes.
Here’s a Python example to illustrate this principle.
Imagine we have a base class Bird that includes a fly method.
An inital implementation might look like this:
class Bird:
def fly(self):
return "I'm flying"
class Sparrow(Bird):
pass
class Ostrich(Bird):
def fly(self):
raise NotImplementedError("Ostriches can't fly")
Explanation: In this example, we have a Bird class with a fly method, and 2 subclasses: Sparrow and Ostrich. The Sparrow inherits the fly method directly, but the Ostrich class overrides the fly method to raise an exception bacause ostriches cannot fly.
This violates the LSP because you cannot substitute an Ostrich for a Bird without causing an error.
To adhere to the Liskov Subsitution Principle, we need to redesign the hierarchy so that all subclasses can be used interchangeably with their base class:
from abc import ABC, abstractmethod
class Bird(ABC):
@abstractmethod
def move(self):
pass
class FlyingBird(Bird):
def move(self):
return "I'm flying"
class NonFlyingBird(Bird):
def move(self):
return "I'm running"
class Sparrow(FlyingBird):
pass
class Ostrich(NonFlyingBird):
pass
Explanation:
- The
Birdclass is now an abstract class with and abstract methodmove. - We create 2 subclasses:
FlyingBirdandNonFlyingBird, each implementing themovemethod appropriatlely. Sparrowinherits fromFlyingBird, andOstrichinherits fromNonFlyingBird.
With this design, both Sparrow and Ostrich can be substituted for Bird without causing any errors, adhering to the LSP:
def let_bird_move(bird: Bird):
print(bird.move())
sparrow = Sparrow()
ostrich = Ostrich()
let_bird_move(sparrow) # Outputs: I'm flying
let_bird_move(ostrich) # Outputs: I'm running
The important thing to remember is that the class hierarchy must be precise, with each subclass inheriting only properties and methods that can be reused.
ISP Interface Segregation Principle
The Interface Segregation Principle (ISP) states that clients should not be forced to depend on interfaces they do not use.
In simpler terms, it’s better to have many small, specific interfaces than on large, general-purpose interface.
Here’s a Python example to illustrate this principle.
Imagine we have a Worker interface that include both work an eat methods.
An intitial implementation might look like this:
from abc import ABC, abstractmethod
class Worker(ABC):
@abstractmethod
def work(self):
pass
@abstractmethod
def eat(self):
pass
class HumanWorker(Worker):
def work(self):
return "I'm working"
def eat(self):
return "I'm eating"
class RobotWorker(Worker):
def work(self):
return "I'm working"
def eat(self):
raise NotImplementedError("Robots don't eat")
Explanation: In this example, HumanWorker implements both work and eat methods correctly.
However, RobotWorker raises an error for eat because robots do not eat. This violates ISP because RobotWorker is forced to implement an interface that it does not use.
To adhere to the Inferface Segregation Principle, we should split the Worker interface into 2 smaller more specific interfaces:
from abc import ABC, abstractmethod
class Workable(ABC):
@abstractmethod
def work(self):
pass
class Eatable(ABC):
@abstractmethod
def eat(self):
pass
class HumanWorker(Workable, Eatable):
def work(self):
return "I'm working"
def eat(self):
return "I'm eating"
class RobotWorker(Workable):
def work(self):
return "I'm working"
Explanation:
- The
Workableinterface includes only theworkmethod. - The
Eatableinterface includes only theeatmethod. HumanWorkerimplements bothWorkableandEatableinterfaces because it needs to work and eat.RobotWorkerimplements only theWorkableinterface because it only needs to work.
With this design, each class only implements the interfaces that are relevant to them, adhering to ISP:
def process_work(worker: Workable):
print(worker.work())
def process_eat(eater: Eatable):
print(eater.eat())
human = HumanWorker()
robot = RobotWorker()
process_work(human) # Outputs: I'm working
process_work(robot) # Outputs: I'm working
process_eat(human) # Outputs: I'm eating
# process_eat(robot) would raise an error if attempted
By splitting larger interfaces into smaller more specific ones, we ensure that classes depend only on the methods they actually use. This makes the code more flexible and easier to maintain.
DIP Dependency Inversion Principle
The Dependency Inversion Principle (DIP) states that high-level modules should not depend on low-level modules. Both should depend on abstractions. Additionally, abstractions should not depend on details.
Details should depend on abstractions. here’s a Python example to illustrate this principle.
Imagine a project where we have high-level modules (like Project) depending on low-level modules (like BackendDeveloper and FrontendDeveloper.
An initial implementation might look like this:
class BackendDeveloper:
def develop(self):
return "Writing Python code"
class FrontendDeveloper:
def develop(self):
return "Writing JavaScript code"
class Project:
def __init__(self):
self.backend = BackendDeveloper()
self.frontend = FrontendDeveloper()
def develop(self):
return f"{self.backend.develop()} and {self.frontend.develop()}"
Explanation: In this example, the Project class directly depends on the concrete classes BackendDeveloper and FrontendDeveloper.
This violates DIP because if we need to change the developers (e.g. to a FullStackDeveloper), we have to modify the Project class.
To adhere to the Dependency Inversion Principle, we should introduce abstractions that both high-level and low-level modules depend on:
from abc import ABC, abstractmethod
class Developer(ABC):
@abstractmethod
def develop(self):
pass
class BackendDeveloper(Developer):
def develop(self):
return "Writing Python code"
class FrontendDeveloper(Developer):
def develop(self):
return "Writing JavaScript code"
class Project:
def __init__(self, backend: Developer, frontend: Developer):
self.backend = backend
self.frontend = frontend
def develop(self):
return f"{self.backend.develop()} and {self.frontend.develop()}"
Explanation:
- The
Developerclass is an abstract base class that defines a contract with thedevelopmethod. - Both
BackendDevelperandFrontendDeveloperimplement theDeveloperinterface. - The
Projectclass now depends on theDeveloperinterface, not the concrete classes.
With this design, we can easily replace or extend the Developer implementations without modifying the Project class. For instance, if we want to add a FullStackDeveloper:
class FullStackDeveloper(Developer):
def develop(self):
return "Writing both Python and JavaScript code"
# Using the new FullStackDeveloper with the Project class
fullstack = FullStackDeveloper()
project = Project(fullstack, fullstack)
print(project.develop())
Introducing abstractions and making both high-level and low-level depend on then makes the code more flexible, easier to maintain, and enhances modularity.
An application implementing all principles
Let’s now design a simple application demonstrating the benefits of all SOLID principles.
This application manages employees and processes their payroll, demonstrating the SOLID principles in a real-world context. here’s a breakdown:
- Employee Management:
Employeeclass represents employees, andEmployeeRepositoryhandles storing and retrieving employee data.- Different types of employees, such as
ManagerandDeveloper, inherit fromEmployee.
- Task Management:
- Task class represents tasks assigned to employees.
- Employees can be assigned tasks, adhering to SRP by separating task management from employee management.
- Payment Processing:
PaymentProcessorinterface and its implementations (MonthlyPaymentProcessor,HourlyPaymentProcessor) handle payroll processing, showcasing OCP by allowing new payment methods without modifying existing ones.
- Employee Types:
ContractEmployeeandPermanentEmployeeclasses demonstrate LSP by ensuring all employee types can be used interchangeably.
- Specific Interfaces:
WorkableandReportableinterfaces ensure that classes implement only the methods they need, following ISP.
- Abstraction Dependency:
PayrollSystemdepends on thePayementProcessorinterface, not concrete implementations, adhering to DIP.
from abc import ABC, abstractmethod
from typing import List
# Single Responsibility Principle (SRP): Separate responsibilities for different classes
class Employee:
def __init__(self, name: str):
self.name = name
self.tasks = []
def add_task(self, task):
self.tasks.append(task)
class Task:
def __init__(self, description: str):
self.description = description
class EmployeeRepository:
def __init__(self):
self.employees = []
def add_employee(self, employee: Employee):
self.employees.append(employee)
def get_all_employees(self) -> List[Employee]:
return self.employees
# Open/Closed Principle (OCP): Allow extension without modifying existing code
class PaymentProcessor(ABC):
@abstractmethod
def process(self, employee: Employee):
pass
class MonthlyPaymentProcessor(PaymentProcessor):
def process(self, employee: Employee):
return f"Processing monthly payment for {employee.name}"
class HourlyPaymentProcessor(PaymentProcessor):
def process(self, employee: Employee):
return f"Processing hourly payment for {employee.name}"
# Liskov Substitution Principle (LSP): Subtypes should be substitutable for their base types
class ContractEmployee(Employee):
def __init__(self, name: str, contract_end_date: str):
super().__init__(name)
self.contract_end_date = contract_end_date
class PermanentEmployee(Employee):
pass
# Interface Segregation Principle (ISP): Use specific interfaces
class Workable(ABC):
@abstractmethod
def work(self):
pass
class Reportable(ABC):
@abstractmethod
def report(self):
pass
class Manager(PermanentEmployee, Workable, Reportable):
def work(self):
return f"{self.name} is managing the team"
def report(self):
return f"{self.name} is reporting the status"
class Developer(PermanentEmployee, Workable):
def work(self):
return f"{self.name} is writing code"
# Dependency Inversion Principle (DIP): Depend on abstractions
class PayrollSystem:
def __init__(self, payment_processor: PaymentProcessor):
self.payment_processor = payment_processor
def run_payroll(self, employees: List[Employee]):
for employee in employees:
print(self.payment_processor.process(employee))
# Putting it all together
employee_repo = EmployeeRepository()
employee_repo.add_employee(Manager("Alice"))
employee_repo.add_employee(Developer("Bob"))
payment_processor = MonthlyPaymentProcessor()
payroll_system = PayrollSystem(payment_processor)
payroll_system.run_payroll(employee_repo.get_all_employees())
Explanation:
- SRP:
Employee,TaskandEmployeeRepositoryclasses each have distinct responsibilities. - OCP:
PaymentProcessorinterface allows adding new payment processors without modifying existing ones. - LSP:
ContractEmployeeandPermanentEmployeecan be used interchangeably withEmployee. - ISP:
WorkableandReportableinterfaces ensure classes only implement relevant methods. - DIP:
PayrollSystemdepends on thePaymentProcessorabstraction, not concrete implementations.
In this application, we can manage every type of employee and every type of payment with a unique and modular system.
Conclusion
By following the SOLID principles, developers can produce high-quality code that is easier to maintain, extend and scale.
This results in software that is more resilient to change and better aligned with evolving business needs.
Ultimately, SOLID principles help create a sustainable codebase, allowing teams to deliver features faster and with greater confidence.
Embracing these principles doesn’t just make you a better coder; it makes your software stronger, your development process smoother, and your users happier.
Leave a Reply