Hello, Habr! Here is a translation of an article by Robert Martin of the Open-Closed Principle , which he published in January 1996. The article, to put it mildly, is not the latest. But in RuNet, Uncle Bob's articles about SOLID are retold only in a truncated form, so I thought that a full translation would not be superfluous.
⌘ ⌘ ⌘
I decided to start with the letter O, since the principle of openness-closure, in fact, is central. Among other things, there are many important subtleties that are worth paying attention to:
- No program can be "closed" 100%.
- Object-oriented programming (OOP) operates not with physical objects of the real world, but with concepts - for example, the concept of "ordering".
This is the first article in my Engineer Notes column for The C ++ Report . The articles published in this column will focus on the use of C ++ and OOP and touch upon the difficulties in software development. I will try to make the materials pragmatic and useful for practicing engineers. For documentation of object-oriented design in these articles I will use Buch's notation.
There are many heuristics associated with object-oriented programming. For example, “all member variables must be private”, or “global variables should be avoided”, or “type determination at runtime is dangerous”. What is the reason for such heuristics? Why are they true? Are they always true? This column explores the design principle underlying these heuristics - the principle of openness-closure.
Ivar Jacobson said: “All systems change during the life cycle. This must be borne in mind when designing a system that has more than one version expected. ” How can we design a system so that it is stable in the face of change and with more than one version expected? Bertrand Meyer told us about this back in 1988, when the now famous principle of openness-closeness was formulated:
Program entities (classes, modules, functions, etc.) must be open for expansion and closed for changes.
If one change in the program entails a cascade of changes in the dependent modules, then undesirable signs of a “bad” design appear in the program.
The program becomes fragile, inflexible, unpredictable and unused. The principle of openness-closeness solves these problems in a very straightforward way. He says it is necessary to design modules that never change . When requirements change, you need to extend the behavior of such modules by adding new code, rather than changing the old, already working code.
Description
Modules that meet the principle of openness-closure have two main characteristics:
- Open for expansion. This means that the behavior of the module can be expanded. That is, we can add new behavior to the module in accordance with the changing requirements for the application or to meet the needs of new applications.
- Closed for change. The source code of such a module is untouchable. No one has the right to make changes to it.
It seems that these two signs do not fit together. The standard way to extend the behavior of a module is to make changes to it. A module that cannot be changed is usually thought of as a module with fixed behavior. How can these two opposite conditions be fulfilled?
The key to the solution is abstraction.
In C ++, using the principles of object-oriented design, it is possible to create fixed abstractions that can represent an unlimited set of possible behaviors.
Abstractions are abstract base classes, and an unlimited set of possible behaviors is represented by all possible successor classes. A module can manipulate abstraction. Such a module is closed for changes, since it depends on a fixed abstraction. Also, the behavior of the module can be expanded by creating new descendants of abstraction.
The diagram below shows a simple design option that does not meet the principle of openness-closeness. Both classes, Client
and Server
, are not abstract. There is no guarantee that functions that are members of the Server
class are virtual. The Client
class uses the Server
class. If we want the Client
class object to use a different server object, we must change the Client
class to refer to the new server class.
Closed client
And the following diagram shows the corresponding design option, which meets the principle of openness-closeness. In this case, the AbstractServer
class is an abstract class, all member functions of which are virtual. The Client
class uses abstraction. However, objects of the Client
class will use objects of the Server
successor class. If we want objects of the Client
class to use a different server class, we will introduce a new descendant of the AbstractServer
class. The Client
class will remain unchanged.
Open client
Shape
Abstract
Consider an application that should draw circles and squares in a standard GUI. Circles and squares must be drawn in a specific order. In the corresponding order, a list of circles and squares will be compiled, the program should go through this list in the order and draw each circle or square.
In C, using procedural programming techniques that do not meet the open-close principle, we could solve this problem as shown in Listing 1. Here we see many data structures with the same first element. This element is a type code that identifies the data structure as a circle or square. The DrawAllShapes
function passes through an array of pointers to these data structures, recognizing the type code and then calling the corresponding function ( DrawCircle
or DrawSquare
).
// 1 // / enum ShapeType {circle, square} struct Shape { ShapeType itsType; }; struct Circle { ShapeType itsType; double itsRadius; Point itsCenter; }; struct Square { ShapeType itsType; double itsSide; Point itsTopLeft; }; // // // void DrawSquare(struct Square*) void DrawCircle(struct Circle*); typedef struct Shape *ShapePointer; void DrawAllShapes(ShapePointer list[], int n) { int i; for (i=0; i<n; i++) { struct Shape* s = list[i]; switch (s->itsType) { case square: DrawSquare((struct Square*)s); break; case circle: DrawCircle((struct Circle*)s); break; } } }
The DrawAllShapes
function DrawAllShapes
not meet the principle of openness-closure, because it cannot be “closed” from new types of shapes. If I wanted to expand this function with the ability to draw shapes from a list that includes triangles, then I would need to change the function. In fact, I have to change the function for each new type of shape that I need to draw.
Of course, this program is just an example. In real life, the switch
operator from the DrawAllShapes
function would be repeated over and over in various functions throughout the application and each would do something different. Adding new shapes to such an application means finding all the places where such switch
(or if/else
chains) are used, and adding a new shape to each of them. Moreover, it is very unlikely that all switch
and if/else
chains will be as well structured as in DrawAllShapes
. It is much more likely that the predicates in if
will be combined with logical operators, or the case
blocks of switch
will be combined in such a way as to “simplify” a particular place in the code. Therefore, the problem of finding and understanding all the places where you need to add a new figure can be non-trivial.
In Listing 2, I will show code that demonstrates a square / circle solution that meets the principle of openness-closedness. An abstract Shape
class is introduced. This abstract class contains one pure virtual Draw
function. The Circle
and Square
classes are descendants of the Shape
class.
// 2 // / - class Shape { public: virtual void Draw() const = 0; }; class Square : public Shape { public: virtual void Draw() const; }; class Circle : public Shape { public: virtual void Draw() const; }; void DrawAllShapes(Set<Shape*>& list) { for (Iterator<Shape*>i(list); i; i++) (*i)->Draw(); }
Note: if we want to extend the behavior of the DrawAllShapes
function in Listing 2 to draw a new kind of shape, all we need to do is add a new descendant of the Shape
class. No need to change the DrawAllShapes
function. Therefore, DrawAllShapes
meets the principle of openness-closeness. Its behavior can be expanded without changing the function itself.
In the real world, the Shape
class would contain many other methods. And yet, adding a new shape to the application is still very simple, since all you need to do is enter a new heir and implement these functions. No need to scour the entire application in search of places requiring change.
Therefore, programs that meet the principle of openness-closeness are changed by adding new code, and not by changing the existing one; they do not cascade changes characteristic of programs that do not correspond to this principle.
Closed Entry Strategy
Obviously, no program can be 100% closed. For example, what happens to the DrawAllShapes
function in Listing 2 if we decide that circles and then squares should be drawn first? The DrawAllShapes
function DrawAllShapes
not closed from this kind of change. In general, it doesn’t matter how “closed” the module is, there is always some type of change from which it is not closed.
Since closure cannot be complete, it must be introduced strategically. That is, the designer must choose the types of changes from which the program will be closed. This requires some experience. An experienced developer knows the users and the industry well enough to calculate the likelihood of various changes. He then makes sure that the principle of openness-closeness is respected for the most likely changes.
Using abstraction to achieve additional closeness
How can we close the DrawAllShapes
function from changes in the drawing order? Remember that closure is based on abstraction. Therefore, to close DrawAllShapes
from ordering, we need some kind of “ordering abstraction”. A special case of ordering, presented above, is drawing figures of one type in front of figures of another type.
Ordering policy implies that with two objects, you can determine which one should be drawn first. Therefore, we can define a method for the Shape
class called Precedes
, which takes another Shape
object as an argument and returns the Boolean value true
as the result if the Shape
class object that received this message needs to be sorted before the Shape
class object that was passed as an argument.
In C ++, this function can be represented as overloading the “<” operator. Listing 3 shows the Shape
class with sorting methods.
Now that we have a way to determine the order of the objects of the Shape
class, we can sort them and then draw them. Listing 4 shows the corresponding C ++ code. It uses the Set
, OrderedSet
and Iterator
classes from the Components
category developed in my book (Designing Object Oriented C ++ Applications using the Booch Method, Robert C. Martin, Prentice Hall, 1995).
So, we have implemented the ordering of objects of the Shape
class and drawing them in the appropriate order. But we still do not have an implementation of the abstraction of ordering. Obviously, every Shape
object must override the Precedes
method to determine the order. How can this work? What code needs to be written in Circle::Precedes
so that circles are drawn to squares? Pay attention to listing 5.
// 3 // Shape . class Shape { public: virtual void Draw() const = 0; virtual bool Precedes(const Shape&) const = 0; bool operator<(const Shape& s) {return Precedes(s);} };
// 4 // DrawAllShapes void DrawAllShapes(Set<Shape*>& list) { // OrderedSet . OrderedSet<Shape*> orderedList = list; orderedList.Sort(); for (Iterator<Shape*> i(orderedList); i; i++) (*i)->Draw(); }
// 5 // bool Circle::Precedes(const Shape& s) const { if (dynamic_cast<Square*>(s)) return true; else return false; }
It is clear that this function does not meet the principle of openness-closedness. There is no way to close it from the new descendants of the Shape
class. Each time a new descendant of the Shape
class appears, this function needs to be changed.
Using a Data Driven Approach to Achieve Closure
The closeness of the inheritors of the Shape
class can be achieved using a tabular approach that does not provoke changes in each inherited class. An example of this approach is shown in Listing 6.
Using this approach, we successfully closed the DrawAllShapes
function from changes related to ordering, and each descendant of the Shape
class — from introducing a new descendant or from a change in the ordering policy for objects of the Shape
class depending on their type (for example, such that objects of the Squares
class should be drawn first).
// 6 // #include <typeinfo.h> #include <string.h> enum {false, true}; typedef int bool; class Shape { public: virtual void Draw() const = 0; virtual bool Precedes(const Shape&) const; bool operator<(const Shape& s) const {return Precedes(s);} private: static char* typeOrderTable[]; }; char* Shape::typeOrderTable[] = { "Circle", "Square", 0 }; // . // , // . , , // bool Shape::Precedes(const Shape& s) const { const char* thisType = typeid(*this).name(); const char* argType = typeid(s).name(); bool done = false; int thisOrd = -1; int argOrd = -1; for (int i=0; !done; i++) { const char* tableEntry = typeOrderTable[i]; if (tableEntry != 0) { if (strcmp(tableEntry, thisType) == 0) thisOrd = i; if (strcmp(tableEntry, argType) == 0) argOrd = i; if ((argOrd > 0) && (thisOrd > 0)) done = true; } else // table entry == 0 done = true; } return thisOrd < argOrd; }
The only element that is not closed from changing the order of drawing shapes is a table. The table can be placed in a separate module, separated from all other modules, and therefore its changes will not affect other modules.
Further closure
This is not the end of the story. We closed the hierarchy of the Shape
class and the DrawAllShapes
function from changing the ordering policy based on the type of shapes. However, the descendants of the Shape
class are not closed from ordering policies that are not associated with Shape
types. It looks like we need to arrange the drawing of shapes according to a higher-level structure. A full study of such problems is beyond the scope of this article; however, an interested reader might think how to solve this problem using the abstract OrderedObject
class contained in the OrderedShape
class, which inherits from the Shape
and OrderedObject
.
Heuristics and Conventions
As already mentioned at the beginning of the article, the principle of openness-closeness is the key motivation behind many heuristics and conventions that have emerged over the many years of the development of the OOP paradigm. The following are the most important ones.
Make all member variables private
This is one of the most enduring conventions of the PLO. Variables - members of classes should only be known by the method of the class in which they are defined. Variable members should not be known to any other classes, including derived classes. Therefore, they must be declared with a private
access modifier, not public
or protected
.
In light of the principle of openness-closeness, the reason for such a convention is understandable. When class member variables change, each function that depends on them must change. That is, the function is not closed from changes to these variables.
In OOP, we expect that the methods of a class are not closed to changes in the variables that are members of this class. However, we expect that any other class, including subclasses, is closed from changes to these variables. This is called encapsulation.
But what if you have a variable about which you are sure that it will never change? Does it make sense to make it private
? For example, Listing 7 shows the Device
class that contains the variable member bool status
. It stores the status of the last operation. If the operation was successful, then the value of the status
variable will be true
; otherwise, false
.
// 7 // class Device { public: bool status; };
We know that the type or meaning of this variable will never change. So why not make it public
and give the client direct access to it? If the variable really never changes, if all the clients follow the rules and only read from this variable, then there is nothing wrong with the fact that the variable is public. However, think about what will happen if one of the clients takes the opportunity to write to this variable and change its value.
Unexpectedly, this client can affect the operation of any other client of the Device
class. This means that it is impossible to close clients of the Device
class from changes to this incorrect module. This is too much risk.
On the other hand, suppose we have the Time
class, shown in Listing 8. What is the danger of the publicity of the variables that are members of this class? It is very unlikely that they will change. Moreover, it doesn’t matter if the client modules change the values of these variables or not, since a change in these variables is assumed. It is also very unlikely that inherited classes can depend on the value of a particular member variable. So is there a problem?
// 8 class Time { public: int hours, minutes, seconds; Time& operator-=(int seconds); Time& operator+=(int seconds); bool operator< (const Time&); bool operator> (const Time&); bool operator==(const Time&); bool operator!=(const Time&); };
The only complaint I could make to the code in Listing 8 is that the time change is not atomic. That is, the client can change the value of the minutes
variable without changing the value of the hours
variable. This can cause an object of the Time
class to contain inconsistent data. I would prefer to introduce a single function for setting the time, which would take three arguments, which would make setting the time an atomic operation. But this is a weak argument.
It is easy to come up with other conditions under which the publicity of these variables can lead to problems. Ultimately, however, there is no compelling reason to make them private
. I still think that making such variables public is a bad style, but maybe it's not a bad design. I believe that this is a bad style, because it costs almost nothing to enter the appropriate functions to access these members, and it is definitely worth it to protect yourself from the small risk associated with the possible occurrence of problems with closure.
Therefore, in such rare cases, when the principle of openness-closeness is not violated, the prohibition of public
- and protected
variables depends more on style and not on content.
No global variables ... at all!
The argument against global variables is the same as the argument against public member variables. No module that depends on a global variable can be closed from a module that can write to it. Any module that uses this variable in a manner not intended by other modules will break these modules. It’s too risky to have many modules depending on the vagaries of a single malicious module.
On the other hand, in cases where global variables have a small number of modules dependent on them or cannot be used in the wrong way, they do no harm. The designer must evaluate how much privacy is sacrificed and determine whether the convenience provided by the global variable is worth it.
Here again, style problems come into play. Alternatives to using global variables are usually inexpensive. In such cases, the use of a technique that introduces, although small, but a risk for closure instead of a technique that completely eliminates such a risk, is a sign of bad style. However, sometimes using global variables is really convenient. A typical example is the global variables cout and cin. In such cases, if the principle of openness-closeness is not violated, you can sacrifice the style for the sake of convenience.
RTTI is dangerous
Another common prohibition is the use of dynamic_cast
. Very often, dynamic_cast
or some other form of runtime type determination (RTTI) is accused of being an extremely dangerous technique and should therefore be avoided. At the same time, they often give an example from Listing 9, which obviously violates the principle of openness-closeness. However, Listing 10 shows an example of a similar program that uses dynamic_cast
without violating the open-close principle.
The difference between the two is that in the first case, shown in Listing 9, the code needs to be changed every time a new descendant of the Shape
class appears (not to mention that this is an absolutely ridiculous solution). However, in Listing 10, no changes are required in this case. Therefore, the code in Listing 10 does not violate the open-close principle.
In this case, the rule of thumb is that RTTI can be used if the principle of openness-closure is not violated.
// 9 //RTTI, -. class Shape {}; class Square : public Shape { private: Point itsTopLeft; double itsSide; friend DrawSquare(Square*); }; class Circle : public Shape { private: Point itsCenter; double itsRadius; friend DrawCircle(Circle*); }; void DrawAllShapes(Set<Shape*>& ss) { for (Iterator<Shape*>i(ss); i; i++) { Circle* c = dynamic_cast<Circle*>(*i); Square* s = dynamic_cast<Square*>(*i); if (c) DrawCircle(c); else if (s) DrawSquare(s); } }
// 10 //RTTI, -. class Shape { public: virtual void Draw() cont = 0; }; class Square : public Shape { // . }; void DrawSquaresOnly(Set<Shape*>& ss) { for (Iterator<Shape*>i(ss); i; i++) { Square* s = dynamic_cast<Square*>(*i); if (s) s->Draw(); } }
Conclusion
I could talk for a long time about the principle of openness-closeness. In many ways, this principle is most important for object-oriented programming. Compliance with this particular principle provides the key advantages of object-oriented technology, namely reuse and support.
, - -. , , , , , .