SOLID is an acronym for the first five object-oriented programming (OOP) principles by Robert C. Martin.
- Single Responsibility Principle
- Open-Closed Principle
- Liskov Substitution Principle
- Interface Segregation Principle
- Dependency Inversion Principle
These principles are considered by many people The Bible of the OOP, and every good and experienced programmer should know about them (and also about Design Patterns). In this post, we are going to explore these principles one by one through examples for better understanding.
In this post I’m going to use python for the examples.
The First Letter: Single Responsibility Principle
A class should have one and only one reason to change, meaning that a class should have only one job.
Robert c. Martin
To exemplify, let’s take a class that calculates the sum of the areas of a series of figures (Circles and Squares).
import math
class Square:
def __init__(self, side_length):
self.side_length = side_length
class Circle:
def __init__(self, radius):
self.radius = radius
class AreaCalculator:
def __init__(self, shapes):
self.shapes = shapes
def sum(self):
total_area = 0
for shape in self.shapes:
if type(shape) == Square:
total_area += shape.side_length ** 2
elif type(shape) == Circle:
total_area += math.pi * shape.radius ** 2
return total_area
def pretty_sum(self):
return 'The total area is %f' % self.sum()
The code is easy to understand since it only uses the basic formulas for a square or circle area correspondingly, but note that the AreaCalculator class also displays a more descriptive message for the user using the pretty_sum method.
What if we want to change the format of this message later to JSON to be used as a response to an HTTP request? we have to modify the code of the AreaCalculator, even though the logic of calculating the areas does not vary. This goes against the Single-Responsibility principle since the AreaCalculator should only be concerned about calculating the area.
The logic for outputting should be handled in a separate class
(...)
class AreaCalculator:
def __init__(self, shapes):
self.shapes = shapes
def sum(self):
total_area = 0
for shape in self.shapes:
if type(shape) == Square:
total_area += shape.side_length ** 2
elif type(shape) == Circle:
total_area += math.pi * shape.radius ** 2
return total_area
class CalculatorOutputter:
def __init__(self, calculator):
self.calculator = calculator
def pretty_sum(self):
return 'The total area is %f' % self.calculator.sum()
def json(self):
return {'sum': self.calculator.sum()}
Open-Closed Principle
Objects or entities should be open for extension but closed for modification.
Robert c. Martin
This means that, whenever possible, a class should be extendable without modifying the class itself.
Let’s analize again the AreaCalculator
(...)
class AreaCalculator:
def __init__(self, shapes):
self.shapes = shapes
def sum(self):
total_area = 0
for shape in self.shapes:
if type(shape) == Square:
total_area += shape.side_length ** 2
elif type(shape) == Circle:
total_area += math.pi * shape.radius ** 2
return total_area
Suppose we want to add triangles to our program. For our AreaCalculator to continue working properly, its code must be modified to use the appropriate formula for the new shape.
(...)
class Triangle:
def __init__(self, base, height):
self.base = base
self.height = height
class AreaCalculator:
def __init__(self, shapes):
self.shapes = shapes
def sum(self):
total_area = 0
for shape in self.shapes:
if type(shape) == Square:
total_area += shape.side_length ** 2
elif type(shape) == Circle:
total_area += math.pi * shape.radius ** 2
elif type(shape) == Triangle:
total_area += shape.base * shape.height / 2
return total_area
Every time we want to add a new shape we have to modify the code, adding more if/else statements to the sum method. This goes against the Open-Closed Principle and is very difficult to maintain. One solution is to delegate the calculation of the area to the shape itself and use this method in the AreaCalculator
(...)
class Triangle:
def __init__(self, base, height):
self.base = base
self.height = height
def area(self):
return self.base * self.height / 2
class AreaCalculator:
def __init__(self, shapes):
self.shapes = shapes
def sum(self):
total_area = 0
for shape in self.shapes:
total_area += shape.area()
return total_area
Now we can add new figures without modifying the code of the AreaCalculator class, and it will continue working as expected.
Liskov Substitution Principle
Let q(x) be a property provable about objects of x of type T. Then q(y) should be provable for objects y of type S where S is a subtype of T.
ROBERT C. MARTIN
This means that every subclass or derived class should be substitutable for their base or parent class.
Building off the example AreaCalculator class, consider a new VolumeCalculator class that extends the AreaCalculator class:
(...)
class VolumeCalculator(AreaCalculator):
def __init__(self, shapes):
super().__init__(shapes)
def sum(self):
total_volume = 0
for shape in self.shapes:
# assume volume method implemented in shapes
total_volume += shape.volume()
return [total_volume]
Recall that the CalculatorOutputter class resembles this:
(...)
class CalculatorOutputter:
def __init__(self, calculator):
self.calculator = calculator
def pretty_sum(self):
return 'The total area is %f' % self.calculator.sum()
def json(self):
return {'sum': self.calculator.sum()}
If you tried to run an example like this:
areas = new AreaCalculator(shapes);
volumes = new VolumeCalculator(solidShapes);
output = new CalculatorOutputter(areas);
output2 = new CalculatorOutputter(volumes);
When you call the pretty_sum method on the output2 object, you will get an error informing you of an array to float conversion.
To fix this, instead of returning an array from the VolumeCalculator class sum method, return total_volume
(...)
class VolumeCalculator(AreaCalculator):
def __init__(self, shapes):
super().__init__(shapes)
def sum(self):
total_volume = 0
for shape in self.shapes:
# assume volume method implemented in shapes
total_volume += shape.volume()
return total_volume
The total_volume should be a float or integer.
That satisfies the Liskov substitution principle.
Interface Segregation Principle
A client should never be forced to implement an interface that it doesn’t use, or clients shouldn’t be forced to depend on methods they do not use.
ROBERT C. MARTIN
(...)
class ShapeInterface:
def area(self) -> float:
pass
def volume(self) -> float:
pass
class Triangle(ShapeInterface):
def __init__(self, base, height):
self.base = base
self.height = height
def area(self):
return self.base * self.height / 2
def volume(self):
return 0
(...)
Let’s build from the previous ShapeInterface example, you will need to support the new three-dimensional shapes of Cuboid and Spheroid, and these shapes will need to also calculate volume.
Let’s consider what would happen if you were to modify the ShapeInterface to add another contract:
Now, any shape you create must implement the volume method, but you know that triangles are flat shapes and that they do not have volumes, so this interface would force the Triangle class to implement a method that it has no use of.
This would violate the interface segregation principle. Instead, you could create another interface called ThreeDimensionalShapesInterface that has the volume contract and three-dimensional shapes can implement this interface:
(...)
class ShapeInterface:
def area(self) -> float:
pass
class ThreeDimensionalShapeInterface:
def volume(self) -> float:
pass
class Triangle(ShapeInterface):
def __init__(self, base, height):
self.base = base
self.height = height
def area(self):
return self.base * self.height / 2
class Cuboid(ThreeDimensionalShapeInterface):
# implementation...
(...)
This is a much better approach that satisfies the interface segregation principle.
Dependency Inversion Principle
Entities must depend on abstractions, not on concretions. It states that the high-level module must not depend on the low-level module, but they should depend on abstractions.
ROBERT C. MARTIN
This principle allows for decoupling.
Here is an example of a PasswordReminder that connects to a MySQL database:
class MySQLConnection:
def connect(self):
# handle the database connection
return 'Database connection';
class PasswordReminder:
_dbConnection;
def __init__(dbConnection)
self._dbConnection = dbConnection;
First, the MySQLConnection is the low-level module while the PasswordReminder is high level, but according to the definition of D in SOLID, which states to Depend on abstraction, not on concretions. This snippet above violates this principle as the PasswordReminder class is being forced to depend on the MySQLConnection class.
Later, if you were to change the database engine, you would also have to edit the PasswordReminder class, and this would violate the open-close principle.
The PasswordReminder class should not care what database your application uses. To address these issues, you can code to an interface since high-level and low-level modules should depend on abstraction:
class DBConnectionInterface:
def connect(self) -> str:
pass
The interface has a connect method and the MySQLConnection class implements this interface. Also, instead of directly type-hinting MySQLConnection class in the constructor of the PasswordReminder, you instead type-hint the DBConnectionInterface, and no matter the type of database your application uses, the PasswordReminder class can connect to the database without any problems and the open-closed principle is not violated.
class MySQLConnection(DBConnectionInterface):
def connect(self):
# handle the database connection
return 'Database connection';
class PasswordReminder:
_dbConnection;
def __init__(dbConnection)
self._dbConnection = dbConnection;
This code establishes that both the high-level and low-level modules depend on abstraction.
Conclusion
In this article, you were presented with the five principles of SOLID Code. Projects that adhere to SOLID principles can be shared with collaborators, extended, modified, tested, and refactored with fewer complications.