Understanding S.O.L.I.D Principles with Python Examples.
Understanding SOLID principles for beginners is not quite easy. My experience with learning about SOLID principles was finding an elementary tutorial that a beginner could understand wasn't easy. In this post, I use simple python codes to explain (with code) what SOLID principles are.
What are SOLID principles?
SOLID principles are a set of 5 Object-Oriented Programming design principles that were advocated for by Uncle Bob (Robert Fowler) by the year 2000. SOLID is an acronym that stands for:
- Single Responsibility Principle (S)
- Open Closed Principle (O)
- Liskov Substitution Principle (L)
- Interface Segregation Principle (I)
- Dependency Inversion Principle (D)
Single Responsibility Principle (SRP).
A class should have one and only one reason to change, meaning that a class should have only one job.
Class User: def __init__(self, details: dict): self.details = details def get_details(self): pass def save(self): # saves to database pass
In the above code snippet, the user class not only gets the user but also saves him in a database. The class handles both setting user data requirements and database connection and saving. We ought to have a User class that handles the user data and a database class that handles the DB connection and transactions.
Class User: def __init__(self, details: dict): self.details = details def get_details(self): pass Class Db: def save(self): pass
As refactored above, we have two (2) classes, each with one (1) responsibility. If a class has only one responsibility, they tend to be more reusable , simpler, and propagate fewer changes to the system.
Open Closed Principle(OCP)
Software entities(classes, modules, functions) should be open for extension, not modification.
Class SalaryRaise: def __init__(self, employee_class, salary): self.employee_class = employee_class self.salary = salary def give_raise(self): if self.employee_class = 'A': return self.salary * 0.02 #2 percent salary raise
If we wanted to add another employee of employee class B with a four (4) percent increase in the Salary Raise program, we would have to modify the give_raise method. This violates the open closed principle. Hence the above code can be modified to be.
Class SalaryRaise: def __init__(self, employee_class, salary): self.employee_class = employee_class def get_salary(self): return self.salary def employee_A(self): return self.get_salary * 0.02 # To add another employee class we just # extend the class by adding a function def employee_B(self): return self.get_salary * 0.04
Liskov Substitution Principle(LSP)
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.
Simply put, this states that objects of the derived class (or super class) can be used in place of objects of their super-class (parent class) without breaking. This means that the objects should behave in the same way.
For instance, they should have the same value return type, should have the same number of arguments, raise the same exceptions etc.
class Shape: def area(self, l, w): return l * w class Square(Shape): def area(self, s): return s * s
In the above code snippet, the class Square violates the Liskov Principle as its area method cannot be substituted by its Parent class (Shape) method. This is because of the number of arguments being taken in.
Another example is shown below
class Calculator: def calculate(self, a, b): return a * b class Divide(Calculator): def calculate(self, a, b): return a / b
Notice that in the above two (2) classes, even though the calculate methods take equal no of arguments, the method in the Divide class could possibly throw a divide by zero error which isn't a possibility in its parent class (Calculator class) hence a violation of SOLID principles.
Interface Segregation Principle(ISP)
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.
Given an interface IMotion that defines various motion types
class IMotion: def drive(self): pass def cycle(self): pass def fly(self): pass
To implement this interface in multiple classes, say Bicycle, Car and Aeroplane, we would have
class Bicycle(IMotion): def drive(self): pass def cycle(self): pass def fly(self): pass class Car(IMotion): def drive(self): pass def cycle(self): pass def fly(self): pass class Aeroplane(IMotion): def drive(self): pass def cycle(self): pass def fly(self): pass
As you can see, the classes Car, Bicycle and Aeroplane are forced to have methods that they don't need. This violates the ISP. In case, for instance, we wanted to add a
sail motion to the interface, all the classes implementing this interface would need to add it too otherwise, an exception would be thrown. The above code can be refactored to be:
class IMotion: def motion(self): pass class Aeroplane(IMotion): def motion(self): pass class Car(IMotion): def motion(self): pass class Bicycle(IMotion): def motion(self): pass
In case we now need to add a sail motion, all we need to add is a new class called ship that implements the IMotion interface rather than altering all other classes.
Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. They should depend on abstractions and abstractions should not depend on details, rather details should depend on abstractions.
This means that any changes on lower level modules shouldn't affect higher level modules providing complex logic. This can be achieved by adding abstractions. This means, therefore that basically, you end up with two dependencies: higher level modules depending on the abstraction layer and lower level modules depending on the abstraction layer.
Assume we want to create various Http Service types. Like XHTML and Mock request.
class Xhtml: def request(self, url, method): pass class Http: def __init__(self, Xhtml: Xhtml): self.xhtml = Xhtml def post(self, url): self.xhtml.request(url, 'POST') def get(self, url): self.xhtml.request(url, 'GET')
This will always work just for Xhtml requests, but it violates DIP. Http being the higher level module is dependent on Xhtml, which is the lower level. Hence, if we need to have to do Mock requests, it would necessitate a lot of changes in the higher level Http. Let's refactor this by introducing an abstraction layer
class Connector: #abstraction layer def request(self, url, method): pass class Xhtml(Connector): def request(self, url, method): pass #new class added class Mock(Connector): def request(self, url, method): pass # high level module class Http: def __init__(self, conn: Connector): self.conn = conn def post(self, url): self.conn.request(url, 'POST') def get(self, url): self.conn.request(url, 'GET')
By adding the
Connector interface, we can now ensure that both high and low level modules only depend on the abstraction introduced. If you are keen enough, you'll notice that DIP is some sort of a combination of Open/Closed and Liskov
That is it. You can read and re-read to understand the principles better.
Good Luck and Happy Coding :-)