Agile Principles, Patterns, and Practices in C#

The Shape Application

The 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.

Violating OCP

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 statementsor if/else chainsexist 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 Squares that they do to Circles. 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.

[3] In C/C++, changes to enums can cause a change in the size of the variable used to hold the enum. So, great care must be taken if you decide that you don't need to recompile the other shape declarations.

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.

Conforming to OCP

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 rebuiltwith 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]

[4] Such objects are known as factories, and we'll have more to say about them in Chapter 29.

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 Circles should be drawn before any Squares. 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.

Anticipation and "Natural" Structure

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!

Putting the "Hooks" In

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

"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.

Stimulating change

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.

Using Abstraction to Gain Explicit Closure

OK, we've taken the first bullet. The user wants us to draw all Circles before any Squares. 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 Circles were drawn before Squares? 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]

[5] It is possible to solve this problem by using the ACYCLIC VISITOR pattern described in Chapter 35. Showing that solution now would be getting ahead of ourselves a bit. I'll remind you to come back here at the end of that chapter.

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.

Using a Data-Driven Approach to Achieve Closure

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 Squares 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.

Категории