"I've got a job for you. It's complicated, and there's no guarantee you'll come back alive. But it'll be the most exciting thing you'll ever do." - Saul Goodman
At some point in our dev adventure we'll come across a set of guidelines, patters and ani-patterns, especially when it comes to refactoring. That's why we gotta start with the basics - The SOLID Principles. They're like the purest form of crystal, keeping our code clean and DRY (Don't Repeat Yourself). With SOLID guidelines, our software development becomes much more modular, scalable, maintainable, testable, reusable, you name it!
You know what they say:
"I am the one who
knocksknows SOLID principles" - not Walter White
If you want to be a good cook (aka developer), you need to learn how to write code that's able to do all those things. So, start with SOLID, and you build from there.
"Yeah, science!" - Jesse Pinkman
Lets explain what SOLID principles are, and how to apply them using Python.
Single Responsibility Principle (aka SRP)
"A class should have only one reason to change" - Robert C. Martin
A class, method or module should have one responsibility or job to do, and any change to that responsibility should only require changes to that class/method.
This also means that we're creating a high-cohesion responsibility ensuring that the function does one thing only, and can be reused easily.
🧪 Walt is responsible for producing crystal, while Jesse is responsible for distributing it.
Let's refactor the Production
class and apply SRP:
class Production:
def __init__(self):
self.batch = []
self.sizes = []
self.costs = []
self.status = "preparing"
def create(self, id, size, cost):
self.batch.append(id)
self.sizes.append(size)
self.costs.append(cost)
def total_cost(self):
total = 0
for i in range(len(self.costs)):
total += self.sizes[i] * self.costs[i]
return total
def operation(self, operation_type, person):
if operation_type == "manufacturing":
print(f"Is {person} able to do this?")
print(f"{person} is manufacturing...")
self.status = "cooking"
elif operation_type == "distribution":
print(f"Is {person} able to do this?")
print(f"{person} is distributing...")
self.status = "selling"
else:
print(f"Operation: {operation_type} is not supported")
self.status = "failed"
production = Production()
production.operation("distribution", "Jesse") # Jesse's on the move!
Looking at the code above, we have a Production
class with create
method to create our batches, total_cost
method to calculate the total cost of each batch, and operation
method that is responsible for manufacturing and distribution. We're looking at refactoring the operation
method as it shouldn't really belong to the Production
.
The trick here is to remove the if-else
condition and create new class to handle operation giving it a Single Responsibility.
class Production:
def __init__(self):
self.batch = []
self.sizes = []
self.costs = []
self.status = "preparing"
def create(self, id, size, cost):
self.batch.append(id)
self.sizes.append(size)
self.costs.append(cost)
def total_cost(self):
total = 0
for i in range(len(self.costs)):
total += self.sizes[i] * self.costs[i]
return total
class Operations:
def manufacturing(self, _production, person):
print(f"{person} is manufacturing...")
_production.status = "cooking"
def distribution(self, _production, person):
print(f"{person} is distributing...")
_production.status = "selling"
production = Production()
operations = Operations()
operations.manufacturing(production, "Jesse Pinkman") # Jesse's on the move!
Now we have de-coupled Operations
functionality from Production
. Note how we're passing a production
variable class into the operations
to be able to update the self.status
.
Open-Closed Principle (aka OCP)
"I am the one who knocks!" - Walter White
Software entities (such as classes, methods, functions, etc.) should be open for extension but closed for modification.
This means that we should design our software in a way that allows us to add new functionality, or behaviour without changing the existing code. We can do this with the use of composition, inheritance and interfaces by abstracting structure of classes (superclass), and subclasses.
🧪 When Walter and Jesse start working with a new distributor, they don't need to change the way they produce their crystal. They simply add a new distributor to their network.
class Production:
def __init__(self):
self.batch = []
self.sizes = []
self.costs = []
self.status = "preparing"
...
class Operations:
def manufacturing(self, _production, person):
print(f"{person} is manufacturing...")
_production.status = "cooking"
def distribution(self, _production, person):
print(f"{person} is distributing...")
_production.status = "selling"
production = Production()
operations = Operations()
operations.manufacturing(production, "Jesse Pinkman")
Taking the code from above, we're going to apply Open-Closed Principle to the class Operations
. Why? Because if we wanted to add another business operation (say shipment, recruitment or compliance) we would need to modify the code which violates OCP.
The solution is to create subclasses
, and abstracting out the superclass (the parent) with the use of abc
module:
from abc import ABC, abstractmethod
class Production:
def __init__(self):
self.batch = []
self.sizes = []
self.costs = []
self.status = "preparing"
...
class Operations(ABC):
@abstractmethod
def process(self, _production, person):
pass
class ManufacturingProcess(Operations):
def process(self, _production, person):
print(f"{person} is manufacturing...")
_production.status = "cooking"
class DistributionProcess(Operations):
def process(self, _production, person):
print(f"{person} is distributing...")
_production.status = "selling"
production = Production()
distribution = DistributionProcess()
distribution.process(production, "Jesse Pinkman")
So we've abstracted out the Operations, allowing us to inherit process
method by the ManufacturingProcess
and DistributionProcess
classes. This means we can now add more Operation types without modifying the existing code.
Liskov Substitution Principle (aka LSP)
This principle suggests that we should be able to replace objects with instances of their subtypes or subclasses without affecting the correctness of the program.
This means that we should be able to substitute an object of a subclass for an object of its superclass without changing the behaviour. We can do this by creating an "is-a" relationship between classes, and ensuring that the subclass does not violate any of the "contracts" (set of expectations or requirements) established by the superclass.
🧪 Remember when Jesse, takes over as the lead cook, he is able to fill the same role as Walter without any issues. Same thing for Gale or Todd, filling the role for Jesse as Walter assistants, with expectations they can do the very same job when instructed.
As you can guess, there is more to refactor! The problem we have now is that the parameter person
is required for every single Operation. What if we add a new operation process that doesn't require a human, but rather, is more of an automated shipment process requiring an id
.
"I'm just a problem solver." - Gus Fring
Let's focus on Operations
class:
class Operations(ABC):
@abstractmethod
def process(self, _production):
pass
class ManufacturingProcess(Operations):
def process(self, _production, person):
print(f"{person} is manufacturing...")
_production.status = "cooking"
class DistributionProcess(Operations):
def process(self, _production, person):
print(f"{person} is distributing...")
_production.status = "selling"
production = Production()
distribution = DistributionProcess()
distribution.process(production, "Jesse Pinkman")
First we need to remove the argument inheritance of person
as it's going to be specific to the type of operation. This means we need to move it to the initializer __init__
of our subclass.
Then we'll add a new operation subclass InternationalShipment
, this time our initializer will be an id
of the shipment.
class Operations(ABC):
@abstractmethod
def process(self, _production):
pass
class ManufacturingProcess(Operations):
def __init__(self, person):
self.person = person
def process(self, _production):
print(f"{self.person} is manufacturing...")
_production.status = "cooking"
class DistributionProcess(Operations):
def __init__(self, person):
self.person = person
def process(self, _production):
print(f"{self.person} is distributing...")
_production.status = "selling"
class InternationalShipment(Operations):
def __init__(self, id):
self.id= id
def process(self, _production):
print(f"ID: {self.id} accepted!")
_production.status = "shipping"
production = Production()
distribution = DistributionProcess("Jesse Pinkman")
distribution.process(production)
shipment = InternationalShipment("YYY1")
shipment.process(production)
Now we've added an ability to not only add new functionality without modifying the code, but also an ability to have parameters that are specific to our subclass. Our code now passes the principle of Liskov Substitution.
Interface Segregation Principle (aka ISP)
Clients should not be forced to depend on interfaces they do not use. This means that we should split interfaces into smaller, more specific ones, so that clients only need to depend on the interfaces that they actually use.
This aims to design interfaces that need to be specific to the clients that use them. And in reverse we should not force clients to depend on methods (their superclass) or properties they don't need or use.
🧪 As a farfetched analogy, Saul Goodman, only needs to know about the legal aspects of Heisenberg's business. He doesn't need to know anything about the chemistry or logistics of producing and distributing crystal while being exposed to the whole undertaking.
In the example above we have a superclass Operation that has abstract method process
. Now, what interface segregation suggests is that we need to only pass abstracted methods to our subclass that actually has a use case.
Let's add a new abstract method deliver
to Operations:
class Operations(ABC):
@abstractmethod
def process(self, _production):
pass
@abstractmethod
def deliver(self, _production):
pass
The problem with the above is that our subclasses ManufacturingProcess
and DistributionProcess
doesn't really need to inherit deliver
method, as it's not part of that process. So we're going to abstract it out by creating an interface that extends Operations superclass.
"No more half measures, Walter." - Mike Ehrmantraut
class Operations(ABC):
@abstractmethod
def process(self, _production):
pass
class DeliveryOperation(Operations)
@abstractmethod
def deliver(self, _production):
pass
Now we have an DeliveryOperation
that inherits abstracted method process
from its superclass, and additional deliver
method that can be used inside the InternationalShipment
subclass.
class Operations(ABC):
@abstractmethod
def process(self, _production):
pass
class DeliveryOperation(Operations)
@abstractmethod
def deliver(self, is_delivered):
pass
class ManufacturingProcess(Operations):
def __init__(self, person):
self.person = person
def process(self, _production):
print(f"{self.person} is manufacturing...")
_production.status = "cooking"
class DistributionProcess(Operations):
def __init__(self, person):
self.person = person
def process(self, _production):
print(f"{self.person} is distributing...")
_production.status = "selling"
class InternationalShipment(DeliveryOperation):
def __init__(self, id):
self.id= id
def deliver(self, is_delivered):
self.is_delivered = is_delivered
pass
def process(self, _production):
print(f"ID: {self.id} accepted!")
if self.is_delivered:
_production.status = "shipping"
production = Production()
distribution = DistributionProcess("Jesse Pinkman")
distribution.process(production)
shipment = InternationalShipment("YYY1")
shipment.deliver(True)
shipment.process(production)
Dependency Inversion Principle (aka DIP)
The last principle in the segment is Dependency Inversion that suggests that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions.
This means that we should design modules independently of one another. Instead of having a high-level module directly depend on a low-level module, we should create an abstraction that both modules can depend on. By doing this, we can reduce coupling between modules and make our code more flexible and easier to modify over time. The take away here is abstractions should not depend on details, details should depend on abstractions.
🧪 When Walter & Jesse initially distributed crystal with low-level criminal, Krazy-8, that quickly became a liability. Walter had to quickly find a new way to distribute their product. Because Walter and Jesse were tightly coupled to Krazy-8, they are unable to adapt quickly and their operation suffered.
We're going to create a new ShipmentVerification
class, that will be responsible for checking if our shipment has completed. We're also going to add a new verifier
parameter to our InternationalShipment
initializer specifying the type of verification class we're using.
class ShipmentVerification:
complete = False
def verification(self, id):
self.complete = True
def is_delivered(self) -> bool:
return self.complete
...
class InternationalShipment(DeliveryOperation):
def __init__(self, id, verifier: ShipmentVerification):
self.id= id
self.verifier= verifier
def deliver(self, is_delivered):
self.is_delivered = is_delivered
pass
def process(self, _production):
print(f"ID: {self.id} accepted!")
if self.is_delivered:
_production.status = "shipping"
So what's the problem with the above, I hear you ask? Well, the ShipmentVerification
class has a high-level dependency, not an abstraction, as Dependency Inversion Principle indicates.
"Say my name." - Walter White
The solution is to abstract ShipmentVerification
class out, so that it can be used independently.
class Verifier(ABC)
@abstractmethod
def is_delivered(self):
pass
class ShipmentVerification(Verifier):
complete = False
def verification(self, id):
self.complete = True
def is_shipped(self) -> bool:
return self.complete
...
class InternationalShipment(DeliveryOperation):
def __init__(self, id, verifier: Verifier):
self.id= id
self.verifier = verifier
def deliver(self, is_delivered):
self.is_delivered = is_delivered
pass
def process(self, _production):
print(f"ID: {self.id} accepted!")
if self.is_shipped:
_production.status = "shipping"
That is it! We've covered all the SOLID principles! I hope it all makes sense and you can smash your next interview, or apply them in your next project, making it all modular and simple -able.
Also I'd like to mention that this is my very first post, and feedback (or roast🥩) is very much welcome! Cheers!
Top comments (0)