Understanding SOLID Principles in Python
The SOLID principles are a set of fundamental guidelines for writing maintainable, scalable, and flexible software.
Introduction
SOLID principles are a set of software design guidelines that aim to improve the quality, maintainability, and extensibility of code. Violating these principles can lead to various problems, such as:
Single responsibility principle (SRP): This principle states that a class or module should have one and only one reason to change. Violating this principle can result in classes that are too large, complex, and coupled, making them difficult to understand, test, and modify.
Open/closed principle (OCP): This principle states that software entities should be open for extension, but closed for modification. Violating this principle can result in code that is rigid and fragile, requiring frequent changes to existing code whenever new requirements arise, increasing the risk of introducing bugs and breaking existing functionality.
Liskov substitution principle (LSP): This principle states that subtypes should be substitutable for their base types. Violating this principle can result in code that is not polymorphic, relying on type checks and conditional logic to handle different behaviors of subclasses, violating the abstraction and cohesion of the code.
Interface segregation principle (ISP): This principle states that clients should not be forced to depend on interfaces that they do not use. Violating this principle can result in code that is not modular, creating unnecessary dependencies and coupling between components, reducing the reusability and flexibility of the code.
Dependency inversion principle (DIP): This principle states that high-level modules should not depend on low-level modules, but both should depend on abstractions. Violating this principle can result in code that is not decoupled, creating direct and hard-coded dependencies between components, making the code difficult to change and test.
Each principle addresses specific aspects of object-oriented design, promoting good practices that lead to cleaner and more modular code. In this article, we'll explore examples in Python to illustrate the impact of not following SOLID principles versus adhering to them.
1. Single Responsibility Principle (SRP)
Consider a class that handles both reading, writing, compressing and decompressing to a file:
# file_manager_srp.py
"""
Single responsibility principle (SRP):
This principle states that a class or module should have one and only one reason to change.
class FileManager violates the SRP principle because it has too many responsibilities
"""
from pathlib import Path
from zipfile import ZipFile
class FileManager:
def __init__(self, filename):
self.path = Path(filename)
def read(self, encoding="utf-8"):
return self.path.read_text(encoding)
def write(self, data, encoding="utf-8"):
self.path.write_text(data, encoding)
def compress(self):
with ZipFile(self.path.with_suffix(".zip"), mode="w") as archive:
archive.write(self.path)
def decompress(self):
with ZipFile(self.path.with_suffix(".zip"), mode="r") as archive:
archive.extractall()
if __name__ == '__main__':
## read
file = FileManager("./file_manager_srp_refactored.py")
print(file.read()) # Output: content offile file_manager_srp_refactored.py
## write
file = FileManager("./hello")
file.write("Hello World!") # file hello created
print(file.read()) # Output: Hello World!
## compress
file.compress() # file hello.zip created
## decompress
file.decompress() # file hello.zip unziped
print(file.read()) # Output: Hello World!
Here, the FileManager
class violates the Single Responsibility Principle by having two distinct responsibilities: reading, writing files, compressing and decompressing files.
Let's refactor the code by separating concerns into two classes:
# file_manager_srp_refactored.py
"""
Single responsibility principle (SRP):
This principle states that a class or module should have one and only one reason to change.
To comply with the SRP principle, we can separate concerns by creating class FileManager and class ZipFileManager.
"""
from pathlib import Path
from zipfile import ZipFile
class FileManager:
def __init__(self, filename):
self.path = Path(filename)
def read(self, encoding="utf-8"):
return self.path.read_text(encoding)
def write(self, data, encoding="utf-8"):
self.path.write_text(data, encoding)
class ZipFileManager:
def __init__(self, filename):
self.path = Path(filename)
def compress(self):
with ZipFile(self.path.with_suffix(".zip"), mode="w") as archive:
archive.write(self.path)
def decompress(self):
with ZipFile(self.path.with_suffix(".zip"), mode="r") as archive:
archive.extractall()
if __name__ == '__main__':
## read
file = FileManager("./file_manager_srp.py")
print(file.read()) # Output: content offile file_manager_srp_refactored.py
## write
file = FileManager("./hello")
file.write("Hello World!") # file hello created
print(file.read()) # Output: Hello World!
## compress
file = ZipFileManager("./hello")
file.compress() # file hello.zip created
## decompress
file.decompress() # file hello.zip unziped
file = FileManager("./hello")
print(file.read()) # Output: Hello World!
Now, we have two classes, each with a single responsibility. This adheres to SRP and makes the code more modular.
2. Open/Closed Principle (OCP)
Consider a class that calculates the area of different shapes:
# shapes_ocp.py
"""
Open/closed principle (OCP):
This principle states that software entities should be open for extension, but closed for modification.
class Shape violates the OCP principle because if we want to add a new type of shape, we have to modify the source code of the Shape class, which can introduce bugs and make the code difficult to maintain.
"""
from math import pi
class Shape:
def __init__(self, shape_type, **kwargs):
self.shape_type = shape_type
if self.shape_type == "rectangle":
self.width = kwargs["width"]
self.height = kwargs["height"]
elif self.shape_type == "circle":
self.radius = kwargs["radius"]
def area(self):
if self.shape_type == "rectangle":
return self.width * self.height
elif self.shape_type == "circle":
return pi * self.radius**2
if __name__ == '__main__':
# rectangle
width = 2
height = 4
rectangle = Shape("rectangle", width=width, height=height)
print(f"The area of the rectangle with width {width} and height {height} is {rectangle.area()}") # Output: The area of the rectangle with width 2 and height 4 is 8
# circle
radius = 4
circle = Shape("circle", radius=radius)
print(f"The area of the circle with radius {radius} is {circle.area()}") # Output: The area of the circle with radius 4 is 50.26548245743669
Here, adding a new shape requires modifying the Shape
class, violating the Open/Closed Principle.
Use abstraction and inheritance to create an extensible design:
# shapes_ocp_refactored.py
"""
Open/closed principle (OCP):
This principle states that software entities should be open for extension, but closed for modification.
To comply with the OCP principle, we can modify the Shape class to use a strategy pattern. We can create an abstract Shape class and have specific Shape classes extend it. The Shape class can then accept a shape of the Shape class, which will be used to calculate the area.
"""
from abc import ABC, abstractmethod
from math import pi
class Shape(ABC):
def __init__(self, shape_type):
self.shape_type = shape_type
@abstractmethod
def area(self):
pass
class Circle(Shape):
def __init__(self, radius):
super().__init__("circle")
self.radius = radius
def area(self):
return pi * self.radius**2
class Rectangle(Shape):
def __init__(self, width, height):
super().__init__("rectangle")
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Square(Shape):
def __init__(self, side):
super().__init__("square")
self.side = side
def area(self):
return self.side**2
if __name__ == '__main__':
# rectangle
width = 2
height = 4
rectangle = Rectangle(width, height)
print(f"The area of the rectangle with width {width} and height {height} is {rectangle.area()}") # Output: The area of the rectangle with width 2 and height 4 is 8
# circle
radius = 4
circle = Circle(4)
print(f"The area of the circle with radius {radius} is {circle.area()}") # Output: The area of the circle with radius 4 is 50.26548245743669
Now, adding a new shape involves creating a new class that inherits from the Shape
abstract class, adhering to OCP.
3. Liskov Substitution Principle (LSP)
Suppose we have a function that expects a Bird
object:
# bird_lsp.py
"""
Liskov substitution principle (LSP):
This principle states that subtypes should be substitutable for their base types.
Bird class violates the LSP principle because if we create a subclass that has a different implementation of the fly method, it may cause unexpected behaviours when passed into a method that expects an object of the Bird class.
"""
class Bird:
def fly(self):
print(f"{self.__class__.__name__} can fly")
class Penguin(Bird):
def fly(self):
raise Exception(f"{self.__class__.__name__} can't fly")
if __name__ == '__main__':
def make_bird_fly(bird):
bird.fly()
bird = Bird()
make_bird_fly(bird) # Output: Bird can fly
penguin = Penguin()
make_bird_fly(penguin) # Output: An exception is raised
If we have a class Penguin
that cannot fly, passing it to this function would break LSP.
Ensure that derived classes can be used interchangeably with the base class:
# bird_lsp_refactored
"""
Liskov substitution principle (LSP):
This principle states that subtypes should be substitutable for their base types.
To comply with the LSP principle, we can create an abstract Bird class and have specific bird classes (e.g., Sparrow, Penguin, tec.) to extend it.
"""
from abc import ABC, abstractmethod
class Bird(ABC):
@abstractmethod
def fly(self):
pass
class Sparrow(Bird):
def fly(self):
print(f"{self.__class__.__name__} can fly")
class Penguin(Bird):
def fly(self):
print(f"{self.__class__.__name__} can't fly")
if __name__ == '__main__':
# Sparrow
bird = Sparrow()
bird.fly() # Output: Sparrow can fly
# Penguin
bird = Penguin()
bird.fly() # Output: Penguin can't fly
Now, both Sparrow
and Penguin
can be used interchangeably where a Bird
is expected.
4. Interface Segregation Principle (ISP)
Consider an interface with methods that are not relevant to all implementing classes:
# worker_isp.py
"""
Interface segregation principle (ISP):
This principle states that clients should not be forced to depend on interfaces that they do not use.
The code violates the ISP as if we try to create a OfficeWorker class using Worker class but an office worker doesn't have to eat in the office.
"""
from abc import ABC, abstractmethod
class Worker(ABC):
@abstractmethod
def work(self):
pass
@abstractmethod
def eat(self):
pass
class OfficeWorker(Worker):
def work(self):
print("Working in the office")
def eat(self):
print("Eating in the office")
if __name__ == '__main__':
office_worker = OfficeWorker()
# work
office_worker.work() # Output: Working in the office
# eat
office_worker.eat() # Output: Eating in the office
Implementing this interface in a class that doesn't need the eat
method would violate ISP.
Create specific interfaces for different roles:
# worker_isp_refactored.py
"""
Interface segregation principle (ISP):
This principle states that clients should not be forced to depend on interfaces that they do not use.
To comply with the ISP principle, we can seperate abstract class Worker to abstract classes Workable and Eatable and make OfficeWorker a multiple inheritance to classes Workable, Eatable.
"""
from abc import ABC, abstractmethod
class Workable(ABC):
@abstractmethod
def work(self):
pass
class Eatable(ABC):
@abstractmethod
def eat(self):
pass
class OfficeWorker(Workable, Eatable):
def work(self):
print("Working in the office")
def eat(self):
print("Eating in the office")
if __name__ == '__main__':
office_worker = OfficeWorker()
# work
office_worker.work() # Output: Working in the office
# eat
office_worker.eat() # Output: Eating in the office
Now, classes can implement only the interfaces relevant to their roles.
5. Dependency Inversion Principle (DIP)
Consider high-level modules depending on low-level modules:
# switch_dip.py
"""
Dependency inversion principle (DIP):
This principle states that high-level modules should not depend on low-level modules, but both should depend on abstractions.
In this example, the Switch class depends directly on the LightBulb class, creating tight coupling.
"""
class LightBulb:
def turn_on(self):
print(f"Switch {light_bulb.__class__.__name__} on")
def turn_off(self):
print(f"Switch {light_bulb.__class__.__name__} off")
class Switch:
def __init__(self, light_bulb: LightBulb):
self.light_bulb = light_bulb
self.on = False
def turn_on(self):
self.on = True
self.light_bulb.turn_on()
def turn_off(self):
self.on = False
self.light_bulb.turn_off()
if __name__ == '__main__':
light_bulb = LightBulb()
switch = Switch(light_bulb)
switch.turn_on() # Output: Switch light on
print(f"{light_bulb.__class__.__name__} is {'on' if switch.on else 'off'}") # Output: Light is on
switch.turn_off() # Output: Switch light off
print(f"{light_bulb.__class__.__name__} is {'on' if switch.on else 'off'}") # Output: Light is off
Here, Switch
depends directly on a concrete implementation (LightBulb
), violating DIP.
Introduce an abstraction to decouple high-level and low-level modules:
# switch_dip_refactored.py
"""
Dependency inversion principle (DIP):
This principle states that high-level modules should not depend on low-level modules, but both should depend on abstractions.
To comply with the Dependency Inversion Principle, we introduce an protocol to decouple the high-level Switch class from the low-level LightBulb class.
"""
from abc import ABC, abstractmethod
class Switchable(ABC):
@abstractmethod
def turn_on(self):
pass
@abstractmethod
def turn_off(self):
pass
class LightBulb(Switchable):
def turn_on(self):
print(f"Switch {self.__class__.__name__} on")
def turn_off(self):
print(f"Switch {self.__class__.__name__} off")
class Fan(Switchable):
def turn_on(self):
print(f"Switch {self.__class__.__name__} on")
def turn_off(self):
print(f"Switch {self.__class__.__name__} off")
class Switch:
def __init__(self, device: Switchable):
self.device = device
self.on = False
def turn_on(self):
self.device.turn_on()
self.on = True
def turn_off(self):
self.device.turn_off()
self.on = False
if __name__ == '__main__':
light_bulb = LightBulb()
switch = Switch(light_bulb)
switch.turn_on() # Output: Switch light on
print(f"{light_bulb.__class__.__name__} is {'on' if switch.on else 'off'}") # Output: Light is on
switch.turn_off() # Output: Switch light off
print(f"{light_bulb.__class__.__name__} is {'on' if switch.on else 'off'}") # Output: Light is off
fan = Fan()
switch = Switch(fan)
switch.turn_on() # Output: Switch fan on
print(f"{fan.__class__.__name__} is {'on' if switch.on else 'off'}") # Output: Fan is on
switch.turn_off() # Output: Switch fan off
print(f"{fan.__class__.__name__} is {'on' if switch.on else 'off'}") # Output: Fan is off
Now, Switch
depends on the abstraction Switchable
, promoting flexibility.
Summary
In conclusion, adhering to SOLID principles in Python leads to more maintainable, extensible, and robust code. The examples provided demonstrate the impact of both not following and following these principles. By applying SOLID principles, developers can create software that is easier to understand, modify, and scale, contributing to the overall success of a project.
Source code: SOLID Principles
References
Design Principles and Design Patterns - Robert C. Martin
SOLID - wikipedia
SOLID Design Principles in Ruby - Alessandro Allegranzi
SOLID & Ruby in 5 short examples - Tech - RubyCademy
SOLID Object-Oriented Design Principles with Ruby Examples - Oleg P., Maryna Z.
Mastering SOLID Principles in Python: A Guide to Scalable Coding - Olatunde Adedeji