The Open-Close Principle (OCP) is one of the five essential SOLID design principles. These principles are guidelines for the proper usage of object-oriented features. The OCP definition is:
Modules should be open for extension and closed for modification.
Let’s refine this definition this way :
- A module will be said to be open if it is still available for extension. For example, it should be possible to add fields to the data structures it contains, or new elements to the set of functions it performs.
- A module will be said to be closed if it is available for use by other modules. This assumes that the module has been given a well-defined, stable description (the interface in the sense of information hiding)
There have been debates about the meaning of this simple definition and its implication on the usage of Object-Oriented Programming (OOP) in the real world.
Bertrand Meyer is generally credited for having originated the term open-closed principle, which appeared in his 1988 book Object Oriented Software Construction.
SOLID Principles Summary
Before delving into explaining the Open Close Principle, let’s take a quick look at how it stands in the SOLID design principles:
- The Single Responsibility Principle (SRP): A class should have one reason to change. This principle is about how to partition your logic into classes and avoid ending up with some monster classes (known as god class).
- The Open-Close Principle (OCP): Modules should be open for extension and closed for modification. To implement a new feature, it is better to add a new derived class instead of having to modify some existing code.
- The Liskov Substitution Principle (LSP): Methods that use references to base classes must be able to use objects of derived classes without knowing it. Array implements IList<T> but throws NotSupportException on IList<T>.Add(). This is a clear LSP violation.
- The Interface Segregation Principle (ISP): The client should not depend by design on methods it does not use. This is why interfaces like IReadOnlyCollection<T> have been introduced. Often the client just needs a subset of features like read-only access instead of full read-write access.
- The Dependency Inversion Principle (DIP): Depend on abstractions, not on implementations. Interfaces are much less subject to changes than classes, especially the ones that abide by the (ISP) and the (LSP).
Open-Close Principle Explained with a C# Example
Have a look at the C# code below. It attempts to propose a universal Drawer class that can draw both Circle and Square. The main issue with this code is that when a new kind of shape is available, like a Triangle, the Drawer class must be modified. It would be great to have a real universal Drawer class that doesn’t depend on each shape class.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
public class Circle { } public class Square { } public static class Drawer { public static void DrawShapes(IEnumerable<object> shapes) { foreach (object shape in shapes) { if (shape is Circle) { DrawCircle(shape as Circle); } else if (shape is Square) { DrawSquare(shape as Square); } } } private static void DrawCircle(Circle circle) { /*Draw circle*/ } private static void DrawSquare(Square square) { /*Draw Square*/ } } |
Refactoring with the Open-Close Principle
If you had a chance to practice object-oriented programming already, it should be obvious to refactor this code to something like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
public interface IShape { void Draw(); } public class Circle : IShape { public void Draw() { /*Draw circle*/ }} public class Square : IShape { public void Draw() { /*Draw Square*/ } } public static class Drawer { public static void DrawShapes(IEnumerable<IShape> shapes) { foreach (IShape shape in shapes) { shape.Draw(); } } } |
Let’s look at the implications of this refactoring:
- We introduced a new abstraction IShape to represent a concept that we already had in mind. Indeed, in the first code sample, the method DrawShapes() already accepted an untyped sequence of shapes. With the new IShape abstraction our design is now open to accept more shapes like Triangle or Pentagone.
- The method DrawShapes() will draw any new shape without any need for modification. In other words, the DrawShapes() method implementation is closed. Because of that, it is universal.
Benefits of Open-Close Principle
OCP is all about anticipating future changes in the system in a way that when a change occurs:
- The existing code is left untouched. Here the existing code is the combination of the DrawShapes() method body, IShape interface and Circle and Square classes.
- New code to implement changes is written in new classes that implement existing abstraction. New classes here could be Triangle and Pentagone.
Anticipating Variations
The Point of Variation principle
Another way to see the OCP is the Point of Variation (PV) principle that states:
Identify points of predicted variation and create a stable interface around them.
I find PV more understandable than OCP because it is actionable. First, identify potential variations, second build the proper abstractions around these variations.
The real challenge: Anticipation
But the real challenge is anticipation, anticipating is hard. If anticipation was easy we’d be all billionaires in bitcoins aren’t we? In the real world, when you anticipate the risk is high that:
- We do anticipate variations that won’t vary. This is what the YAGNI principle says (You aren’t gonna need it): Always implement things when you actually need them, never when you just foresee that you need them. Developing and maintaining an abstraction has a cost and if we don’t need it this is wasted resources.
- The risk is also high that we don’t anticipate the variation that will be actually needed. But once the need for variation becomes real this is your developer’s responsibility to refactor. It means that you have to create the right abstractions and the right stable code that will act upon these abstractions. This is the fool me once, don’t fool me twice idea: I am not supposed to foresee what I’ll need but I am supposed to identify and then write the right abstraction when I need it.
Applying the Open-Closed Principle in the real world
Here is a pragmatic approach to OCP:
- In any case, the KISS principle applies (Keep It Simple Stupid): don’t underestimate the difficulty of anticipating. Don’t waste your resources creating abstractions you won’t need.
- Write automatic tests: one of the greatest benefits of writing tests is that for a while, you must look at your code from the client’s perspective. If your code contains some areas difficult to cover by tests, it certainly means that your code should be refactored to be easily 100% testable. Experience shows that when refactoring from poorly to fully testable code, the need for the right abstractions naturally arises.
- Code review and static analyzers can help you pinpoint typical OCP violations. Here are good indicators that you need OCP:
- when downcasting reference (i.e casting from a base class or interface to a subclass or leaf class.),
- when using the is or as C# operators (like in the first example above).
- NDepend has the rule Base class should not use derivatives: matches of this rule are obvious violations of the OCP.
- Keep in mind the fool me once, don’t fool me twice idea. You must refactor code as soon as the need to abstract some concepts is identified. Of course, sometimes it is not possible if plenty of client code depends upon your API. In this situation, one cannot easily refactor. Often you’ll have to live with the wrong design. This is why public API design is such a sensitive topic: you have no other choice than doing your best to anticipate. It also means to accept to live with past design mistakes.
Be open to more than one variation with the Visitor pattern
Finally let’s underline that in the real world, entity objects – like the shapes here – don’t implement algorithms such as drawing. Experience tells us that this is a clear violation of OCP. When a new algorithm is needed on data objects, like for example persistence or serialization in addition to drawing, all shape classes must be modified again. This is also a violation of the Single Responsibility Principle (SRP, the S in SOLID) because a shape class has now two responsibilities: 1) model the shape and 2) draw the shape.
Hence we now have two variations: we need a way to abstract both the shapes and the algorithms applied to the shapes. What we want is to be able to write something like algorithm.ApplyOn(shape). This sort of call involving two abstractions is called a double dispatching call: the implementation invoked depends both on the IShape object’s type and the IAlgorithm object’s type. If you have N shapes and M algorithms you need [N x M] implementations.
Fortunately, the visitor pattern helps implement double dispatching. The code with the new persistence algorithm would then look like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
// // Shapes elements // public interface IShape { void Accept(IVisitor visitor); } public class Circle : IShape { public void Accept(IVisitor visitor) { visitor.Visit(this); } } public class Square : IShape { public void Accept(IVisitor visitor) { visitor.Visit(this); } } // // Visitors algorithms on shapes elements // don't use the IAlgorithm terminology to keep up with the classical visitor pattern terminology // public interface IVisitor { void Visit(Circle circle); void Visit(Square square); } public class DrawAlgorithm : IVisitor { public void Visit(Circle circle) { /*Draw circle*/} public void Visit(Square square) { /*Draw square*/} } public class PersistAlgorithm : IVisitor { public void Visit(Circle circle) { /*Persist circle*/} public void Visit(Square square) { /*Persist square*/} } public static class Program { public static void ApplyVisitorAlgorithmOnShapesElements(IEnumerable<IShape> shapes, IVisitor visitor) { foreach (IShape shape in shapes) { // Double dispatching: // shape can be both: Circle or Square // visitor can be both Draw or Persist shape.Accept(visitor); } } } |
Conclusion
In conclusion, the Open-Closed Principle (OCP) is a foundational concept in SOLID design, promoting extensibility and maintainability in software systems. By encouraging developers to design classes and modules that are open for extension but closed for modification, OCP fosters flexibility and adaptability in the face of changing requirements. Embracing OCP not only enhances code quality but also paves the way for more robust and scalable software solutions.
Great! Especially the Visitor pattern.
About IShape interface, I think it must not know anything about draw method. It is not IShape business to know how to draw itself. it is better to get shape geometry like its points in drawer. Then drawing it.
Did you read the end of the article?
“Finally let’s underline that in the real world, data objects (like the shapes here) don’t implement themselves algorithm such as drawing. “
Sorry, I didn’t read that part of the article yet. Beautiful article, thanks.
For me, the Visitor pattern is too much, at least for that particular example. I would rather use some other patter, which is very similar to Strategy pattern:
public static class Drawer {
public static void DrawShapes(IEnumerable shapes, List concreteDrawers)
{
foreach (IShape shape in shapes)
{
foreach (drawer in concreteDrawers)
{
if (drawer.Draw(shape))
break;
}
}
}
}
With visitor example you still have to modify every IVisitor implementation, where on my example provided you just add new class and instantiate new object to append it to the list.