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
Shape
class is an abstract class wit han abstract methodarea
. This defines a contract that any subclass must implement the area method. Rectangle
andCircle
classes inherit fromShape
and provide their own implementations of thearea
method.- The
AreaCalculator
class now uses theShape
interface to calculate the area. If a new shape is added, we can extend the Shape class without modifying theAreaCalculator
class.
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
Bird
class is now an abstract class with and abstract methodmove
. - We create 2 subclasses:
FlyingBird
andNonFlyingBird
, each implementing themove
method appropriatlely. Sparrow
inherits fromFlyingBird
, andOstrich
inherits 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
Workable
interface includes only thework
method. - The
Eatable
interface includes only theeat
method. HumanWorker
implements bothWorkable
andEatable
interfaces because it needs to work and eat.RobotWorker
implements only theWorkable
interface 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
Developer
class is an abstract base class that defines a contract with thedevelop
method. - Both
BackendDevelper
andFrontendDeveloper
implement theDeveloper
interface. - The
Project
class now depends on theDeveloper
interface, 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:
Employee
class represents employees, andEmployeeRepository
handles storing and retrieving employee data.- Different types of employees, such as
Manager
andDeveloper
, 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:
PaymentProcessor
interface and its implementations (MonthlyPaymentProcessor
,HourlyPaymentProcessor
) handle payroll processing, showcasing OCP by allowing new payment methods without modifying existing ones.
- Employee Types:
ContractEmployee
andPermanentEmployee
classes demonstrate LSP by ensuring all employee types can be used interchangeably.
- Specific Interfaces:
Workable
andReportable
interfaces ensure that classes implement only the methods they need, following ISP.
- Abstraction Dependency:
PayrollSystem
depends on thePayementProcessor
interface, 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
,Task
andEmployeeRepository
classes each have distinct responsibilities. - OCP:
PaymentProcessor
interface allows adding new payment processors without modifying existing ones. - LSP:
ContractEmployee
andPermanentEmployee
can be used interchangeably withEmployee
. - ISP:
Workable
andReportable
interfaces ensure classes only implement relevant methods. - DIP:
PayrollSystem
depends on thePaymentProcessor
abstraction, 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