Tải bản đầy đủ - 0 (trang)
Chapter 14. Choosing between Inheritance and Composition

Chapter 14. Choosing between Inheritance and Composition

Tải bản đầy đủ - 0trang

file://///Administrator/General%20English%20Learning/it2002-7-6/core.htm



I will also use lower level specific criteria such as encapsulation, information hiding, coupling, and

cohesion.

All of these techniques are directed toward making code easier to read. In this chapter, one of the

criteria to judge the quality of design will be ease of writing. This is a major deviation from the

principles proclaimed in the Chapter 1, where I stressed the ease of reading and argued that the ease

of writing is usually achieved at the expense of the ease of reading and hence should be avoided.

After all, we write code only once, when we type it in, and the actual typing takes only a miniscule

fraction of the time that we spend reading the code when we try to debug it, test it, integrate it,

reuse it, or modify it.

This shift in emphasis is unavoidable when you use inheritance because inheritance is a design

technique directed toward ease of writing. The designer of the server class derives the server class

from the base class, not to serve the client code better, but for the convenience of the server class

implementation. Ideally, the designer of the client code should not care whether the server class is

designed from scratch or is derived from some base class (as long as the server class supports the

services that the client code needs).

This is the ideal but real life is different from the ideal in C++ programming, much as in other

human activities. The use of inheritance (for the sake of the ease of writing the server classes) is at

odds with the ease of reading, both for the client code and for the maintainer. In the next chapter, I

will show you how to use inheritance to make the client code simpler as well.

For the discussion of links among classes, I will use the Unified Modeling Language (UML)

notation to describe the relationships between classes in the application. Today, the use of UML is

considered critical for the success of object-oriented design and implementation. Many

organizations embrace it for their object-oriented projects. Anecdotal evidence of UML utility is

plentiful, but there is yet no hard evidence that the use of UML makes object-oriented projects

successful. UML is a product of a political and technical compromise rather than the result of a

breakthrough in development. It was designed by a committee with the goal of unifying several

earlier versions of object-oriented design notation and they added more features for describing

object relationships in more detail. However, each member of the committee was trying to add to

UML the features of their favorite notation system. As a result, the language is overblown with

features and is very difficult to learn.

This is a pity. The language should be unobtrusive. It should allow the developers to communicate

their ideas and understand the ideas of others with ease. If someone is a novice in the language, so

that his or her statements are ambiguous or confusing, there should be a compiler that warns the

designer about that. Nothing of that sort is available for UML. It tends to produce diagrams that are

more complex than necessary. My experience with UML (and its predecessors) shows that it is a

waste of time to learn it before you know an object-oriented language well. Also, this modeling

file://///Administrator/General%20English%20Learning/it2002-7-6/core.htm (819 of 1187) [8/17/2002 2:58:03 PM]



file://///Administrator/General%20English%20Learning/it2002-7-6/core.htm



language is so huge, and possible design variations are so broad, that one should not try to study it

while learning an object-oriented language¡Xit will not speed up the process of learning. However,

the basic UML (or any of its predecessors) could and should be used to describe the object-oriented

designs implemented in C++.

This is what I will try to do in this chapter: I will introduce the basic UML notation as a descriptive

tool for comparing the uses of inheritance and composition. I will also use UML notation for

illustrating general relationships among objects in a program. The examples I discuss in this

chapter are large enough to warrant several approaches to their design and implementation, and the

use of the UML will be helpful for understanding the high-level issues of program design.



Choosing a Technique for Code Reuse

In this section, I will discuss the relative advantages and disadvantages of using inheritance and

composition. Both relationships are client-server relationships. A derived class is a client of the

base class, and the base class is a server of the derived class. A composite class is a client of its

component class, and the component class is a server of the composite class. This means that you

are going to see significant similarities between C++ programs built with alternative design

techniques.

The common feature of different design solutions is the division of work between the client and the

server classes, be it with the use of composition or any other design techniques. This means that the

server class has to be implemented before the client class can be designed. Hence, the techniques,

which are discussed in this section, can be used both for program development and for program

evolution.



Example of a Client-Server Relationship Between Classes

As a simple example of the client-server relationship, I will discuss an application that uses class

Circle, with a data member for the circle radius, so that the client code can send messages to

access the internal data representation in Circle objects.

Circle c1(2.5), c2(5.0);

double len = c1.getLength();

double area = c2.getArea();

c1.set(3.0);

double diam = 2 * c1.getRadius();



// set the value of radius

// compute circumference

// access internal data



To support this kind of client code, the class Circle should implement at least five public member

functions:

file://///Administrator/General%20English%20Learning/it2002-7-6/core.htm (820 of 1187) [8/17/2002 2:58:03 PM]



file://///Administrator/General%20English%20Learning/it2002-7-6/core.htm



ϒΠ



a constructor with one integer parameter



ϒΠ



a method getLength() that returns the circle circumference



ϒΠ



a method getRadius() that returns the circle radius



ϒΠ



a method set() that changes the circle radius



ϒΠ



a method getArea() that returns the circle area



Notice again, that it is the needs of the specific client code that define how the server class is going

to look. This is not the only possible mode of C++ programming. When a high premium is put on

the reuse of software components, server classes are often designed as library classes so that they

can satisfy the needs of the maximum number of clients. To achieve this, the server classes offer

the services that the class designer thinks will satisfy the maximum number of clients. As a result,

some clients have to work harder to use these generic classes.

In this book, I do not pay much attention to the design of library classes. To design these classes

well, one has to provide access to internal data representation and let the client programmers

manipulate the data as they see fit.

The second mode of C++ programming, the mode of supporting the client-server relationship, is

much more challenging. It requires the server programmer to recognize the client needs and

implement methods that satisfy these needs rather than just bring information to the client code for

manipulation.

Notice also that I send messages to the server objects to access the internal data representation.

Whatever a class method does (e.g., multiplies the circle radius by two and by PI to compute the

circumference), it accesses the internal data representation (in this case, radius) on behalf of the

client code that does not have such access. To support the client needs in this case, class Circle

should look this way.

class Circle

{ protected:

double radius;

public:

Circle (double r)

{ radius = r; }

double getLength () const

{ return 2 * PI * radius; }

double getArea () const



// original code for reuse

// inheritance is one of the options

// internal data

// support for initialization

// compute circumference

// compute area



file://///Administrator/General%20English%20Learning/it2002-7-6/core.htm (821 of 1187) [8/17/2002 2:58:03 PM]



file://///Administrator/General%20English%20Learning/it2002-7-6/core.htm



{ return PI * radius * radius; }

double getRadius() const

{ return radius; }

void set(double r)

{ radius = r; } };



// change size



Those of you who would like to avoid errors related to writing floating point numbers (and would

like to have more practice in using initialization lists) could use a different version of class Circle.

class Circle

{ protected:

options

const double PI;

the list

double radius;

public:

Circle (double r) : PI (3.1415926536)

{ radius = r; }

double getLength ()

{ return 2 * PI * radius; }

double getArea () // compute area

{ return PI * radius * radius; }

double getRadius() const

{ return radius; }

void set(double r)

{ radius = r; } };



// original code for reuse

// inheritance is one of the

// it must be initialized in

// internal data

// initializer list

// compute circumference



// change size



Notice that I quoted only one common rationale for using a constant instead of a numeric literal:

the likelihood of typing errors when the same literal is typed in different places in the code. I did

not use another popular rationale: convenience of changing the value at the time of maintenance.

Unless there is an unexpected scientific breakthrough, the value of PI is not going to change soon.

Also notice that now I multiply PI by 2 each time the method getLength() is called. These are not

serious drawbacks, but they indicate that the real goal of defining PI as a constant in this example is

to show you once again that the initializer list can contain not only constructor parameters, but also

literal arguments.

Finally, notice that PI is defined as local to class Circle. If other classes in the application need

this value, they have to either define it themselves or get it from class Circle. To facilitate this,

this constant data member could be made public.



file://///Administrator/General%20English%20Learning/it2002-7-6/core.htm (822 of 1187) [8/17/2002 2:58:03 PM]



file://///Administrator/General%20English%20Learning/it2002-7-6/core.htm



class Circle

{ protected:

options

double radius;

public:

const double PI;

list

public:

Circle (double r) : PI (3.1415926536)

{ radius = r; }

¡K } ;



// original code for reuse

// inheritance is one of the

// internal data

// it must be initialized in the



// initializer list

// the rest of class Circle



You see here a popular technique for defining public data in a separate public section to make it

more conspicuous. Had I defined PI in the same public section as the class member function, it

might have been lost there.

All right, now class Circle is in place¡Xbut wait a minute, is it in place? In this design of class

Circle, each Circle object is allocated memory for the value of PI individually. Meanwhile, this

value is the same for each object. Allocating this memory for each Circle object is a waste.

Programmers who work with non-object-oriented languages do not have to deal with these issues.

C++ programmers deal with these issues all the time. It is important to develop the appropriate

intuition to spot the potential waste. Until this intuition is developed, it is a good idea to be vigilant

and scrutinize the use patterns for each data member. When a data member has the same value for

each object of the class, this is a situation where the use of static data fits the bill beautifully. Here

is class Circle that allocates only one value of PI for all of its objects:

class Circle

//

{ protected:

//

double radius;

//

public:

static const double PI;

//

public:

Circle (double r)

//

{ radius = r; }

double getLength () const

//

{ return 2 * PI * radius; }

double getArea () const

//

{ return PI * radius * radius; }

double getRadius() const

{ return radius; }

void set(double r)

{ radius = r; } };

//



original code for reuse

inheritance is one of the options

internal data

it must be initialized

initializer list

compute circumference

compute area



change size



file://///Administrator/General%20English%20Learning/it2002-7-6/core.htm (823 of 1187) [8/17/2002 2:58:03 PM]



file://///Administrator/General%20English%20Learning/it2002-7-6/core.htm



const double Circle::PI = 3.1415926536;



As you see, the initialization of a static data member does not require the member initialization list.

It is initialized in the definition, which is implemented in the same file as class member functions.

Similar to member functions, the class scope operator specifies to which class this data member

belongs. If you want to define a data member PI in another class, these names will not conflict with

each other because they belong to different classes.

This example shows again that the C++ programmer should always think about different aspects of

the program design. Concentrating on part of the picture only results in wrong conclusions that

might lead to waste or even to errors.

Make sure that the diversity of issues you should always think about while writing C++ code does

not make your vision too narrow.

All right, now that the class Circle is in place, let us consider the client code requirements of the

class Cylinder that has data members describing the cylinder radius and height.

Cylinder cyl1(2.5,6.0), cyl2(5.0,7.5);

double length =cyl1.getLength();

cyl1.set(3.0);

double diam = 2 * cyl1.getRadius();

double vol = cyl2.Volume();



// initialize data

// similar to Circle

// no call to getArea()

// not in Circle



Even though classes Circle and Cylinder are different, they have similar internal structures (the

radius data member) and provide services that have the same name and same interface, for

example, getLength(). This is why the issue of reusing the class Circle in the design of class

Cylinder is a valid one.

Although I tried to keep this example small to let you concentrate on the design issues rather than

on the design details, the example shows that some of the existing Circle services should not be

made available in the Cylinder objects, for example, the method getArea(). On the other hand,

Cylinder clients might need services that are not available to the Circle clients, for example, the

method Volume(). This is typical for most reuse contexts¡Xsome of the existing services are

reused, some are suppressed or ignored, and some new services are added.

Now let us assume that the Circle code is available, but the class Cylinder is not designed yet.

The similarities between classes suggest that we should try to build class Cylinder using class

file://///Administrator/General%20English%20Learning/it2002-7-6/core.htm (824 of 1187) [8/17/2002 2:58:03 PM]



file://///Administrator/General%20English%20Learning/it2002-7-6/core.htm



Circle code



so that the reuse of available code is maximized and facilitated.



For many people, this similarity is a decisive argument in favor of using inheritance. This is too

simplistic. Inheritance is used too much in industry. It should not be your first choice in reuse, or at

least it should be chosen as the result of comparisons with other techniques of code reuse. How do

you choose one technique over another?

The amount and convenience of code reuse should be your first criterion. The next two criteria

should be the amount of new code that should be written and the extent of testing. This example is

very small, so the differences are not going to be essential, but they will show you what to pay

attention to while deciding which way to go.

In general, there are four approaches to code reuse: reuse of human intelligence (i.e., writing code

from scratch); writing a new class so that its methods are using (buying) methods (services) of the

existing class; writing a new class so that it inherits from the existing class, and its objects provide

their clients with the base services; and using inheritance with redefinition of some methods. For

this example, the agenda for each approach should include:

1. Human intelligence: Write new code for Cylinder from scratch, using the editor to copy

the Circle code for radius, getLength(), and other member functions into class Cylinder

and adding new Cylinder code to do the job that class Circle does not provide.

2. Buy services: Using the assumption that each cylinder "has a" circle object inside it, you

design the Cylinder class as a composite class. An object of the Circle type is used as a data

member in class Cylinder, and the Cylinder methods (e.g., getLength()), send messages

with the same name to the Circle component.

3. Inherit from the existing class as a base class: Using the assumption that each cylinder

object "is a" circle (plus some more and probably minus some), you design the Cylinder class

as a class derived from Circle; there is no need to implement code for inherited methods, for

example, getLength() because each Cylinder object can respond to these messages

inherited from its Circle base.

4. Inherit but redefine some methods: This approach supports a new way to do existing

operations; for example, the area of a cylinder should be computed differently from the area of

a circle.

In the following sections, I will implement the class Cylinder using each of these techniques and

will discuss relative advantages and disadvantages of each approach.



Reuse Through Human Intelligence: Just Do It Again

file://///Administrator/General%20English%20Learning/it2002-7-6/core.htm (825 of 1187) [8/17/2002 2:58:03 PM]



file://///Administrator/General%20English%20Learning/it2002-7-6/core.htm



Reuse through human intelligence is very common in non-object-oriented programming. It seems

that in object-oriented languages, the programmers are so excited about using inheritance and

composition that they look down on such a low-tech method of code reuse.

In this approach, you draw on your past. If you have experience in similar tasks, you reproduce the

code you wrote earlier and edit as is appropriate to satisfy the new requirements. In this case,

assume that you wrote and tested class Circle recently, and now your task is to write class

Cylinder. I call this approach reuse through human intelligence because you reuse the knowledge

that you accumulated by working on similar code.

Listing 14.1 shows the design of class Cylinder, which uses the design of class Circle. You

reproduce the data part of the class Circle (in this case, the radius data member) and add

whatever the class Cylinder requires (the height data member). You reproduce the constructor

and add the parameter and code to initialize the height data member. You copy the methods that

can be reused verbatim (in this case, the method getLength() and others). You bite the bullet and

implement the Cylinder methods that the class Circle lacks (in this case, the Volume() method).

And you do not pay attention to the Circle methods that are not needed in the class Cylinder (in

this case, the getArea() method). The results of program execution are shown in Figure 14-1.



Figure 14-1. Output for program in Listing 14.1.



Example 14.1. Example of code reuse through human intelligence.

#include

using namespace std;

class Cylinder

{ protected:

static const double PI;

double radius;

double height;

public:

Cylinder (double r, double h)

code

{ radius = r;

height = h; }



// new class Cylinder

// from class Circle

// from class Circle

// new code



// from Circle plus new



// new code



double getLength () const

file://///Administrator/General%20English%20Learning/it2002-7-6/core.htm (826 of 1187) [8/17/2002 2:58:03 PM]



file://///Administrator/General%20English%20Learning/it2002-7-6/core.htm



{ return 2 * PI * radius; }



// from class Circle



double getRadius() const

{ return radius; }



// from class Circle



void set(double r)

{ radius = r; }



// from class Circle



double getVolume() const

{ return PI * radius * radius * height

} ;



// no getArea()

// new code



const double Cylinder::PI = 3.1415926536;

int main()

{

Cylinder cyl1(2.5,6.0), cyl2(5.0,7.5);

// initialize data

double length = cyl1.getLength();

// similar to Circle

cyl1.set(3.0);

double diam = 2 * cyl1.getRadius();

// no call to getArea()

double vol = cyl2.getVolume();

// not in Circle

cout << " Circumference of first cylinder: " << length << endl;

cout << " Volume of the second cylinder:

" << vol << endl;

cout << " Diameter of the first cylinder: " << diam << endl;

return 0;

}



Most of the existing Circle code (its data and its methods) are copied verbatim. Unnecessary

methods are omitted. New code has to be developed for data and methods missing in Circle but

that are present in class Cylinder. This new code has to be tested. If the existing code is copied

using a text editor rather than typed in, testing the Circle code should be minimal. Since the

interfaces of the Circle functions do not change, existing testing sequences for class Circle could

be reused for class Cylinder as well.

Productivity of this method of code reuse is very high. Everybody, including your boss, is stunned

by the lightning speed of your code development. If they knew that you were relying on your

previous experience, they would be less awed. On the other hand, you were hired to do the job

because you had experience in the development of similar systems. This experience is the most

valuable asset for the development team.

From the software engineering point of view, there is a serious drawback to this approach. Do you

see what it is? These two classes, Circle and Cylinder, are related to each other. They have

common data members and common member functions. This connection between classes Circle

and Cylinder exists only in the mind of the Cylinder designer. The maintainer might easily

overlook this connection. This could result in errors during maintenance.

file://///Administrator/General%20English%20Learning/it2002-7-6/core.htm (827 of 1187) [8/17/2002 2:58:03 PM]



file://///Administrator/General%20English%20Learning/it2002-7-6/core.htm



Reuse Through Buying Services

It is considered good practice to write C++ programs in such a way that objects that send messages

to other objects in the program are related to each other in real life. Sending a message to another

object is sometimes called buying the services of that object.

Notice that I was careful to say that the objects send messages to other objects in the program, not

that objects send messages to each other. Syntactically, it is quite possible that an object of class A

sends a message to an object of class B, and an object of class B sends a message to an object of

class A in the same program. C++ does not make such convoluted cooperation illegal. Moreover, in

some real-time programs, this architecture might even be useful. Mostly, however, this results in

unnecessary complexity of design, that is, in unnecessary complexity of partitioning the job among

cooperating classes and in complexity of links among the classes. This is why in most cases of class

cooperation, it is one class that plays the role of the client class, and it is another class that plays the

role of the server class. When a method of a client class sends a message to an object of the server

class, we say that one class "buys the services" of another class.

There are three contexts in which a client method can access a server object and send a message to

that object:

ϒΠ



Define a server object as a local variable in the client method.



ϒΠ



Define a server object as a data member in the client class.



ϒΠ



Receive a server object as a parameter to the client method.



The first context is the most beneficial from the point of view of class communications: Only one

client function (where the server object is defined) has access to the server object. Given a choice,

you should always choose this type of client-server relationship. Often, this is not possible, because

the server object has to be accessed by other client class methods or by other classes (or by both).

The second context is the next beneficial: The server object is accessible to all member functions of

the client class. Given a choice, you should always prefer to use this type of client-server

relationship rather than using the server object as a parameter to the client class method.

The third context is the most complex from the point of view of communications between

cooperating classes: The argument object is used as a server both by the function to which it is

passed as a parameter and by the functions that call this server function. Given a choice, you should

always avoid this type of client-server relationship, reducing it to either the first context (access by

one client method only) or to the second context (access by methods of one client class only).



file://///Administrator/General%20English%20Learning/it2002-7-6/core.htm (828 of 1187) [8/17/2002 2:58:03 PM]



file://///Administrator/General%20English%20Learning/it2002-7-6/core.htm



From the point of view of code reuse, it is the second type of the client-server relationship that

allows one to create server classes that serve their clients by providing them the services of existing

classes.

For an example of the relationship between classes Circle and Cylinder, this means setting up a

client-server relationship between them so that a Circle object is a member of class Cylinder.

Since we try to design data members as private (or protected), the Circle services are not available

to the Cylinder clients directly. To provide such services to its clients (in this case, the

getLength() method), the Cylinder class should ask its Circle data member to do the job.

Listing 14.2 shows this design (the output of the program is the same as in Listing 14.1). Class

Circle is defined explicitly. Class Cylinder defines a data member of class Circle along with

additional data (in this case, data member height). If this data member were made public, it would

be accessible to the Cylinder client code.

class Cylinder

// new class Cylinder

{ protected:

double height;

// new code

public:

Circle c;

// no PI, no radius

public:

Cylinder (double r, double h)

// from Circle plus new code

: c(r)

// initializer list (no PI)

{ height = h; }

// new code

double getVolume() const

// no getArea()

{ double radius = c.getRadius();

// new code

return Circle::PI * radius * radius * height; }

} ;

// no getLength(), getRadius(), set()



This class Cylinder has very few member functions. It does not have to implement methods

getLength(), getRadius(), and set() on behalf of its client code because the client code can

send these messages to the Cylinder public data member c.

Cylinder cyl1(2.5,6.0), cyl2(5.0,7.5);

double length = cyl1.c.getLength();

cyl1.c.set(3.0);

double diam = 2 * cyl1.c.getRadius();

double vol = cyl2.getVolume();



// initialize data

// use Circle data member

// no call to getArea()

// not in Circle



file://///Administrator/General%20English%20Learning/it2002-7-6/core.htm (829 of 1187) [8/17/2002 2:58:03 PM]



Tài liệu bạn tìm kiếm đã sẵn sàng tải về

Chapter 14. Choosing between Inheritance and Composition

Tải bản đầy đủ ngay(0 tr)

×