SOLID is an acronym that stands for five object-oriented design principles that help to make software more maintainable, scalable, and easy to extend. The principles are a subset of many principles promoted by Robert C. Martin (AKA Uncle Bob) which were first introduced in his 2000 paper Design Principles and Design Patterns discussing software rot. The SOLID acronym was introduced later, around 2004, by Michael Feathers.
Here are the high level description of each principle. (Take a look, don’t worry too much. They are explained with examples later) -
1. Single Responsibility Principle (SRP) : Software artefacts (usually a class, module, or interface) should have only one reason to change.
2. Open-Closed Principle (OCP) : Software entities should be open for extension but closed for modification.
3. Liskov Substitution Principle (LSP) : Sub-types must be substitutable for their base types.
4. Interface Segregation Principle (ISP): : Clients should not be forced to depend on interfaces they do not use.
5. Dependency Inversion Principle (DIP): : High-level modules should not depend on low-level modules, but both should depend on abstractions.
Rest assured, you don’t have memorize or understand all these now. Let's dive a little deeper into each one.
A. Single Responsibility Principle : SRP
The SRP states that a class should have only one responsibility or reason to change. In other words, a class should have only one job or function, and should not be responsible for doing more than that one job.
So, How do we achieve SRP?
- Separating concerns: Separate the concerns of the class into smaller, more specialised classes. Each class should be responsible for a single concern, and not overlap with other classes. For example, a class that handles both database operations and user interface interactions could be split into two classes, one for database operations and one for user interface interactions.
- Creating helper classes: A class can have a single responsibility, but the responsibility may be complex or involve multiple tasks. In such cases, the class can create helper classes that perform specific tasks. For example, a class that generates reports could have a helper class that formats the report, and another helper class that retrieves data for the report.
- Encapsulating responsibilities: Another way to achieve SRP is to encapsulate the responsibilities of a class into well-defined interfaces or modules. By encapsulating responsibilities, changes to one module or interface will not affect other modules or interfaces. This allows the code to be more modular and easier to maintain over time.
- Delegating responsibilities: A class can also delegate responsibilities to other classes, rather than performing all operations itself.
Overall, achieving SRP in code involves breaking down classes into smaller, more focused units of responsibility, encapsulating responsibilities, delegating responsibilities, and refactoring code. By following these principles, we can create code that is more maintainable, extensible, and easier to understand over time.
No code examples here, use your own judgement for SRP. However, here's a blog that has a nice exaple in it - The Single Responsibility Principle
Now,
B. The Open-Closed Principle : OCP
OCP is achieved in code by designing software entities such as classes, modules, functions, etc. that are open for extension but closed for modification. This means that we should be able to extend the behavior of a software entity without changing its existing implementation.
A Python Example -
Here, we have created a hierarchy of classes that represent shapes, including Shape
, Rectangle
, and Circle
. Each of these classes implements the area()
method, which calculates the area of the respective shape.
class Shape:
def area(self):
pass
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14 * self.radius ** 2
class AreaCalculator:
def calculate(self, shapes):
total_area = 0
for shape in shapes:
total_area += shape.area()
return total_area
We have also created an AreaCalculator
class, which takes a list of shapes as input and calculates the total area of all the shapes. This class is closed for modification, meaning we can add new shapes to our hierarchy without having to modify the AreaCalculator
class.
For example, if we wanted to add a new shape, such as a triangle, we could simply create a new Triangle
class that implements the area()
method, and the AreaCalculator
class would be able to handle it without any modification. This demonstrates the Open-Closed Principle, which states that software entities (classes, modules, etc.) should be open for extension but closed for modification.
Ways of the OCP -
- Abstraction: We can use abstraction to define a generic interface for a set of related operations, and then create concrete implementations of that interface for specific use cases. This allows us to add new implementations without modifying the existing ones.
- Polymorphism: We can use polymorphism to allow objects of different types to be treated as if they were of the same type. This allows us to write code that can work with different implementations of the same interface without having to know the specifics of each implementation.
- Inheritance: We can use inheritance to create a hierarchy of classes, where each class inherits behavior from its parent classes. This allows us to create new classes that inherit existing behavior, and add new behavior on top of it without modifying the existing classes.
- Composition: We can use composition to combine smaller, more specialized classes into larger, more complex classes. This allows us to create new behavior by composing existing behavior, without having to modify the existing classes.
Overall, the key idea behind achieving the OCP is to separate behavior that is likely to change from behavior that is unlikely to change, and to create well-defined interfaces between them. By doing this, we can make our software more flexible, maintainable, and extensible. In the end, we just don’t want to face a situation like this :
https://twitter.com/i/status/1064871830358503425
Sorry for the lame meme. Let's move on to-
C. Liskov Substitution Principle : LSP
LSP states that objects of a superclass should be able to be replaced with objects of its subclasses without affecting the correctness of the program. In other words, if we have a program that is designed to work with objects of a particular type, we should be able to use objects of any of its subtypes without breaking the program.
To achieve LSP, we can follow a few guidelines when designing our classes and interfaces:
- All subclasses should behave in the same way as their superclass. This means that any methods or properties defined in the superclass should also be present in the subclasses, and should behave in the same way.
- Subclasses should not add any additional preconditions or stronger postconditions to the methods defined in the superclass. In other words, they should not impose any additional constraints on the inputs or outputs of the methods.
- Subclasses should not override any methods of the superclass in a way that changes their semantics. The overridden methods should behave in the same way as the superclass methods.
LSP explained with Code -
First, take a look at this meme:
This is a good analogy for the LSP. In the same way that you can learn to drive one car and then be able to drive any car afterwards, if we design our classes and interfaces according to LSP, we can write code that works with one object and then be able to work with any object that is a subtype of that object without modifying the code.
For example, if we have a program that works with objects of a class Vehicle
, and we follow the LSP when designing our classes, we should be able to use any object that is a subtype of Vehicle
, such as a Car
, Truck
, or Motorcycle
, without having to change the code that works with Vehicle
objects.
This is because all of the subtypes of Vehicle
should have the same behavior as Vehicle
, and should be able to respond to the same methods and properties in the same way. This means that we can use them interchangeably without affecting the correctness of the program.
Just like you can use your knowledge of driving one car to drive any other car, we can use our knowledge of working with one type of object to work with any subtype of that object, as long as we follow the LSP.
class Vehicle:
def __init__(self, make, model):
self.make = make
self.model = model
def start_engine(self):
print("Starting engine...")
def stop_engine(self):
print("Stopping engine...")
class Car(Vehicle):
def __init__(self, make, model, num_doors):
super().__init__(make, model)
self.num_doors = num_doors
def start_engine(self):
print("Starting car engine...")
class Motorcycle(Vehicle):
def __init__(self, make, model, num_wheels):
super().__init__(make, model)
self.num_wheels = num_wheels
def start_engine(self):
print("Starting motorcycle engine...")
In this example, we have a Vehicle
class that defines basic properties and methods for a vehicle, such as make
, model
, start_engine()
, and stop_engine()
. We also have two subclasses of Vehicle
: Car
and Motorcycle
. Each of these subclasses overrides the start_engine()
method to provide their own implementation of starting the engine.
We can create objects of these classes and use them interchangeably, because they all share the same interface and behavior:
def drive(vehicle):
vehicle.start_engine()
print("Driving...")
vehicle.stop_engine()
car = Car("Toyota", "Camry", 4)
motorcycle = Motorcycle("Harley-Davidson", "Sportster", 2)
drive(car) # Starting car engine... Driving... Stopping engine...
drive(motorcycle) # Starting motorcycle engine... Driving... Stopping engine...
In this code, the drive()
function takes an object of the Vehicle
class, which can be any subtype of Vehicle
, and calls its start_engine()
, drive()
, and stop_engine()
methods. We can pass in either a Car
or a Motorcycle
object, and the code works the same way for both, without having to modify the drive()
function. This is an example of LSP in action.
D. Interface Segregation Principle : ISP
ISP states that "clients should not be forced to depend on interfaces they do not use." This means that we should design our interfaces to be as specific and focused as possible (Look at the cookie meme), so that clients only need to depend on the parts of the interface that they actually use.
Check this link if you don't understand the meme-
https://stackoverflow.com/questions/56174598/understanding-the-motivational-poster-for-the-interface-segregation-principle
Idea
The idea behind this one is that “no client should be forced to depend on methods it does not use”. What this actually means is that a class should be correctly abstracted so only have the methods it needs (think Single Responsibility). It also allows for larger classes (more common in historical work) where clients can interface with only the methods they need and not be exposed to those they don’t. ISP is basically "Single Responsibility Principle applied to interfaces".
Common ways to achieve ISP
- Break down large interfaces into smaller, more focused interfaces: Instead of creating a single large interface that includes many methods, break it down into smaller interfaces that each have a specific purpose. This allows clients to depend only on the interfaces they need.
- Use Adapter classes: If you have a class that implements an interface, but not all of the methods in that interface are relevant to that class, you can create an adapter class that implements the interface and provides default implementations for the irrelevant methods. This allows clients to depend on the adapter class instead of the original class.
- Default implementations: For interfaces that have many methods, you can provide default implementations that do nothing or raise an exception. This allows clients to only implement the methods they need and ignore the rest.
- By Delegation: If a class needs to implement multiple interfaces, but not all of the methods in those interfaces are relevant to that class, you can delegate the implementation of the irrelevant methods to other classes or objects. This allows the main class to only implement the methods it needs.
Here's an example of how we can achieve ISP in Python code
We have a Vehicle
class with a start_engine()
method, which is implemented by its subclasses Car
and Motorcycle
. These subclasses only provide the functionality that is relevant to them, and do not implement any unnecessary methods. We also have an Airplane
class with a fly()
method, which is not related to the Vehicle
hierarchy.
class Vehicle:
def start_engine(self):
pass
class Car(Vehicle):
def start_engine(self):
print("Starting car engine...")
class Motorcycle(Vehicle):
def start_engine(self):
print("Starting motorcycle engine...")
class Airplane:
def fly(self):
print("Flying airplane...")
class AirplaneAdapter(Vehicle):
def __init__(self, airplane):
self.airplane = airplane
def start_engine(self):
self.airplane.fly()
However, we can still make use of the Airplane
class by creating an AirplaneAdapter
class, which implements the Vehicle
interface by wrapping an instance of the Airplane
class. This allows us to use an Airplane
object as if it were a Vehicle
object, even though it does not inherit from the Vehicle
class.
By using the AirplaneAdapter
class, we are following the ISP, because clients of the Vehicle
interface only need to depend on the start_engine()
method, which is the only method that is relevant to them. They do not need to depend on the fly()
method, which is specific to the Airplane
class.
def start_and_stop_engine(vehicle):
vehicle.start_engine()
print("Stopping engine...")
car = Car()
motorcycle = Motorcycle()
airplane = Airplane()
adapter = AirplaneAdapter(airplane)
start_and_stop_engine(car) # Starting car engine... Stopping engine...
start_and_stop_engine(motorcycle) # Starting motorcycle engine... Stopping engine...
start_and_stop_engine(adapter) # Flying airplane... Stopping engine...
In this code, we have a start_and_stop_engine()
function that takes any object that implements the Vehicle
interface, and calls its start_engine()
method followed by a print statement. We can pass in either a Car
, Motorcycle
, or AirplaneAdapter
object, and the code works the same way for all of them, without having to modify the start_and_stop_engine()
function. This is an example of ISP in action.
This will sound a bit confusing the first time you read it. If you couldn’t comprehend all that mumbo-jumbo, just remember that the main goal of this principle is to write code in a way so that devs using our code doesn’t feel like this-
E. Dependency Inversion Principle : DIP
DIP states that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions.
In other words, DIP suggests that classes or modules should depend on abstractions or interfaces, rather than on concrete implementations. This allows for greater flexibility and modularity in the design, as changes to the low-level modules do not affect the high-level modules.
Here's an example of how to apply the Dependency Inversion Principle (DIP) in Python:
Let's say we have two classes, FileReader
and FileProcessor
, where FileReader
reads data from a file and FileProcessor
processes the data. The FileProcessor
class depends on the FileReader
class to get the data. However, if we want to change the way the data is read (for example, from a different file format or from a database instead of a file), we would have to change the FileProcessor
class as well.
# Without DIP:
class FileReader:
def read(self, filename):
# read data from file
pass
class FileProcessor:
def __init__(self):
self.reader = FileReader()
def process(self, filename):
data = self.reader.read(filename)
# process data
pass**
# With DIP:
class IReader:
def read(self, filename):
pass
class FileReader(IReader):
def read(self, filename):
# read data from file
pass
class FileProcessor:
def __init__(self, reader):
self.reader = reader
def process(self, filename):
data = self.reader.read(filename)
# process data
pass
In this example, we define an interface IReader
that defines the read
method, and the FileReader
class implements the interface. The FileProcessor
class no longer depends on the FileReader
class directly, but on the IReader
interface, which can be implemented by any class that reads data.
By using an interface, we can easily swap out the implementation of the IReader
interface (for example, by creating a new class that reads data from a database), without having to modify the FileProcessor
class. This makes our code more flexible and easier to maintain.
How to achieve DIP
- Use interfaces or abstract classes to define dependencies: Instead of depending on concrete classes directly, use interfaces or abstract classes to define the required behavior. This allows you to swap out the implementation without changing the code that depends on it.
- Use Dependency injection: Instead of creating and managing dependencies within a class or module, use a dependency injection framework to inject the required dependencies at runtime. This allows you to easily swap out dependencies without changing the code that uses them.
- Use Inversion of control containers: An inversion of control (IoC) container is a framework that manages the creation and configuration of objects and their dependencies. By using an IoC container, you can achieve automatic dependency injection and reduce the amount of boilerplate code required for managing dependencies.
Dependency Inversion is a design principle to limit the effect of changes to low-level classes in your projects.
Think of a base class like ‘make call’. This class worked great 50 years ago when calls were only made in one way, but now we can make wi-fi calls, skype, whatsapp and more. To add these into our bad example would require modifying the base class to enable support for other methods and means that every time we add or edit one of these we would need to test all other versions because they derive from the same class.
Dependency inversion states that classes should depend on abstractions. So in this case, we could define an interface for the call and them implement methods for ‘Landline’, ‘Skype’ etc.
Dependency Inversion isn't really a separate principle, but is rather a combination of Single Responsibility Principle and Liskov Substitution Principle.
Wrapping it all up
So, In conclusion, the SOLID principles are a cool set of guidelines for writing clean, maintainable, and extensible code.
While the SOLID principles are not always easy to apply, especially for beginners, it's important to keep in mind that they are not strict rules, but rather guidelines that can be adapted to suit different situations. However, by understanding and applying these principles when appropriate, we can produce higher quality code with flexibility, scalability that will save us time and effort in the long run and ultimately deliver better software products.
Top comments (2)
Nice introduction! Loved the meme selection too 😊
Insightful and super helpful for the beginner programmers!