The Simplest Case
I start with a simple implementation of a PolarArray class that meets the requirements previously specified. I fixed the size of the globe to 8×8 (via const variables); see Listing One.
Not a bad start! This simple interface provides the following accessors:
- getValueAtIndex(int)
- getValueAtGeographicCoordinate(int, int)
- getValueAtNorthPole()
- getValueAtSouthPole()
- getValueAtGreatCircleCoordinate(int, int)
Clearly, we have satisfied the requirements for this class, and could conceivably halt development on PolarArray at this point. But what kinds of refinement could we make to this class interface? Is there a more intuitive way it could be constructed?
===== PolarArray.h ===== static const int ROWS = 8; static const int COLUMNS = 8; class PolarArray { public: explicit PolarArray(double * data) { memcpy(_data, data, ROWS * COLUMNS + 2); } double getValueAtIndex(int index) { return _data[index]; } double getValueAtGeographicCoordinate(int latitude, int longitude) { return getValueAtIndex(latitude * ROWS + longitude); } double getValueAtNorthPole() { return getValueAtIndex(ROWS * COLUMNS); } double getValueAtSouthPole() { return getValueAtIndex(ROWS * COLUMNS + 1); } double getValueAtGreatCircleCoordinate(int latitude, int longitude) { // The north pole if(latitude == 0) { return getValueAtNorthPole(); } // Any location on the "front" side of the globe else if(latitude > 0 && latitude <= ROWS) { return getValueAtGeographicCoordinate(latitude, longitude); } // The south pole else if(latitude == ROWS + 1) { return getValueAtSouthPole(); } // Any location on the "back" side of the globe else // latitude > ROWS + 1 { int trueLatitude = (ROWS * COLUMNS + 1) - latitude; int trueLongitude = longitude + COLUMNS / 2; return getValueAtGeographicCoordinate(trueLatitude, trueLongitude); } } private: double _data[ROWS * COLUMNS + 2]; }; // Example client code: PolarArray p(...); p.getValueAtIndex(10); p.getvalueAtGeographicCoordinate(2, 2); p.getValueAtGreatCircleCoordinate(12, 2); p.getValueAtNorthPole(); p.getValueAtSouthPole();
In this first implementation, each accessor function actually combines three distinct concepts:
- Value lookup. Getting a measurement from the globe.
- Coordinate system. Specifying a coordinate system (absolute, geographic, or great circle).
- Coordinate. Specifying the domain-specific coordinates themselves (single index, pair of latitude and longitude, or specific pole).
The function name specifies the first two concepts ("getValue" indicates we are requesting a value, "at<System>" denotes which coordinate system), and the function arguments specify the third (the coordinates within the system). Note that getNorthPole() and getSouthPole() actually specify the coordinate itself as part of the function name.
While it is not a sin to combine multiple concepts into a single token, it is conceptually clearer if the concepts are differentiated by more than simple capitalization. In the simple aforementioned example, the only delineation is the word "at," which separates the "getValue" portion of the function name from the coordinate system name.
Consider how you might use the various features of object-oriented design to better differentiate the two concepts in play. What if you refactor the index-translation portion of the class so that it is publicly available? You could force users to understand coordinate system and coordinates as distinct concepts from value lookup.
Listing Two (available online at www.ddj.com/code/) certainly differentiates the value lookup from the coordinate system. It is a bit unwieldy for the client, however, because any request for a PolarArray's value at a location must now come in the form of two function invocations instead of one. You have clearly differentiated coordinate system from value lookup, but at the expense of readability.
Let's back up a step and try a different approach. What if you simply factored out the great circle versus geographic system and North versus South Pole distinctions into class arguments? If you add a few enums to the code, the implementation might look like Listing Three (available online).
Now we're getting somewhere! You've refactored the distinction between great circle and geographic coordinate systems as a function argument to getValueAtCoordinate(...), as well as North and South Pole coordinates as a function argument to getValueAtPole(...).
Let's take this a step further. What if you used a type-based system to denote the coordinate system rather than a simple enum argument? You will create a Coordinate base class, from which you derive GeographicCoordinate and GreatCircleCoordinate. Similarly, you will create a Pole base class, from which we will derive NorthPole and SouthPole; see Listing Four (available online).
Now we're really making progress! Looking at the example client code, you see that the distinction between the three conceptsvalue lookup, coordinate system, and coordinateis clearly differentiated. If you pursue this inheritance-based coordinate specification fully, the class hierarchy looks like Figure 1.
By using dynamic_cast to identify the coordinate system, you can finalize the PolarArray code. Pay particular attention to the example client code at the bottom of the listing; notice how it is both readable and clearly differentiates the three concepts into separate tokens; see Listing Five (available online).
Discussion
Even a simple class interface can mask a complex system of overlapping concepts. The original PolarArray interface consisted of (a constructor and) five accessors. Each accessor, by virtue of its naming convention, bound up the concepts of value lookup, coordinate system, and (for the polar accessors) coordinates in the function name itself. By recognizing those different concepts and finding alternate ways to functionally specify them, I improved the distinction between these concepts. I implemented a class hierarchy that lets the client specify coordinate system by type, rather than function name.
Besides improving the class's understandability, this improved its maintainability. If in the future you want to add new coordinate systems, you can do so simply by deriving from the existing coordinate hierarchy and amending the PolarArray getValue() code to deal with the new type. This would be much more awkward to do in the original system!
However, specifying the polar coordinate system is still bound up directly with specifying a particular pole (because the user must create a NorthPole or a SouthPole instance). We could easily use the enum Pole as a data member for class Pole, and eliminate the NorthPole and SouthPole classes. I chose not to do this because the client code would be less readable; see Listing Six (available online).
I think the second version is simply not very readable! Similarly, it's arguable that I should not have created an AbsoluteIndex class because it was easier just to have a getValue(int) for that purpose.
Lastly, I realize that the use of a dynamic_cast to perform a type-based switch is a little ugly and doesn't have great performance. We could just as well use function overloading (getValue(NorthPole&), getValue(SouthPole&), getValue(GeographicCoordinate&), and the like) to accomplish the same thing.