© Jennifer M. Kohnke
Dutch Door: Noun. A door divided in two horizontally so that either part can be left open or closed.
—The American Heritage Dictionary of the English Language, Fourth Edition, 2000
As Ivar Jacobson has said, “All systems change during their life cycles. This must be borne in mind when developing systems expected to last longer than the first version.”1 How can we create designs that are stable in the face of change and that will last longer than the first version? Bertrand Meyer2 gave us guidance as long ago as 1988 when he coined the now-famous open/closed principle. To paraphrase him:
The Open/Closed Principle (OCP)
Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.
When a single change to a program results in a cascade of changes to dependent modules, the design smells of rigidity. OCP advises us to refactor the system so that further changes of that kind will not cause more modifications. If OCP is applied well, further changes of that kind are achieved by adding new code, not by changing old code that already works. This may seem like motherhood and apple pie—the golden, unachievable ideal—but in fact, there are some relatively simple and effective strategies for approaching that ideal.
Modules that conform to OCP have two primary attributes.
It would seem that these two attributes are at odds. The normal way to extend the behavior of a module is to make changes to the source code of that module. A module that cannot be changed is normally thought to have a fixed behavior.
How is it possible that the behaviors of a module can be modified without changing its source code? Without changing the module, how can we change what a module does?
The answer is abstraction. In C# or any other object-oriented programming language (OOPL), it is possible to create abstractions that are fixed and yet represent an unbounded group of possible behaviors. The abstractions are abstract base classes, and the unbounded group of possible behaviors are represented by all the possible derivative classes.
It is possible for a module to manipulate an abstraction. Such a module can be closed for modification, since it depends on an abstraction that is fixed. Yet the behavior of that module can be extended by creating new derivatives of the abstraction.
Figure 9-1 shows a simple design that does not conform to OCP. Both the Client
and Server
classes are concrete. The Client
class uses the Server
class. If we want for a Client
object to use a different server object, the Client
class must be changed to name the new server class.
Figure 9-2 shows the corresponding design that conforms to the OCP by using the STRATEGY pattern (see Chapter 22). In this case, the ClientInterface
class is abstract with abstract member functions. The Client
class uses this abstraction. However, objects of the Client
class will be using objects of the derivative Server
class. If we want Client
objects to use a different server class, a new derivative of the ClientInterface
class can be created. The Client
class can remain unchanged.
The Client
has some work that it needs to get done and can describe that work in terms of the abstract interface presented by ClientInterface
. Subtypes of Client-Interface
can implement that interface in any manner they choose. Thus, the behavior specified in Client
can be extended and modified by creating new subtypes of ClientInterface
.
You may wonder why I named ClientInterface
the way I did. Why didn’t I call it AbstractServer
instead? The reason, as we will see later, is that abstract classes are more closely associated to their clients than to the classes that implement them.
Figure 9-3 shows an alternate structure using the TEMPLATE METHOD pattern (see Chapter 22). The Policy
class has a set of concrete public functions that implement a policy, similar to the functions of the Client
in Figure 9-2. As before, those policy functions describe some work that needs to be done in terms of some abstract interfaces. However, in this case, the abstract interfaces are part of the Policy
class itself. In C#, they would be abstract methods. Those functions are implemented in the subtypes of Policy
. Thus, the behaviors specified within Policy
can be extended or modified by creating new derivatives of the Policy
class.
These two patterns are the most common ways of satisfying OCP. They represent a clear separation of generic functionality from the detailed implementation of that functionality.
Shape
ApplicationThe Shape
example has been shown in many books on object-oriented design. This infamous example is normally used to show how polymorphism works. However, this time, we will use it to elucidate OCP.
We have an application that must be able to draw circles and squares on a standard GUI. The circles and squares must be drawn in a particular order. A list of the circles and squares will be created in the appropriate order, and the program must walk the list in that order and draw each circle or square.
In C, using procedural techniques that do not conform to OCP, we might solve this problem as shown in Listing 9-1. Here, we see a set of data structures that have the same first element but are different beyond that. The first element of each is a type code that identifies the data structure as either a Circle
or a Square
. The function DrawAllShapes
walks an array of pointers to these data structures, examining the type code and then calling the appropriate function, either DrawCircle
or DrawSquare
.
Listing 9-1. Procedural solution to the Square/Circle problem
--shape.h---------------------------------------
enum ShapeType {circle, square};
struct Shape
{
ShapeType itsType;
};
--circle.h---------------------------------------
struct Circle
{
ShapeType itsType;
double itsRadius;
Point itsCenter;
};
void DrawCircle(struct Circle*);
--square.h---------------------------------------
struct Square
{
ShapeType itsType;
double itsSide;
Point itsTopLeft;
};
void DrawSquare(struct Square*);
--drawAllShapes.cc-------------------------------
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;
}
}
}
Because it cannot be closed against new kinds of shapes, the function DrawAllShapes
does not conform to OCP. If I wanted to extend this function to be able to draw a list of shapes that included triangles, I would have to modify the function. In fact, I would have to modify the function for any new type of shape that I needed to draw.
Of course, this program is only a simple example. In real life, the switch
statement in the DrawAllShapes
function would be repeated over and over again in various functions all through the application, each one doing something a little different. There might be one each for dragging shapes, stretching shapes, moving shapes, deleting shapes, and so on. Adding a new shape to such an application means hunting for every place that such switch
statements—or if/else
chains—exist and adding the new shape to each.
Moreover, it is very unlikely that all the switch
statements and if/else
chains would be as nicely structured as the one in DrawAllShapes
. It is much more likely that the predicates of the if
statements would be combined with logical operators or that the case
clauses of the switch
statements would be combined to “simplify” the local decision making. In some pathological situations, functions may do precisely the same things to Square
s that they do to Circle
s. Such functions would not even have the switch/ case
statements or if/else
chains. Thus, the problem of finding and understanding all the places where the new shape needs to be added can be nontrivial.
Also, consider the kinds of changes that would have to be made. We’d have to add a new member to the ShapeType
enum. Since all the different shapes depend on the declaration of this enum, we’d have to recompile them all.3 And we’d also have to recompile all the modules that depend on Shape
.
So, we not only must change the source code of all switch/case
statements or if/ else
chains but also alter the binary files, via recompilation, of all the modules that use any of the Shape
data structures. Changing the binary files means that any assemblies, DLLs, or other kinds of binary components must be redeployed. The simple act of adding a new shape to the application causes a cascade of subsequent changes to many source modules and even more binary modules and binary components. Clearly, the impact of adding a new shape is very large.
Let’s run through this again. The solution in Listing 9-1 is rigid because the addition of Triangle
causes Shape
, Square
, Circle
, and DrawAllShapes
to be recompiled and redeployed. The solution is fragile because there will be many other switch
/case
or if
/else
statements that are both difficult to find and difficult to decipher. The solution is immobile because anyone attempting to reuse DrawAllShapes
in another program is required to bring along Square
and Circle
, even if that new program does not need them. In short, Listing 9-1 exhibits many of the smells of bad design.
Figure 9-2 shows the code for a solution to the square/circle
problem that conforms to OCP. In this case, we have written an abstract class named Shape
. This abstract class has a single abstract method named Draw
. Both Circle
and Square
are derivatives of the Shape
class.
Listing 9-2. OOD solution to Square/Circle problem
public interface Shape
{
void Draw();
}
public class Square : Shape
{
public void Draw()
{
//draw a square
}
}
public class Circle : Shape
{
public void Draw()
{
//draw a circle
}
}
public void DrawAllShapes(IList shapes)
{
foreach(Shape shape in shapes)
shape.Draw();
}
Note that if we want to extend the behavior of the DrawAllShapes
function in Listing 9-2 to draw a new kind of shape, all we need do is add a new derivative of the Shape
class. The DrawAllShapes
function does not need to change. Thus, DrawAllShapes
conforms to OCP. Its behavior can be extended without modifying it. Indeed, adding a Triangle
class has absolutely no effect on any of the modules shown here. Clearly, some part of the system must change in order to deal with the Triangle
class, but all the code shown here is immune to the change.
In a real application, the Shape
class would have many more methods. Yet adding a new shape to the application is still quite simple, since all that is required is to create the new derivative and implement all its functions. There is no need to hunt through all the application, looking for places that require changes. This solution is not fragile.
Nor is the solution rigid. No existing source modules need to be modified, and no existing binary modules need to be rebuilt—with one exception. The module that creates instances of the new derivative of Shape
must be modified. Typically, this is done by main
, in some function called by main
, or in the method of some object created by main
.4
Finally, the solution is not immobile. DrawAllShapes
can be reused by any application without the need to bring Square
or Circle
along for the ride. Thus, the solution exhibits none of the attributes of bad design mentioned.
This program conforms to OCP. It is changed by adding new code rather than by changing existing code. Therefore, the program does not experience the cascade of changes exhibited by nonconforming programs. The only changes required are the addition of the new module and the main
related change that allows the new objects to be instantiated.
But consider what would happen to the DrawAllShapes
function from Listing 9-2 if we decided that all Circle
s should be drawn before any Square
s. The DrawAllShapes
function is not closed against a change, like this. To implement that change, we’ll have to go into DrawAllShapes
and scan the list first for Circles
and then again for Squares
.
Had we anticipated this kind of change, we could have invented an abstraction that protected us from it. The abstractions we chose in Listing 9-2 are more of a hindrance to this kind of change than a help. You may find this surprising; after all, what could be more natural than a Shape
base class with Square
and Circle
derivatives? Why isn’t that natural, real-world model the best one to use? Clearly, the answer is that that model is not natural in a system in which ordering is coupled to shape type.
This leads us to a disturbing conclusion. In general, no matter how “closed” a module is, there will always be some kind of change against which it is not closed. There is no model that is natural to all contexts!
Since closure cannot be complete, it must be strategic. That is, the designer must choose the kinds of changes against which to close the design, must guess at the kinds of changes that are most likely, and then construct abstractions to protect against those changes.
This takes a certain amount of prescience derived from experience. Experienced designers hope that they know the users and the industry well enough to judge the probability of various kinds of changes. These designers then invoke OCP against the most probable changes.
This is not easy. It amounts to making educated guesses about the likely kinds of changes that the application will suffer over time. When the designers guess right, they win. When they guess wrong, they lose. And they will certainly guess wrong some of the time.
Also, conforming to OCP is expensive. It takes development time and effort to create the appropriate abstractions. Those abstractions also increase the complexity of the software design. There is a limit to the amount of abstraction that the developers can afford. Clearly, we want to limit the application of OCP to changes that are likely.
How do we know which changes are likely? We do the appropriate research, we ask the appropriate questions, and we use our experience and common sense. And after all that, we wait until the changes happen!
How do we protect ourselves from changes? In the previous century, we said that we’d “put the hooks in” for changes that we thought might take place. We felt that this would make our software flexible.
However, the hooks we put in were often incorrect. Worse, they smelled of needless complexity that had to be supported and maintained, even though they weren’t used. This is not a good thing. We don’t want to load the design with lots of unnecessary abstraction. Rather, we want to wait until we need the abstraction and then put them in.
“Fool me once, shame on you. Fool me twice, shame on me.” This is a powerful attitude in software design. To keep from loading our software with needless complexity, we may permit ourselves to be fooled once. This means that we initially write our code expecting it not to change. When a change occurs, we implement the abstractions that protect us from future changes of that kind. In short, we take the first bullet and then make sure that we are protected from any more bullets coming from that particular gun.
If we decide to take the first bullet, it is to our advantage to get the bullets flying early and frequently. We want to know what kinds of changes are likely before we are very far down the development path. The longer we wait to find out what kinds of changes are likely, the more difficult it will be to create the appropriate abstractions.
Therefore, we need to stimulate the changes. We do this through several of the means discussed in Chapter 2.
• We write tests first. Testing is one kind of usage of the system. By writing tests first, we force the system to be testable. Therefore, changes in testability will not surprise us later. We will have built the abstractions that make the system testable. We are likely to find that many of these abstractions will protect us from other kinds of changes later.
• We use very short development cycles: days instead of weeks.
• We develop features before infrastructure and frequently show those features to stakeholders.
• We develop the most important features first.
• We release the software early and often. We get it in front of our customers and users as quickly and as often as possible.
OK, we’ve taken the first bullet. The user wants us to draw all Circle
s before any Square
s. Now we want to protect ourselves from any future changes of that kind.
How can we close the DrawAllShapes
function against changes in the ordering of drawing? Remember that closure is based on abstraction. Thus, in order to close DrawAllShapes
against ordering, we need some kind of “ordering abstraction.” This abstraction would provide an abstract interface through which any possible ordering policy could be expressed.
An ordering policy implies that, given any two objects, it is possible to discover which ought to be drawn first. C# provides such an abstraction. IComparable
is an interface with one method, CompareTo
. This method takes an object as a parameter and returns -1
if the receiving object is less than the parameter, 0
if they’re equal, and 1
if the receiving object is greater than the parameter.
Figure 9-3 shows what the Shape
class might look like when it extends the IComparable
interface.
Listing 9-3. Shape extending IComparable
public interface Shape : IComparable
{
void Draw();
}
Now that we have a way to determine the relative ordering of two Shape
objects, we can sort them and then draw them in order. Listing 9-4 shows the C# code that does this.
Listing 9-4. DrawAllShapes with ordering
public void DrawAllShapes(ArrayList shapes)
{
shapes.Sort();
foreach(Shape shape in shapes)
shape.Draw();
}
This gives us a means for ordering Shape
objects and for drawing them in the appropriate order. But we still do not have a decent ordering abstraction. As it stands, the individual Shape
objects will have to override the CompareTo
method in order to specify ordering. How would this work? What kind of code would we write in Circle.CompareTo
to ensure that Circle
s were drawn before Square
s? Consider Listing 9-5.
Listing 9-5. Ordering a Circle
public class Circle : Shape
{
public int CompareTo(object o)
{
if(o is Square)
return -1;
else
return 0;
}
}
It should be very clear that this function, and all its siblings in the other derivatives of Shape
, do not conform to OCP. There is no way to close them against new derivatives of Shape
. Every time a new derivative of Shape
is created, all the CompareTo()
functions will need to be changed.5
Of course, this doesn’t matter if no new derivatives of Shape
are ever created. On the other hand, if they are created frequently, this design would cause a significant amount of thrashing. Again, we’d take the first bullet.
If we must close the derivatives of Shape
from knowledge of one another, we can use a table-driven approach. Listing 9-6 shows one possibility.
Listing 9-6. Table driven type ordering mechanism
/// <summary>
/// This comparer will search the priorities
/// hashtable for a shape's type. The priorities
/// table defines the odering of shapes. Shapes
/// that are not found precede shapes that are found.
/// </summary>
public class ShapeComparer : IComparer
{
private static Hashtable priorities = new Hashtable();
static ShapeComparer()
{
priorities.Add(typeof(Circle), 1);
priorities.Add(typeof(Square), 2);
}
private int PriorityFor(Type type)
{
if(priorities.Contains(type))
return (int)priorities[type];
else
return 0;
}
public int Compare(object o1, object o2)
{
int priority1 = PriorityFor(o1.GetType());
int priority2 = PriorityFor(o2.GetType());
return priority1.CompareTo(priority2);
}
}
public void DrawAllShapes(ArrayList shapes)
{
shapes.Sort(new ShapeComparer());
foreach(Shape shape in shapes)
shape.Draw();
}
By taking this approach, we have successfully closed the DrawAllShapes
function against ordering issues in general and each of the Shape
derivatives against the creation of new Shape
derivatives or a change in policy that reorders the Shape
objects by their type (e.g., changing the ordering so that Square
s are drawn first).
The only item that is not closed against the order of the various Shapes
is the table itself. And that table can be placed in its own module, separate from all the other modules, so that changes to it do not affect any of the other modules.
In many ways, the Open/Closed Principle is at the heart of object-oriented design. Conformance to this principle is what yields the greatest benefits claimed for object-oriented technology: flexibility, reusability, and maintainability. Yet conformance to this principle is not achieved simply by using an object-oriented programming language. Nor is it a good idea to apply rampant abstraction to every part of the application. Rather, it requires a dedication on the part of the developers to apply abstraction only to those parts of the program that exhibit frequent change. Resisting premature abstraction is as important as abstraction itself.
[Jacobson92] Ivar Jacobson, Patrick Johnsson, Magnus Christerson, and Gunnar Övergaard, Object-Oriented Software Engineering: A Use Case Driven Approach, Addison-Wesley, 1992.
[Meyer97] Bertrand Meyer, Object Oriented Software Construction, 2d. ed., Prentice Hall, 1997.
3.137.171.121