SOLID Design Principles
Design Patterns in Python: Part I
SOLID Design Principles for Object-Oriented Programming.
The structural design of OOP software can take any form. The SOLID design principles are a set of (best-practice) OOP class structure heuristics to improve your code.
The goal of SOLID design is simple:
“To create understandable, readable, and testable code that many developers can collaboratively work on.”
The principles, compiled to form the SOLID acronym, are:
- S — Single Responsibility Principle
- O — Open-Closed Principle
- L — Liskov Substitution Principle
- I — Interface Segregation Principle
- D — Dependency Inversion Principle
Single Responsibility Principle
A class should only do one thing (separation of concerns) and therefore only has a single reason to change.
Implementation:
Suppose we implement a Journal
class that is used to store journal entries. The class allows one to add or remove journal entries.
class Journal:
def __init__(self) -> None:
self.entries = []
self.count = 0
def add_entry(self, text):
self.count += 1
self.entries.append(f'{self.count}: {text}')
def remove_entry(self, pos):
del self.entries[pos]
def __str__(self) -> str:
return '\n'.join(self.entries)
Adding functionality to load or save a given journal may be desirable. Although implementing a save_to_file()
or load_from_file()
method in the Journal
is straightforward, it may cause unwanted side effects that violate the SRP (single responsibility principle).
In a real application, persistence management of a journal (saving and loading) may be applied to other object types. A centralised design avoids code duplication. It is also undesirable to create “God objects”: bloated classes that perform all tasks.
It is more elegant to create another class to abstract the persistence methods.
class PersistenceManager:
@staticmethod
def save_to_file(journal, filename):
with open(filename, 'w') as f:
f.write(str(journal))
Open-Closed Principle
Classes should be open for extension and closed for modification.
Modification refers to changing the code of an existing class, Extension refers to adding new functionality.
Modifying existing (tested) code can cause bugs. Avoid touching tested, reliable, (mostly) production code. Interfaces (or abstract classes) can be used to circumvent this issue.
Interfaces allow us to change the explicit logic after implementing a solution, without modifying the initial solution.
Implementation:
Suppose we have a number of products, each with 3 attributes (name, colour & size) and we want to implement a product filter to sort through products. A naive solution would be to write a class ProductFilter
that possesses a method for each filter type.
from enum import Enum
class colour(Enum):
red = 1
green = 2
blue = 3
class Size(Enum):
small = 1
medium = 2
large = 3
class Product:
def __init__(self, name, colour, size) -> None:
self.name = name
self.colour = colour
self.size = size
class ProductFilter:
def filter_by_colour(self, products, colour):
for p in products:
if p.colour == colour:
yield p
def filter_by_size(self, products, size):
for p in products:
if p.size == size:
yield p
def sp():
print('.....'*10)
# Instantiate ----------------------++
if __name__ == '__main__':
apple = Product('Apple', colour.green, Size.small)
tree = Product('Tree', colour.green, Size.large)
house = Product('house', colour.blue, Size.large)
prods = [apple, tree, house]
pf = ProductFilter()
sp()
# Instantiate ----------------------++
Although this solution works, it violates the open-closed principle if we want to add a new filter at a later stage. It may be advised to instead implement base classes.
class Specification:
# INTERFACE (base class)
def is_satisfied(self, item):
pass
class Filter:
# INTERFACE (base class)
def filter(self, items, spec):
pass
The filter can be switched out as desired.
class ColourSpecification(Specification):
def __init__(self, colour) -> None:
super().__init__()
self.colour = colour
def is_satisfied(self, item):
return item.colour == self.colour
class SizeSpecification(Specification):
def __init__(self, size) -> None:
super().__init__()
self.size = size
def is_satisfied(self, item):
return item.size == self.size
class AndSpecification(Specification):
def __init__(self, *args) -> None:
super().__init__()
self.args = args
def is_satisfied(self, item):
return all(map(lambda spec: spec.is_satisfied(item), self.args))
class BetterFilter(Filter):
def filter(self, items, spec):
for item in items:
if spec.is_satisfied(item):
yield item
def sp():
print('.....'*10)
# Instantiate ----------------------++
if __name__ == '__main__':
apple = Product('Apple', colour.green, Size.small)
tree = Product('Tree', colour.green, Size.large)
house = Product('house', colour.blue, Size.large)
prods = [apple, tree, house]
sp()
print('Green products (new):')
bf = BetterFilter()
cs = ColourSpecification(colour.green)
for p in bf.filter(items=prods, spec=cs):
print('Product: {:<5} is green'.format(p.name))
sp()
print('Large products (new):')
bf = BetterFilter()
ss = SizeSpecification(Size.large)
for p in bf.filter(items=prods, spec=ss):
print('Product: {:<5} is large'.format(p.name))
sp()
print('Large blue items:')
cs = ColourSpecification(colour.blue)
ss = SizeSpecification(Size.large)
ns = AndSpecification(cs,ss)
for p in bf.filter(items=prods, spec=ns):
print('Product {:<5} is large'.format(p.name))
# Instantiate ----------------------++
Aside: This has the additional benefit of preventing a state-space explosion in this particular example: growing the code disproportionally in the number of filters.
Liskov Substitution Principle
The Liskov Substitution Principle sates that subclasses should be substitutable for their base classes.
More specifically, if class B
is a subclass of class A
, the expected behaviour should not change when using methods from B instead of methods from A. The child class should only extend the behaviour of the base class.
To improve predictability and avoid unexpected obscured bugs, we should be able to pass any object of class B to any method that expects an object of class A and the method should not return any unexpected output.
Example: Suppose we have a rectangle
class with a method to return the area. We use private properties
and setters
to ensure control of the rectangle’s attributes.
class Rectangle:
def __init__(self, width, height) -> None:
self._width = width
self._height = height
@property
def area(self):
return self._width * self._height
def __str__(self) -> str:
return f'width:{self._width}, height:{self._height}'
@property
def width(self):
return self._width
@width.setter
def width(self, value):
self._width = value
@property
def height(self):
return self._height
@height.setter
def height(self, value):
self._height = value
We declare an external function to compute the area and expected area, given certain parameters.
def fetch_area(rec, h=10):
w = rec.width
rec.height = h
expc = int(w*h)
print(f'\nExpected area: {expc:>{3}}, Received area: {rec.area}')
We then derive a subclass to handle squares:
class Square(Rectangle):
def __init__(self, size) -> None:
super().__init__(size, size)
@Rectangle.width.setter
def width(self, value):
self._width = self._height = value
@Rectangle.height.setter
def height(self, value):
self._width = self._height = value
Finally, we instantiate both classes and use the fetch_area()
method:
rc = Rectangle(12,43)
fetch_area(rc)
sq = Square(5)
fetch_area(sq)
The function FetchArea()
now only works on the base class rectangle
and not the subclass square
— violating the Liskov Substitution Principle.
Interface Segregation Principle
Many client-specific interfaces are better than one general-purpose interface. Clients should not be forced to implement a function they do not need.
This ensures class models are flexible, extendable, & the clients do not need to implement any irrelevant logic.
Consider an interface for a modern printer. It hosts a number of features. The interface can be used to implement a modern multi-function printer.
class Machine:
def print(self, document):
raise NotImplementedError
def fax(self, document):
raise NotImplementedError
def scan(self, document):
raise NotImplementedError
class MultiFunctionPrinter(Machine):
def print(self, docment):
pass
def fax(self, document):
pass
def scan(self, document):
return super().scan(document)
If, however, we wish to implement an older (simpler) printer using the same interface some of the functionality becomes superfluous.
class OldFashionPrinter(Machine):
def print(self, docment):
pass
def fax(self, document):
raise NotImplementedError('Fax not available.')
def scan(self, document):
return super().scan(document)
Creating an instance of OldFashionPrinter
may be confusing and lead to unexpected behaviour. The class contains a fax
method — despite not doing anything. It is verbose and misleading. One may implement warnings
or exceptions
to alert the client, however, raising these elements may cause downstream issues in larger applications (crashing the application in an obscured way).
It is instead preferable to decouple the interfaces:
from abc import abstractmethod
class Printer:
@abstractmethod
def print(self, document):
pass
class Scanner:
@abstractmethod
def scan(self, document):
pass
That way downstream classes inherit exactly what they need.
class Printer(Printer):
def print(self, document):
print(document)
class Photocopier(Printer, Scanner):
def print(self, document):
pass
def scan(self, document):
pass
If a multi-facet interface is still required, it can be created by inheriting from the base classes.
class MultiFunctionDevice(Printer, Scanner):
@abstractmethod
def print(self, document):
return super().print(document)
@abstractmethod
def scan(self, document):
return super().scan(document)
Dependency Inversion Principle
Classes should depend on interfaces or abstract classes, and not concrete classes or functions.
Suppose we define a person
class and a class that stores the relationships
between people. We then define a class Research
to search for all parents named “John”.
from abc import abstractmethod
from enum import Enum
class Relationship(Enum):
PARENT = 0
CHILD = 1
SIBLING = 2
class Person:
def __init__(self, name) -> None:
self.name = name
# Low-level module (storage)
class Relationships(RelationshipBrowser):
def __init__(self) -> None:
self.relations = []
def add_parent_and_child(self, parent, child):
self.relations.append((parent, Relationship.PARENT, child))
self.relations.append((child, Relationship.CHILD, parent))
# High-level module
class Research:
def __init__(self, relationships) -> None:
relations = relationships.relations
for r in relations:
if r[0].name == 'John' and r[1] == Relationship.PARENT:
print(f'John has a child called {r[2].name}')
# instantiate
parent = Person('John')
child1 = Person('Chris')
child2 = Person('Matt')
relationships = Relationships()
relationships.add_parent_and_child(parent, child1)
relationships.add_parent_and_child(parent, child2)
Research(relationships)
This yields the expected behaviour but violates the Dependency Inversion Principle.
Violation: The Research class
(high-level module) depends on the data structure of the relationships class
(low-level module (storage)). The code is now vulnerable to the implementation of the relations
data structure.
It is better to depend on abstract classes
&/or interfaces
to improve the robustness of the code.
This can be achieved by introducing an interface module RelationshipBrowser
that ensures certain functionality is implemented by its sub-classes. That way the research class
can depend on the structure of the interface.
from abc import abstractmethod
from enum import Enum
class Relationship(Enum):
PARENT = 0
CHILD = 1
SIBLING = 2
class Person:
def __init__(self, name) -> None:
self.name = name
# interface
class RelationshipBrowser:
@abstractmethod
def find_all_child_of(self, name):
pass
# Low-level module (storage)
class Relationships(RelationshipBrowser):
def __init__(self) -> None:
self.relations = []
def add_parent_and_child(self, parent, child):
self.relations.append((parent, Relationship.PARENT, child))
self.relations.append((child, Relationship.CHILD, parent))
def find_all_child_of(self, name):
for r in self.relations:
if r[0].name == name and r[1] == Relationship.PARENT:
yield r[2].name
# High-level module
class Research:
# No longer has a dependence on the internal mechanics of how a relationship is stored!
def __init__(self, browser) -> None:
for p in browser.find_all_child_of('John'):
print(f'John has a child called {p}')
# instantiation
parent = Person('John')
child1 = Person('Chris')
child2 = Person('Matt')
relationships = Relationships()
relationships.add_parent_and_child(parent, child1)
relationships.add_parent_and_child(parent, child2)
Research(relationships)
Summary
Single Responsibility Principle
- A class should only have one reason to change.
- Separation of Concerns (SOC): independent tasks should be handled by different classes.
Open-Closed Principle
- Classes should be open for extension but closed for modification.
Liskov Substitution Principle
- Base types should be substitutable by subtypes.
- This ensures that we maintain the expected behaviour.
Interface Segmentation Principle
- Decouple interfaces. Don’t add too much complexity to a single interface.
- YAGNI — You Ain’t Going to Need it!
Dependency Inversion Principle
- Classes/modules should depend on abstract classes/interfaces and not instances.
- Use abstractions.