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 method area. This defines a contract that any subclass must implement the area method.
  • Rectangle and Circle classes inherit from Shape and provide their own implementations of the area method.
  • The AreaCalculator class now uses the Shape interface to calculate the area. If a new shape is added, we can extend the Shape class without modifying the AreaCalculator 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 method move.
  • We create 2 subclasses: FlyingBird and NonFlyingBird, each implementing the move method appropriatlely.
  • Sparrow inherits from FlyingBird, and Ostrich inherits from NonFlyingBird.

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 the work method.
  • The Eatable interface includes only the eat method.
  • HumanWorker implements both Workable and Eatable interfaces because it needs to work and eat.
  • RobotWorker implements only the Workable 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 the develop method.
  • Both BackendDevelper and FrontendDeveloper implement the Developer interface.
  • The Project class now depends on the Developer 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:

  1. Employee Management:
    • Employee class represents employees, and EmployeeRepository handles storing and retrieving employee data.
    • Different types of employees, such as Manager and Developer, inherit from Employee.
  2. Task Management:
    • Task class represents tasks assigned to employees.
    • Employees can be assigned tasks, adhering to SRP by separating task management from employee management.
  3. Payment Processing:
    • PaymentProcessor interface and its implementations (MonthlyPaymentProcessor, HourlyPaymentProcessor) handle payroll processing, showcasing OCP by allowing new payment methods without modifying existing ones.
  4. Employee Types:
    • ContractEmployee and PermanentEmployee classes demonstrate LSP by ensuring all employee types can be used interchangeably.
  5. Specific Interfaces:
    • Workable and Reportable interfaces ensure that classes implement only the methods they need, following ISP.
  6. Abstraction Dependency:
    • PayrollSystem depends on the PayementProcessor 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:

  1. SRP: Employee, Task and EmployeeRepository classes each have distinct responsibilities.
  2. OCP: PaymentProcessor interface allows adding new payment processors without modifying existing ones.
  3. LSP: ContractEmployee and PermanentEmployee can be used interchangeably with Employee.
  4. ISP: Workable and Reportable interfaces ensure classes only implement relevant methods.
  5. DIP: PayrollSystem depends on the PaymentProcessor 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.

November 18, 2024

Tags: ,

Leave a Reply

Your email address will not be published. Required fields are marked *