8. Class Design Principles
One of the goals of the software architect is separation of concerns, that is, to break the problem into issues that are as independent (or orthogonal) as possible. The idea of separation of concerns is that the less that issues interact, the easier it is to grapple with their solution.
Concerns are intellectual encapsulations of issues. It may not necessarily be possible to localize the addressing of a concern into a single spot. Some concerns are diffused over the entire system. For example, instrumenting every method with entry and exit timing code. Such concerns are called aspects, and are associated with aspect-oriented programming.
The goal of language and methodology designers is to at least localize the descriptions of concerns and their solutions, even if the actual solution is spread out over the entire system.Abstraction
Abstraction is the architectural principle of hiding complexity inside a well-defined container, and accessing that complexity though a well-defined interface. One way of thinking about abstraction is to consider what is being hidden. Most abstraction involves two kinds of hiding:
information hiding - hide how we represent the data control hiding - delegating the details of behaviour
Modules, abstract data types, objects, systems, files, processes, and many other notions of computing are examples of using abstraction to hide complexity. To be general, we call any such kind of abstraction a unit, and the things inside a unit we call elements. A system is a collection of units.
The two main measures of the quality of a collection of abstraction units are coupling and cohesion.
Coupling is the measure of the strength of association established by a connection from one unit to another. Strong coupling complicates a system because understanding one unit requires understanding another. The goal is to reduce coupling as much as possible, which is accomplished by minimizing the relationships between elements not in the same unit.
Cohesion is the measure of the strength of association between elements of a unit. Cohesion can be measured, informally and nonlinearly, by asking why a collection of elements appears in a unit, that is, what binds them together. Possible (but not exhaustive) kinds of binding, in increasing degree of cohesiveness, can be:
Coincidental - the elements are related only because they appear in the unit. Logical - the elements are present in the unit because they appear together under some logical view of the system. For example, they all have something to do with the user interface. Under a different view they may not be related. Temporal - the elements are related because the are activated at roughly the same time. For example, they are all initialized at once. Temporal binding is logical binding under a temporal view. Communicational - the elements are together because they share some common repository of information. Collaborative - the elements of the unit are together because they collaborate to perform some process. For example, all or portion of a workflow. Functional - the elements of the unit are together because they collaborate to perform some well-defined function. Acessors
Instance variables of objects should always be private. Provide accessor functions to Set and Get the values of the instance variables. Using accessors allows you to
- restrict access to data, such as allowing only read access,
- to control access to data, such as not allowing a read when the data is locked,
- instrument access to data, such as keeping a log of who has accessed it,
- seamlessly use computed values or derived data as if it was an actual attribute.
One of the key measures of a design is how much one object needs to know about another object or the system context in order to operate. The Demeter project, of Karl Liebeeerherr and Ian Holland, http://www.ccs.neu.edu/home/lieber/demeter, has identified some desireable properties associated with message dispatch. The idea is that you do not want to encode too many details of the class structure in the method implementations, otherwise when structure is changed, many methods will also have to be changed. In general, public methods of derived classes should invoke only their own private and public methods, or the public methods of their parents. This preserves encapsulation of their parents, and allows parent implementations to be changed without breaking the children.
A guideline for minimizing this kind of coupling is expressed by the Law of Demeter:
A supplier object to a method M is an object to which a message is sent in M.
Law of Demeter: An object O in response to a message M should send messages only to the following preferred supplier objects:
- O itself,
- objects sent as arguments of message M,
- objects O creates as part of its reaction to message M,
- objects which are directly accessible instance variables of O,
- objects which provide global services to O.
There are many ways in which existing designs and implementation can be reused in a different context. Here is a simply taxonomy:
Composition (or aggregation) is achieved by using existing objects as parts of a new object. The new object contains instances of a previously defined class, that is, it owns the instances, and when the larger object is destroyed, so are all its contained parts. As an implementation concern, the parts can be contained by value in which case they are actually a part of the object, or by reference in which case they are pointed to by references in the larger object.
We can distinguish two kinds of composition (has-a):
- Additive - in which the new object is the sum of its parts. For example, an order containing a number of detail objects.
- Projective - in which the purpose of the new object is to wrap an existing object in order to hide some behaviour and expose others. An example is the square/rectangle problem that follows.
Examples of patterns:
templates - parameterized generation of new program elements (classes, algorithms, etc.) interfaces - allow many different implementations of the same abstraction. references - allow deferred binding of data to the algorithm.
Rather than using an existing class, patterns are mechanisms for taking a general structure, parameterized by one of more abstract classes, and generating a specific instance. For example, container classes are typically generated by patterns because there is no type-safe way of constructing an abstract base-class for a container. This is true even simple containers like arrays and lists.Via Inheritance
Inheritance is the reuse innovation of OO. Like all powerful tools, it is susceptible to misuse. This is partly due to it being used in two ways:
- by extension in which derived classes obey the principle of substitution. That is, a derived class can always be used like its ancestors because the derived class extends the base classes functionality. A derived class is-a ancestor class.
- by exception in which derived classes limit the functionality of their ancestors in some way. In this case a derived class is not necessarily substitutable for an ancestor. This is often seen when representing catalogs, where descending the inheritance hierarchy results in more and more specialized classes.
When we take a class C and construct a new class D from it, possibly by having D inherit from C, we have to ask:
- is class D augmenting the behaviour of C?
- is class D restricting the behaviour of C?
- or both?
Design By Contract: Every attribute and method within the class should be documented with pre-conditions, post-conditions, exceptional-conditions, invariants, and side-effects. If possible, assertions about these should be incorporated into the code and left enabled in production releases.
Design Principle: You should try to build your class hierarchy so that factoring of parent classes does not affect interfaces and implementations of derived classes. That is, derived classes should not look at how their ancestors provide services, but only at the interface to those services, and should not explicitly mention where those services are located.Liskov's Principle of Substitution
If a class D is "just like" a class C except for extensions, then it should be possible to use a D object anywhere you an use an C object. That is, a child should be able to be used where ever the parent can be used.Design Principle: Suppose class D is derived from class C. Then derived class D is-a C. That is, whatever an object of super-class C can do, so should an object of sub-class D. Is-a vs Has-a
Design your classes to preserve this property unless you have a strong reason to do otherwise.
Design Principle: Roles are acquired via attributes, not by subclassing.
Consider this example. The first diagram shows how a Person can be subclassed into Manager, Employee, or Student. This is the wrong way to assign a role. It implies that a person can only have one role, and can never change their role. The problem is that the inheritance hierarchy for Person is being confused with the inheritance hierarchy for Role. The second diagram is a better way.
Now a resonable question is how does a person know what function they can do? That is, how do they know what the capabilities of their current role is? And if some other object needs a person with those capabilities, how do they locate one?
Suppose that class D is derived from class C. We can think of D as class C with some extra data and methods. In terms of data, D has all the data that C has, and possible more. In terms of methods, D cannot hide any methods of C, and may have additional methods. In terms of existing methods of C, the only thing that D can do is to override them with its own versions.
If x is an object of class D, then we can slice x with respect to C, by throwing away all of the extensions that made x a D, and keeping only the C part. The result of the slicing is always an object of class C.
Design Principle: Slicing an object with respect to a parent class C should still produce a well-formed object of class C. That is, the state invariants for D should imply the state invariants for C.
Usage Warning: Even though D is-a C, you must be careful. If you have an argument type that is a C and you supply a D it will be sliced in most compiled languages if you are doing call by value, pointer, or reference.
Watch out for the sliced = operator, it can make the lhs inconsistent. Also, the = operator is never virtual, it wouldn't make sense. For example, suppose classes A, B are both subclasses of class C. Just because an A is a C, and a B is a C, it doesn't mean you can assign a B object to an A object. Without run-time type information you cannot make a safe assignment.
Principle of substitution (again): Suppose that x is an object of class C, and y is an object of class D, and that x is a C slice of y. That is, the C parts of x and y are identical. Suppose that M is a method of class C.
When it is legal to perform x.M, then since D is-a C, it should be legal to perform y.M. That is
Note that the C slice of y.M need not be equivalent to x.M, that is, doing something in D might generate a different result than in C, yet both satisfy the postconditions and maintain the invariants. For example, the operation M might have the postcondition that M increases the value of attribute d1. But M in D might increase it by 1, while M in C increases it by 2. In both cases, the invariant that d1 is positive is preserved.
Also note that the principle of substitution is relative to the method M. There may be other methods that don't obey substitution, and these should not be used polymorphically, or in circumstances where they might invalidate the invariants needed for substitution of other methods.Polymorphism and Virtual Functions
Suppose that class D has been derived from C in a way that preserves substitution. Can a D be simply substituted for a C? A sliced D will certainly work as a C, but the effect of performing a method on the slice could be to make the object internally inconsistent.
This is where you need virtual functions. Virtual functions achieve polymorphism by allowing any derived class to substitute for the base class.
- All methods of C declared virtual, except constructors.
- All methods of C correctly implemented in derived D to account for augmentation of D.
- Whenever a D is substituted for a C, the D'ness is preserved, and the corresponding virtual method of D is executed.
- Virtual functions must have the same signatures.
An abstract base class, that is, one that defines an interface without implementing anything, has all methods pure virtual.
Design Principle: If one method is virtual in a class, all of them should probably be virtual, including the destructors. (Except for those methods that would never be overridden by a derived class.)
Design Principle: Do not, if at all possible, inspect an object to determine its class. Sometimes this is necessary, for example when building a wrapper class that has to inspect the kind of class provided to it in order to achieve the correct transformation of the interface - this typically occurs with generic callback mechanisms.
We frequently need to collect objects together into containers. A container for class C is a class whose purpose is to contain objects from class C or its descendents.
Design Principle: Container classes should be generated by template substitution, not inheritance.
Suppose D is a subclass of C. A container for D is not a container for C. That is, containers violate the principle of substitution. For example, although the apple class is-a fruit class, and a bag containing apples is-a bag containing fruit, an apple bag is not a fruit bag --- if it was, you could put a banana into it.Behaviour Restriction
Design Principle: If a new class D is like an existing class C, but with restrictions on its behaviour, then D should not be derived from C via inheritance. Why: because restriction violates the principle of substitution. Instead, class D should contain an instance of class C (aggregation). Most of D's behaviour is directly delegated to C, with only a few methods being suitably restricted.
Example: A square is not a rectangle. Suppose that we have a class Rectangle that has two attributes l, w and a method SetSize(l,w) which sets l and w. It is tempting to derive a new class Square, that is like a Rectangle except that it overrides SetSize(l,w) to only allow cases where l=w. This violates the principle of substitution, because the pre-condition for calling SetSize on a Rectangle does not imply the pre-condition for calling SetSize on a Square.
It is better to define Square in terms of containing a Rectangle, and giving Square a method SetWidth(x) which calls SetSize(x,x) on its contained Rectangle. All other properties of Square that are Rectangle-ish, such as its color, or position in space, can be delegated to the contained Rectangle.
If both Square and Rectangle are subclasses of a graphics Figure, then the Draw method for Square is delegated to the Draw method for its contained Rectangle.Foundation Classes
Consider the following inheritance diagram
If C is being used as a foundation class, then the derived classes are always going to be used on their own. That is, they will always be used in situations where their type will be declared as a D or E. We will never want to manipulate them abstractly as C's.
In this case, the substitution rule could be relaxed. A class derived from C is free to redefine the methods of C as it sees fit. Usually the purpose of C is to provide some basic services that need to be extended or modified a bit. If you do violate the substitution rule, it is important that the resulting D and E classes promise never to be used in a situation where they might be asked to substitute for a C. Slicing could make the internal state inconsistent.
This said, you can still argue that foundation classes obey the substitution rule. Simply set the pre-condition of method M in C to be false, and the post-condition of M in C to be true. The pre in C, i.e. false, always implies any possible pre in D. Similarly, post in D will always imply true, i.e. post in C. Because the precondition of M in C is always false, it is never supposed to be called on a C, and respects the promise above.
Consider the following inheritance diagram. The idea is that the base Shape class provides an interface for manipulating shapes. The original class has a very flat inheritance hierarchy, with every basic shape descending from the main shape.
In an attempt to generalize, squares were derived from rectangles, and ellipses from circles. This was ok so long as the original size of the shape was set on creation time, and only altered by magnification later. But a new method ChangeAspect was added to allow rectangles and ellipses to have their aspect ratios changed. This broke the polymorphism, because now squares and circles do not obey the principle of substitution.
Often a method can be moved up or down the hierarchy to preserve substitution. So a third refactoring was done to devide the shapes into reshapable and not. This means that not every shape can have its aspect ration changed. It then becomes impossible to polymorphically operate on shapes with respect to changing their aspect ratio (this affects the graphics editing menu for example). Impossible, that is, unless we introduce a method, IsStretchable, in the base class and make the pre-condition of ChangeAspect to be that IsStretchable is true. Then the polymorphic operation becomes guarded with a testable pre-condition. In general, substitutability can be preserved by providing a testable pre-condition predicate that indicates whether it is safe to call the method or not.
Another kind of refactoring can occur along service lines. For example, the shape class above might be used to construct a 2 dimensional model of a system, for example a UML diagram. Using components like the shape class above, the model has two roles: to maintain the mathematical stucture that describes the system, and also to render the visual representation of the system. This means that the model and view roles are tightly coupled. In particular, an element of the model (e.g. a class description) is tied to a graphical icon (like a square).
But in many cases, one wants to interact with the model non-graphically. For example a code analyser might want to construct a UML model of an existing system for reverse engineering purposes. Service refactoring in this case means to separate out the two roles into different services: one for maintaining the model, another for providing a view of the model. This way, many different views can be generated of the model, while preserving the core class structure.
One can describe the relationships discussued above using the notions of category theory. In brief:
|Reuse Notion||Category Theory Notion|
|8. Class Design Principles|