| Exploring Solution Spaces © Copyright 2003-2006, by C. Keith Ray | ||||||||||||||||||||||||
|
Archives
Subscribe |
2004.Nov.02 Tue There is a meme going around that it is "an order of magnitude more difficult" to write a class that is "re-usable". That didn't seem right to me, so I decided to list the rules one could follow to write re-usable classes. Once I got up to about nine or ten rules, I decided that it might not be as simple as I thought. Still, compared to all the rules one must know to write safe C++ code, these rules are not that hard. The following list isn't numbered the way I originally wrote them -- it's 7 rules here, but at some point you get into the rules of good design, the rules for using C++ or Java or whatever robustly, etc. so you could go further and make the list longer. By the way, writing code test-first tends to make code much more re-usable, because the code is "used" at least twice - one or more times in the tests, and one or more times in the production code. Note: the previous statement is an understatement. If you as the 're-user' have access to the source code, and can refactor code as needed, then you don't have to follow all these rules initially. For example Rule 6 below doesn't need to be followed unless you determine that you need to do this to make re-use work, then you can refactor to make it work. One obstacle to re-use is class dependencies and package dependencies. Once upon a time, I wanted to use a particular Java class, but it used so many other classes in the same package, that I was forced to add the entire package to my project. But that package used classes in a bunch of other packages. In order to use that single class, I would have had to include at least six packages and a hundred classes in my project. So... Re-use Rule 1: Write your class to avoiding depending on other classes (etc). Obviously that can't always be done. Rule 1a: Try to use only the "basic" classes that everyone uses. More rules to come also address problems that contribute to being forced to grab a lot of classes just to use one class. Rule 1b: avoid writing "too-big" classes and "too-big" packages. Re-use Rule 2: a class should have one responsibility, delegating other responsibilities to other classes. Ditto for packages. Symptoms of having too many responsibilities are groups of methods that each only deal with isolated member variables, and having lots of private (or protected) methods. Symptoms of too-big packages are classes that don't depend on other classes within the package. Classes that are unrelated to whatever "concept" the other classes implement. Packages named "misc". Just because a bunch of classes implement an interface or subclass from a certain class, it doesn't necessarily mean that they belong together in the same package, or even belong in the package in which the interface or parent class resides. Re-use Rule 3: a class should not depend on other concrete classes [except the most basic types like String]. By using interfaces or abstract classes as the "type" of member variables / return values / arguments to methods and constructors, we enable easier re-use (and testing): instead of the passing in the concrete types that were expected, we can pass in new concrete types useful in the new "re-use" context. Take advantage of the power of polymorphism and all that (or at least don't prevent others from doing so). Dynamic languages like Python, Smalltalk, Ruby and so on, which don't declare the types of variables, don't have this problem. Re-use Rule 4: To implement rule 3 and to avoid circular package-dependencies (and to allow re-use without dragging in lots of packages), the interface, the class using that interface, and the concrete classes that implement that interface, probably need to reside in three different (and fairly small) packages. Package design regarding interfaces and concrete classes isn't discussed in many places, but it is discussed in Robert Martin's book "Agile Software Development". Re-use Rule 4: to further allow substitution of concrete types, avoid using "new ConcreteClass" within your class -- particularly the constructors. If you do need to create objects of particular types (instead of having those objects passed into methods and constructors), put the "new" call into a "creation method". This allows the re-user of that class to override that method to instantiate a different kind of object. This is useful for creating Mock objects in unit-tests. Re-use Rule 5: the user of your class may want to subclass-and-override. Make that possible by avoiding static, final, or non-virtual methods, and private methods. Private methods either need to be declared protected, or should be moved to another class and made public, with the original class using an instance of that other class. Re-use Rule 6: You can make member variables protected, or make member variables private. If you make them private, you should implement protected (or, if absolutely necessary, public) accessor methods to get/set the member variables. A subclass can override those accessor methods to extend or modify behavior associated with getting/setting, including returning different objects. If you go this route, then no method or constructor should ever access those private variables directly except the accessor methods themselves. Re-use Rule 7: In event that re-use of your class would involve overriding ALL of the methods, and ignoring ALL of the member variables, declare an interface and make your class implement that interface. Now instead of overriding every method in your class, the re-user can make their own implementation of your interface. C++ makes following some of these rules difficult. For example, in a constructor, method calls to virtual functions act like the method is not declared virtual. In the constructor of class "Parent", calls to virtual accessor methods to set member variables will not invoke overriding accessor methods implemented in class "Child." In this case, you would need to do the work of setting member variables via virtual accessor methods in an "Initialize" method, which must NOT be called from the constructor. For example: "Parent* obj = new Child; obj->Initialize();". Don't forget to declare destructors virtual. C++ allows declaring argument types "by value" as well as "reference" and "pointer". "By value" doesn't allow polymorphism because it creates a new object of the declared type on the stack, possibly copying only a subset of the members from a sub-class object passed into that method call. Following all of these rules all of the time may create ugly code. Make classes re-usable "as needed". Develop test-first so that you can later refactor classes for re-use at any time, protected from unintended bugs by suites of unit tests. There is a concept for class hierarchies of "Object-Oriented Normal Form" in Michael Feather's book "Working with Legacy Code" summed up by the rule: "No child class's methods override any parent class's concrete methods." Any time you would be tempted to do that, you instead restructure the inheritance hierarchy, adding abstract classes or interfaces so that you only override unimplemented abstract/interface methods ("pure virtual" methods in C++.) Here's an example of following these rules in C++:
class Region // the "not very re-usable" version of this class
{
public:
Region();
Region( Rect shape ); // "by value" argument
~Region(); // not virtual, making subclassing dangerous
void Clear(); // not virtual, can't be overridden.
void Union( Rect anotherShape );
void Intersect( Rect anotherShape );
Bitmap ConvertToBitmap();
private:
Bitmap myBitmap;
};
// Now the more re-usable version...
class RegionInterface
{
public:
virtual ~RegionInterface();
virtual void Clear();
virtual void Union( const RectInterface& );
virtual void Intersect( const RectInterface& );
virtual BitmapPtr ConvertToBitmap();
};
//BitmapPtr is a smart pointer declared something like this:
//typedef std::auto_ptr < BitmapInterface > BitmapPtr;
class Region : public RegionInterface
{
public:
Region();
virtual ~Region(); // "virtual" necessary for "delete (RegionSubclass*) obj" to work right.
virtual void Initialize( const RectInterface & ); // because we can't call virtuals in the ctor
virtual void Clear();
virtual void Union( const RectInterface& );
virtual void Intersect( const RectInterface& );
virtual BitmapPtr ConvertToBitmap();
protected:
BitmapPtr GetBitmap() const;
void SetBitmap( BitmapPtr );
private:
BitmapPtr myBitmap;
};
Note that doing
Region* reg = new Region;
reg->Initialize( aRect );
more than once in the code-base is repeated code [a bad thing], so you would want to put those two lines into a creation-function (probably a static method in class Region). I've also left out copy constructors and assignment operators. One last aside... look above at how much "overhead" C++ requires. In dynamic languages like Smalltalk, the only difference between the "less re-usable" class and the "more re-usable" class would be the addition of the accessor methods (which I understand Smalltalkers do by habit already.) Here's the same example in a dynamic "c-ish/pascal-ish" language I just made up:
class Region // the "not very re-usable" version of this class
{
public:
ctor Region();
ctor Region( shape );
proc Clear(); // not virtual, can't be overridden.
proc Union( anotherShape );
proc Intersect( anotherShape );
func ConvertToBitmap();
private:
var myBitmap;
};
class Region // more re-useable version
{
public:
ctor Region();
ctor Region( shape );
proc Clear(); // not virtual, can't be overridden.
proc Union( anotherShape );
proc Intersect( anotherShape );
func ConvertToBitmap();
protected:
func GetBitmap() const;
proc SetBitmap( aBitmap );
private:
var myBitmap;
};
If you developed test-first, you would have pretty much the same set of tests for this class in a dynamic language as you would in a static language, but much less overhead text. |
|||||||||||||||||||||||