What does SOLID stand for?
SOLID is a mnemonic acronym of 5 acronyms themselves: SRP - Single Responsibility Principle, OCP - Open-Closed Principle, LSP - Liskov Substitution Principle, ** ISP** - Interface Segregation Principle, DIP - Dependency Inversion Principle.
These ideas were first mentioned by Robert C. Martin (Uncle Bob) in his paper "Design Principles and Design Patterns". Later, Michael C. Feathers coined the SOLID acronym, which was then re-used by Uncle Bob in the chapter "Design Principles" in his book "Clean Architecture".
Single Responsibility Principle (SRP)
The single responsibility principle was coined by Robert C. Martin himself but he also pays credit to Tom DeMarco's concept of cohesion, which he described in "Structured Analysis and System Specification". It is the first of the SOLID principles and encourages to give classes only a single and definite reason to change. If you can think of more than a single reason for the class to change, the class has more than one responsibility. SRP is often confused with the "Do one thing"-rule At a first glance, this concept seems rather abstract and useless, but the example that is provided in the initial paper is kind of helpful:
Imagine a rectangle class that has the two public methods draw and area. The draw method should return coordinates or any graphical representation. The area method returns the area of the current rectangle instance. The rectangle class thereby has two responsibilities. It's responsible for calculating the area and responsible to draw itself.
class Rectangle(object):def __init__(self, height: float, width: float):self.height = heightself.width = widthdef draw(self) -> VisualRepresentation:return visual_representation(self)def area()-> float:return self.height*self.width
If you are confused by the types behind the arguments in the constructor, you should have a look at Python type hints. However, changes in the rectangle class would affect the GUI as well as the geometric calculation app. Changes in the rectangle class could potentially break the whole system or lead to unpredictable bugs. The solution Martin proposes in this specific case is to split the class into two separate ones:
This separation leads to a single responsibility for each the geometric rectangle and the rectangle class. Changes in the draw method now can no longer affect the way they are calculated.
class GeometricRectangle(object):def __init__(self, height: float, width: float):self.height = heightself.width = widthdef area()-> float:return height*widthclass Rectangle(GeometricRectangle):def draw(self) -> VisualRepresentation:return visual_representation(self)
The attentive readers might have noticed that this concept's strict and mindless usage will also lead to poor software design. It would help if you never used the single responsibility theory without proper support of other constructs like modules or facades. Otherwise, the structure proposed by the SRP will cause the code to break into a thousand confusing pieces
Open-Closed Principle (OCP)
Whatever your personal definition of stable code is, this SOLID principle should be part of it. A good software architecture will almost certainly always fulfill the open-closed rule. Originally, Bertrand Mayer coined this concept in his book "Object-Oriented Software Construction". OCP applies to all kinds of objects in programming you can imagine. This philosophy's main focus is to allow you to scale your classes and modules without caring about legacy code. To simplify this, we are just going to look at applying it to classes.
The key message of the open-closed principle, in this case, is that your classes should be open for extension but closed for modification. Meaning, once you created your class, it shouldn't change any more. However, it could change by simply creating a child class that thereby extends its behavior. Imagine you had a user class that holds a name and an age.
class User:def __init__(self, username: str, age: int):self.username = usernameself.age = agedef __repr__(self):return f"User: {self.username}, {self.age} years old"
Now imagine you want to extend this class by an attribute that saves the user's favorite game. A naive solution to this problem would be to simply add an attribute "favorite_game" to the user class:
class User:def __init__(self, username: str, age: int, favorite_game: Game):self.username = usernameself.age = ageself.favorite_game = favorite_gamedef __repr__(self):return f"User: {self.username}, {self.age} years old, favorite game: {self.favorite_game}"
This might work if your system is small or in development. But if you want to change this in a productive system, things are going to break. Not only did the signature change because the constructor now expects a favorite_game, but also the __repr__ method changed and might break things further. This violates Meyer's postulate. A possible solution to this again could be inheritance:
class User:def __init__(self, username: str, age: int):self.username = usernameself.age = agedef __repr__(self):return f"User: {self.username}, {self.age} years old"class Gamer(User):def __init__(self, username: str, age: int, favorite_game: Game):super().__init__(username, age)self.favorite_game = favorite_gamedef __repr__(self):return f"User: {self.username}, {self.age} years old, favorite game: {self.favorite_game}"
By using this, your functionality is extended, but you don't apply any changes to the original class. Please note: Inheritance isn't always a good solution or a solution at all, but it is the easiest example to make. SOLID is not directly related to inheritance or polymorphism.
Liskov Substitution Principle (LSP)
When you are most familiar with Python or hate maths, the Liskov substitution principle might be slightly confusing. This is because it originally was designed for statically typed programming languages like Java, C, or FORTRAN. Barbara Liskov introduced it in 1987. It is often considered the hardest of the SOLID principles to understand. Visualizing Liskov's definition leads to the following class diagram:
We have a type T and a subtype S, and objects of x that are of type T and objects y of type S. Also, all the elements posses an attribute φ. A representation in Python code could look like this:
class T:def __init__(self, phi: list):self.phi = phiclass S(T):passif __name__ == "__main__":x = T(phi=["a", "b"])y = S(phi=["c", "d"])
And this indeed fulfills the Liskov substitution principle. Any object of type S could replace its parent class. How could you possibly violate this?
class T:def __init__(self, phi: list):self.phi = phiclass S(T):def __init__(self, phi: str):self.phi = phiif __name__ == "__main__":x = T(phi=["a", "b"])y = S(phi="c, d")
If you look closely, you will see that the subtype S implements its own class attribute phi of type string instead of list. This violates Liskov's theory. You can no longer replace T with objects of type S. One becomes aware of this concept's meaningfulness when you think about implementing a method "print_phis" that should solely print all elements in the class attribute phi. Either instances of class T or S would run into a runtime error or lead to a higher degree of complexity in the "print_phis" method due to additional conditionals.
Interface Segregation Principle (ISP)
ISP or interface segregation principle is the SOLID equivalent of high cohesion in GRASP (General Responsibility Assignment Software Principles). Thereby it naturally supports loose coupling and maintainability. The main message behind ISP is that large Interfaces should be split into multiple ones. Functions and classes should not depend on methods they don't use. Look at the following example for a "fat" interface:
class GeometricInterface(ABC):@abstractmethoddef get_area() -> float:raise NotImplementedError@abstractmethoddef get_diameter() -> float:raise NotImplementedErrorclass Square(GeometricInterface):def __init__(self, height: float, width: float):self.height = heightself.width = widthdef get_area():return self.height*self.widthdef get_diameter() -> float:raise NotImplementedErrorclass Circle(GeometricInterface):def __init__(self, radius: float):self.radius = radiusdef get_area():return self.radius * PI **2def get_diameter() -> float:return self.radius*2
Please excuse the far-fetched example, nothing I'm proud of. However, as you can see, the GeometricInterface has two abstract methods of which get_diameter()
is not used by
its subtype square. Thereby the interface segregation rule is violated. There are many possible solutions to this. We could, for instance, segregate the GeometricInterface into
three Interfaces:
class GeometricInterface(ABC):@abstractmethoddef get_area() -> float:raise NotImplementedErrorclass ElipseInterface(GeometricInterface, ABC):@abstractmethoddef get_diameter() -> float:raise NotImplementedErrorclass RectangleInterface(GeometricInterface, ABC):passclass Square(Rectangle):def __init__(self, height: float, width: float):self.height = heightself.width = widthdef get_area(self):return self.height * self.widthclass Circle(Elipse):def __init__(self, radius: float):self.radius = radiusdef get_area(self):return self.radius * PI ** 2def get_diameter(self) -> float:return self.radius * 2
Dependency Inversion Principle (DIP)
In my opinion, the dependency inversion principle is the easiest one to understand. However, it can be the hardest to implement. In essence, the DIP states that modules should not rely on modules that belong to a subordinate concept and should not rely on generalizations. A quick piece of code that is often referenced:
class LightBulb:def __init__(self, initial_state: bool=False):self.power = initial_statedef turn_on(self):self.power = Truedef turn_off(self):self.power = Falseclass Switch:def __init__(self, light_bulb: LightBulb, pressed: bool=False):self.light_bulb = light_bulbself.pressed = presseddef toggle(self):self.pressed = not self.pressed # Toggleif self.pressed:self.light_bulb.turn_on()else:self.light_bulb.turn_off()
The DIP violation here is that a switch is a concept that is logically in a layer above the light bulb, and the switch relies on it. This will lead to poor extensibility or even circular imports that prevent the program from being interpreted or compiled.
Instead of the light bulb telling the switch how the bulb should be handled, the switch should tell the light bulb how to implement it. The naive approach would be to define an interface that tells the light bulb how it should behave to be used with a switch.
class Device(ABC):power: booleandef __init__(self, initial_state: bool=False):self.power = initial_statedef turn_on(self):raise NotImplementedErrordef turn_off(self):raise NotImplementedErrorclass Switch:def __init__(self, device: Device, pressed: bool=False):self.device = deviceself.pressed = presseddef toggle(self):self.pressed = not self.pressed # Toggleif self.pressed:self.device.turn_on()else:self.device.turn_off()class LightBulb(Device):def turn_on(self):self.power = Truedef turn_off(self):self.power = False
Visualized as a class diagram, this source code would lead to the object-oriented design:
The dependency has been inverted. Instead of the switch relying on the light bulb, the light bulb now relies on an interface in a higher module. Also, both rely on abstractions, as required by the DIP. Last but not least, we also fulfilled the requirement "Abstractions should not depend upon details. Details should depend upon abstractions" - The details of how the device behaves rely on the abstraction (Device interface).
Conclusion
As with all guidelines, it's not recommended to follow the SOLID principles blindly either. Principles, patterns, and guidelines can easily turn into anti-patterns and lead to new problems. Sometimes it not preventable to violation some of these. Our job as a developer is to create code and programs that are easy to read and thereby easy to maintain and extend. Robustness and correctness are more or less nice side effects of this approach. These are great guidelines, but that's what they are, guidelines. Specific problems need specific solutions. All principles and patterns can offer are suggestions and best practices.
If you support my statement or are of a completely different opinion I would love to hear about it in the comments section.
References
- SRP: The Single Responsibility Principle - objectmentor.com, 02.02.2015
- The Open-Closed Principle - objectmentor.com, 02.02.2015
- The Liskov Substitution Principle - objectmentor.com 11.02.2015
- The Interface Segregation Principle - objectmentor.com 10.02.2015
- The Dependency Inversion Principle: objectmentor.com 10.02.2015
- Design Principles and Design Patterns
- Barbara Liskov, Professor at MIT
- GRASP: General Responsibility Assignment Software Principles, code-specialist.com
- KISS Principle code-specialist.com
- Do one things, code-specialist.com
- Python type hints, code-specialist.com