SOLID Design Principles

Design Patterns in Python: Part I

Zach Wolpe
8 min readFeb 20, 2023

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 Journalis 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.

--

--

Zach Wolpe
Zach Wolpe

Written by Zach Wolpe

Machine Learning Engineer. Writing for fun.

No responses yet