Last week we revised the Single Responsibility Principle or SRP, today let's have a look at the Open/Closed Principle or OCP, which states: "Entities should be open for extension, but closed for modification." Let's break this definition down.
- Entities, think of it as classes, structs or methods should be simple to change.
- Closed for modification, we want to change that entity's behavior without modifying its source code. This can be really problematic. How the heck can we change the behavior of the entity without touching its source code? And that's when we come back to the term extension. We should think about extensibility while writing our entities.
Committing the sin
Without any further ado, let see an example of this principle being applied hoping it’ll click for you.
For that, let’s imagine we have a Square
class with its properties like so:
class Square {
private(set) var width: Double
private(set) var height: Double
init(width: Double, height: Double) {
self.width = width
self.height = height
}
}
Now, we say that we want to calculate the total area of squares. If we follow the Single Responsibility Principle, we might create a separated class that is dedicating to calculate that area:
class AreaCalculator {
func calculate(squares: Square...) -> Double {
return squares.reduce(0) { $0 + $1.width * $1.height }
}
}
If we run this, it works flawlessly:
let square1 = Square(width: 10, height: 10) // 100
let square2 = Square(width: 20, height: 20) // 400
let square3 = Square(width: 30, height: 30) // 900
let calculator = AreaCalculator()
calculator.calculate(squares: square1, square2, square3) // 1400
Now let's say we want to calculate the circle's areas as well. Our first thought might be to create a Circle
class.
class Circle {
private(set) var radius: Double
init(radius: Double) {
self.radius = radius
}
}
In the code above, we notice that we call everything square (in the AreaCalculator
class) because we didn't think about the possibility that we'll need to calculate the area for anything other than a square. So we'll have to edit the original AreaCalculator
class, meaning we broke the Open/Closed Principle.
Let's see how to change the behavior of the calculate
method before refactoring it.
class AreaCalculator {
func calculate(shapes: AnyObject...) -> Double {
var totalArea: Double = 0
for shape in shapes {
if shape is Square {
let square = shape as! Square
totalArea += square.width * square.height
}
if shape is Circle {
let circle = shape as! Circle
totalArea += circle.radius * circle.radius * Double.pi
}
}
return totalArea
}
}
Swift being a static language, the calculate
method has to accept an array of AnyObject
, then we manually check the type of the object.
And if we run now the code, we have something like this:
let calculator = AreaCalculator()
let square1 = Square(width: 10, height: 10) // 100
let square2 = Square(width: 20, height: 20) // 400
let square3 = Square(width: 30, height: 30) // 900
let cirlcle1 = Circle(radius: 10) // 314.15926
let cirlcle2 = Circle(radius: 20) // 1256.6370
calculator.calculate(shapes: square1, square2, square3, cirlcle1, cirlcle2) // 2970.79632
Like we said above, we clearly break the Open/Closed Principle by doing this. The AreaCalculator
is opened for modification when it should be closed. However, you might be thinking, oh no, it's not a big deal, add a few if
statements, and you're done, don't worry about this principle. Ok, fair enough, but let say we also need to calculate the area of a triangle, kite, hexagon, pentagon, and some other …gon shapes (screwed right 😅). Do you think we need to add endless if statements in the calculate
method every single time we make a change? Of course no. But now, what can be the solution to make the AreaCalculator
closed for modification 🤔?
Coding to an interface (or protocol): the cure
In order to extend the behavior of the AreaCalculator
while keeping it closed for modification, we need to separate that behavior behind an interface (or protocol in Swift) and then flip the dependencies around, as Uncle Bob said. It can be a little tricky at first to get this, but let's break it down step by step.
Separating the external behavior behind an interface
To do that, we’ll create a Shape
protocol like so:
protocol Shape {
var area: Double { get }
}
The nice thing about Swift is we can have properties in Protocols or interfaces and this particularly reads well: a Shape
has an area
😁
Now we need to implement or to conform to that interface in each concrete class.
class Square: Shape {
private(set) var width: Double
private(set) var height: Double
var area: Double {
return width * height
}
init(width: Double, height: Double) {
self.width = width
self.height = height
}
}
class Circle: Shape {
private(set) var radius: Double
var area: Double {
return radius * radius * Double.pi
}
init(radius: Double) {
self.radius = radius
}
}
Step 1 is done, let’s focus on step 2 now.
Flip the dependencies around
To do so, rather than accepting an AnyObject
array in our calculate
method, we’ll accept an array of Shape
.
class AreaCalculator {
func calculate(shapes: Shape...) -> Double {
return shapes.reduce(0) { $0 + $1.area }
}
}
Because we code to an interface and implement it to the Square
and Cercle
classes, we're 100% sure that they'll have an area
property. We simply use it to calculate the sum of all shapes' area.
This is really powerful because if we need to calculate a triangle's area, for instance, we'll just have to create the Triangle
class, make sure that it conforms to the Shape
protocol, then set the area
property along with the formula for calculating the area of a triangle, and we pass that into the calculate
method of AreaCalculator
.
Notice that we never ever touch the AreaCalculator
class again because it already knows how to calculate shape areas.
Conclusion
As a developer, we should strive to write collaborative code. What I mean by that is we should help our fellow colleague or friend to be able to extend the functionality of a system without needing to modify the source code of any existing classes. Yes, it can be difficult at first glance, but by applying these S.O.L.I.D. principles, we can achieve this goal easier. I hope this example makes something click for you for your understanding of the Open/Closed Principle. Next, we'll have a more in-depth look at the Interface Segregation Principle; until then, have a nice week, and may the force be with you 👊.
Top comments (0)