Effective C++【五】(Scott Meyers著 侯捷译)

继承关系与面向对象设计(Inheritance and Object-Oriented Design)

条款35:确定你的public inheritance,模塑出“isa”的关系
c++完成面向对象程序设计,最重要的一个规则是:public inheritance,意味着“是一种isa”的关系。
如果令class D以public的形式继承了class B,你便是告诉编译器每一个型别为D的对象同时也是一个型别为B的对象,反之并不成立。如:class person,class student:public person。我们知道每个学生都是人,但是并不是每个人都是学生。每个人都有生日,同样的每个学生也有生日,这便是上述继承体系所主张的意义。即基类比继承类表现出更一般化的概念。

任何一个函数如果期望得到一个person(指针或引用)的引数,都可以接受一个student对象(指针或引用)而无任何异议。但是反过来则不行:

void dance(const Person& p); // 任何人可以跳舞
void study(const Student& s); // 只有学生才学习
Person p; // p是一个人
Student s; // s是一个学生
dance(p); // 正确,p是一个人
dance(s); // 正确,s是一个学生, 一个学生"是一个"人
study(s); // 正确
study(p); // 错误! p不是学生

此论点只有在public inheritace下才成立。

public inheritance和isa之间的对等关系听起来挺美好,但事实的发展却不是那么顺利,比如企鹅是一种鸟,鸟类可以飞,如果我们天真的用c++描述了这样一种继承关系,那就大错特错了。这种关系并不严谨,如果谨慎一点我们可以这样声明,声明基类鸟,继承类有不会飞的鸟和会飞的鸟,然后让企鹅继承不会飞的鸟。这样就能反映出真实情况了。

即便如此我们有时候也不能完美的处理好这些鸟事。因为企鹅只是对一个系统而言的,如果另外一个系统是忙着处理鸟喙或者鸟翅的事情,那么你的系统就完全会令人恼火。这就是一个事实,适用所有软件的完美设计是不存在的。如果你的程序对鸟的飞行一无所知并且将来也不用知道,那么让Penguin成为bird的derived class,不失为一个完美的而有效的设计,虽然你硬能找出一些问题。

如果非要处理你找出的这些问题,你可以试用virtual,fly声明成virtual,如果不能飞行的鸟比如Penguin继承了fly这提出错误信息即可。但这种提示也是在运行过程中给出的。如果把鸟分成会飞的和不会飞的两种鸟,这时候你让Penguin继承不会飞的鸟的时候使用fly函数,编译器在编译时间就能提示你的错误使用。

public inheritance主张,每一件事情只要能够施行于base class objects身上,就一定可以施行于derived class objects身上。这样有时候逻辑上并不正确,但是语法上却没有任何问题。

条款36:区分接口和继承(inerface inheritance)和实现继承(implementation inheritance)
声明一个纯虚函数的目的就是为了让derived classes只继承其接口。但令人意外的是我们为纯虚函数提供定义,也就是说你可以实现这个纯虚函数,并且继承类可以指定完整class名字来调用这个定义。

声明一个一般(非纯)虚拟函数的目的,是为了让derived classes继承该类的接口和缺省行为。就是说每个class都要提供这个接口,但是如果你不想自己写一个,那么你可以调用base class提供的缺省版本。

条款37:绝对不要重新定义继承而来的非虚拟函数
在程序逻辑上可能引发不安全的调用行为。另外逻辑上也会产生悖论。

条款38:绝对不要重新定义继承而来的缺省参数值
简化一下该条的范围。缺省参数只能存在于函数之中,而你只能够继承两种函数:虚拟函数和非虚拟函数。然而重新定义一个继承而来的非虚拟函数是不正确的行为,所以我们可以安全地将讨论局限在“继承一个带有缺省参数值的虚拟函数”。

十分明确的理由:虚拟函数系动态绑定(dynamically bound),而缺省参数值却是静态绑定(statically bound)。
什么意思?你可能会说你不懂这些最新的面向对象术语;或者,过度劳累的你一时想不起静态和动态绑定的区别。那么,让我们来复习一下。

对象的静态类型是指你声明的存在于程序代码文本中的类型。看下面这个类层次结构:

enum ShapeColor { RED, GREEN, BLUE };
// 一个表示几何形状的类
class Shape {
public:
// 所有的形状都要提供一个函数绘制它们本身
virtual void draw(ShapeColor color = RED) const = 0;
...
};
class Rectangle: public Shape {
public:
// 注意:定义了不同的缺省参数值 ---- 不好!
virtual void draw(ShapeColor color = GREEN) const;
...
};
class Circle: public Shape {
public:
virtual void draw(ShapeColor color) const;
...
};

用图形来表示是下面这样:
Shape
/\
/ \
/ \
Rectangle Circle
现在看看这些指针:

Shape *ps; // 静态类型 = Shape*
Shape *pc = new Circle; // 静态类型 = Shape*
Shape *pr = new Rectangle; // 静态类型 = Shape*

这个例子中, ps, pc,和pr都被声明为Shape指针类型,所以它们都以此作为自己的静态类型。注意,这和它们真的所指向的对象的类型绝对没有关系 ---- 它们的静态类型总是Shape*。

对象的动态类型是由它当前所指的对象的类型决定的。即,对象的动态类型表示它将执行何种行为。上面的例子中,pc的动态类型是Circle*,pr的动态类型是Rectangle*。至于ps,实际上没有动态类型,因为它(还)没有指向任何对象。

动态类型,顾名思义,可以在程序运行时改变,典型的方法是通过赋值:
ps = pc; // ps的动态类型现在是Circle*
ps = pr; // ps的动态类型现在是Rectangle*
虚函数是动态绑定的,意思是说,虚函数通过哪个对象被调用,具体被调用的函数就由那个对象的动态类型决定:
pc->draw(RED); // 调用Circle::draw(RED)
pr->draw(RED); // 调用Rectangle::draw(RED)
我知道这些都是老掉牙的知识了,你当然也了解虚函数。(如果想知道它们是怎么实现的,参见条款M24)但是,将虚函数和缺省参数值结合起来分析就会产生问题,因为,如上所述,虚函数是动态绑定的,但缺省参数是静态绑定的。这意味着你最终可能调用的是一个定义在派生类,但使用了基类中的缺省参数值的虚函数:
pr->draw(); // 调用Rectangle::draw(RED)!
这种情况下,pr的动态类型是Rectangle*,所以Rectangle的虚函数被调用 ---- 正如我们所期望的那样。Rectangle::draw中,缺省参数值是GREEN。但是,由于pr的静态类型是Shape*,这个函数调用的参数值是从Shape类中取得的,而不是Rectangle类!所以结果将十分奇怪并且出人意料,因为这个调用包含了Shape和Rectangle类中Draw的声明的组合。你当然不希望自己的软件以这种方式运行啦;至少,用户不希望这样,相信我。

不用说,ps, pc,和pr都是指针的事实和产生问题的原因无关。如果它们是引用,问题也会继续存在。问题仅仅出在,draw是一个虚函数,并且它的一个缺省参数在子类中被重新定义了。

为什么C++坚持这种有违常规的做法呢?答案和运行效率有关。如果缺省参数值被动态绑定,编译器就必须想办法为虚函数在运行时确定合适的缺省值,这将比现在采用的在编译阶段确定缺省值的机制更慢更复杂。做出这种选择是想求得速度上的提高和实现上的简便,所以大家现在才能感受得到程序运行的高效;当然,如果忽视了本条款的建议,就会带来混乱。

条款39:避免在继承体系中做向下转型(cast down)动作
在当今喧嚣的经济时代,关注一下我们的金融机构是个不错的主意。所以,看看下面这个有关银行帐户的协议类(Protocol class )(参见条款34):

class Person { ... };
class BankAccount {
public:
BankAccount(const Person *primaryOwner,const Person *jointOwner);
virtual ~BankAccount();
virtual void makeDeposit(double amount) = 0;
virtual void makeWithdrawal(double amount) = 0;
virtual double balance() const = 0;
...
};

很多银行现在提供了多种令人眼花缭乱的帐户类型,但为简化起见,我们假设只有一种银行帐户,称为存款帐户:

class SavingsAccount: public BankAccount {
public:
SavingsAccount(const Person *primaryOwner,const Person *jointOwner);
~SavingsAccount();
void creditInterest(); // 给帐户增加利息
...
};

这远远称不上是一个真正的存款帐户,但还是那句话,现在什么年代?至少,它满足我们现在的需要。

银行想为它所有的帐户维持一个列表,这可能是通过标准库(参见条款49)中的list类模板实现的。假设列表被叫做allAccounts:
list allAccounts; // 银行中所有帐户
和所有的标准容器一样,list存储的是对象的拷贝,所以,为避免每个BankAccount存储多个拷贝,银行决定让allAccounts保存BankAccount的指针,而不是BankAccount本身。

假设现在准备写一段代码来遍历所有的帐户,为每个帐户计算利息。你会这么写:

// 不能通过编译的循环(如果你以前从没见过使用 "迭代子" 的代码,参见下文)
for (list::iterator p = allAccounts.begin();++p) {
(*p)->creditInterest(); // 错误!
}

但是,编译器很快就会让你认识到:allAccounts包含的指针指向的是BankAccount对象,而非SavingsAccount对象,所以每次循环,p指向的是一个BankAccount。这使得对creditInterest的调用无效,因为creditInterest只是为SavingsAccount对象声明的,而不是BankAccount。

如果"list::iterator p = allAccounts.begin()" 在你看来更象电话线中的噪音,而不是C++,那很显然,你以前无缘见识过C++标准库中的容器类模板。标准库中的这一部分通常被称为标准模板库(STL),你可以在条款49和M35初窥其概貌。但现在你只用知道,变量p工作起来就象一个指针,它将allAccounts中的元素从头到尾循环一遍。也就是说,p工作起来就好象它的类型是BankAccount**而列表中的元素都存储在一个数组中。

上面的循环不能通过编译很令人泄气。的确,allAccounts是被定义为保存BankAccount*,但要知道,上面的循环中它事实上保存的是SavingsAccount*,因为SavingsAccount是仅有的可以被实例话的类。愚蠢的编译器!对我们来说这么显然的事情它竟然笨得一无所知。所以你决定告诉它:allAccounts真的包含的是SavingsAccount*:

// 可以通过编译的循环,但很糟糕
for (list::iterator p = allAccounts.begin();p != allAccounts.end();++p) {
static_cast(*p)->creditInterest();
}

一切问题迎刃而解!解决得很清晰,很漂亮,很简明,所做的仅仅是一个简单的转换而已。你知道allAccounts指针保存的是什么类型的指针,迟钝的编译器不知道,所以你通过一个转换来告诉它,还有比这更合理的事吗?

在此,我要拿圣经的故事做比喻。转换之于C++程序员,就象苹果之于夏娃。

这种类型的转换 ---- 从一个基类指针到一个派生类指针 ---- 被称为 "向下转换",因为它向下转换了继承的层次结构。在刚看到的例子中,向下转换碰巧可以工作;但正如下面即将看到的,它将给今后的维护人员带来恶梦。

还是回到银行的话题上来。受到存款帐户业务大获成功的激励,银行决定再推出支票帐户业务。另外,假设支票帐户和存款帐户一样,也要负担利息:

class CheckingAccount: public BankAccount {
public:
void creditInterest(); // 给帐户增加利息
...
};

不用说,allAccounts现在是一个包含存款和支票两种帐户指针的列表。于是,上面所写的计算利息的循环转瞬间有了大麻烦。

第一个问题是,虽然新增了一个CheckingAccount,但即使不去修改循环代码,编译还是可以继续通过。因为编译器只是简单地听信于你所告诉它们(通过static_cast)的一切:*p指向的是SavingsAccount*。谁叫你是它的主人呢?这会给今后维护带来第一个恶梦。维护期第二个恶梦在于,你一定想去解决这个问题,所以你会写出这样的代码:

for (list::iterator p = allAccounts.begin();p != allAccounts.end();++p) {
if (*p 指向一个 SavingsAccount) static_cast(*p)->creditInterest();
else static_cast(*p)->creditInterest();
}

任何时候发现自己写出 "如果对象属于类型T1,做某事;但如果属于类型T2,做另外某事" 之类的代码,就要扇自己一个耳光。这不是C++的做法。是的,在C,Pascal,甚至Smalltalk中,它是很合理的做法,但在C++中不是。在C++中,要使用虚函数。

记得吗?对于一个虚函数,编译器可以根据所使用对象的类型来保证正确的函数调用。所以不要在代码中随处乱扔条件语句或开关语句;让编译器来为你效劳。如下所示:

class BankAccount { ... }; // 同上
// 一个新类,表示要支付利息的帐户
class InterestBearingAccount: public BankAccount {
public:
virtual void creditInterest() = 0;
...
};
class SavingsAccount: public InterestBearingAccount {
... // 同上
};
class CheckingAccount: public InterestBearingAccount {
... // as above
};

用图形表示如下:
BankAccount
^
|
InterestBearingAccount
/\
/ \
/ \
CheckingAccount SavingsAccount

因为存款和支票账户都要支付利息,所以很自然地想到把这一共同行为转移到一个公共的基类中。但是,如果假设不是所有的银行帐户都需要支付利息(以我的经验,这当然是个合理的假设),就不能把它转移到BankAccount类中。所以,要为BankAccount引入一个新的子类InterestBearingAccount,并使SavingsAccoun和CheckingAccount从它继承。

存款和支票账户都要支付利息的事实是通过InterestBearingAccount的纯虚函数creditInterest来体现的,它要在子类SavingsAccount和CheckingAccount中重新定义。

有了新的类层次结构,就可以这样来重写循环代码:

// 好一些,但还不完美
for (list::iterator p = allAccounts.begin();p != allAccounts.end();++p) {
static_cast(*p)->creditInterest();
}

尽管这个循环还是包含一个讨厌的转换,但代码已经比过去健壮多了,因为即使又增加InterestBearingAccount新的子类到程序中,它还是可以继续工作。

为了完全消除转换,就必须对设计做一些改变。一种方法是限制帐户列表的类型。如果能得到一列InterestBearingAccount对象而不是BankAccount对象,那就太好了:

// 银行中所有要支付利息的帐户
list allIBAccounts;
// 可以通过编译且现在将来都可以工作的循环
for (list::iterator p =allIBAccounts.begin();p != allIBAccounts.end();++p) {
(*p)->creditInterest();
}

如果不想用上面这种 "采用更特定的列表" 的方法,那就让creditInterest操作使用于所有的银行帐户,但对于不用支付利息的帐户来说,它只是一个空操作。这个方法可以这样来表示:

class BankAccount {
public:
virtual void creditInterest() {}
...
};
class SavingsAccount: public BankAccount { ... };
class CheckingAccount: public BankAccount { ... };
list allAccounts;
// 看啊,没有转换!
for (list::iterator p = allAccounts.begin();p != allAccounts.end();++p) {
(*p)->creditInterest();
}

要注意的是,虚函数BankAccount::creditInterest提供一个了空的缺省实现。这可以很方便地表示,它的行为在缺省情况下是一个空操作;但这也会给它本身带来难以预见的问题。想知道内幕,以及如何消除这一危险,请参考条款36。还要注意的是,creditInterest是一个(隐式的)内联函数,这本身没什么问题;但因为它同时又是一个虚函数,内联指令就有可能被忽略。条款33解释了为什么。

正如上面已经看到的,"向下转换" 可以通过几种方法来消除。最好的方法是将这种转换用虚函数调用来代替,同时,它可能对有些类不适用,所以要使这些类的每个虚函数成为一个空操作。第二个方法是加强类型约束,使得指针的声明类型和你所知道的真的指针类型之间没有出入。为了消除向下转换,无论费多大工夫都是值得的,因为向下转换难看、容易导致错误,而且使得代码难于理解、升级和维护(参见条款M32)。

至此,我所说的都是事实;但,不是全部事实。有些情况下,真的不得不执行向下转换。

例如,假设还是面临本条款开始的那种情况,即,allAccounts保存BankAccount指针,creditInterest只是为SavingsAccount对象定义,要写一个循环来为每个帐户计算利息。进一步假设,你不能改动这些类;你不能改变BankAccount,SavingsAccount或allAccounts的定义。(如果它们在某个只读的库中定义,就会出现这种情况)如果是这样的话,你就只有使用向下转换了,无论你认为这个办法有多丑陋。

尽管如此,还是有比上面那种原始转换更好的办法。这种方法称为 "安全的向下转换",它通过C++的dynamic_cast运算符(参见条款M2)来实现。当对一个指针使用dynamic_cast时,先尝试转换,如果成功(即,指针的动态类型(见条款38)和正被转换的类型一致),就返回新类型的合法指针;如果dynamic_cast失败,返回空指针。

下面就是加上了 "安全向下转换" 的例子:

class BankAccount { ... }; // 和本条款开始时一样
class SavingsAccount: // 同上
public BankAccount { ... };
class CheckingAccount: // 同上
public BankAccount { ... };
list allAccounts; // 看起来应该熟悉些了吧...
void error(const string& msg); // 出错处理函数;见下文
// 嗯,至少转换很安全
for (list::iterator p = allAccounts.begin(); p != allAccounts.end();++p) {
// 尝试将*p安全转换为SavingsAccount*;psa的定义信息见下文
if (SavingsAccount *psa =dynamic_cast(*p)) {
psa->creditInterest();
}
// 尝试将它安全转换为CheckingAccount
else if (CheckingAccount *pca =dynamic_cast(*p)) {
pca->creditInterest();
}
// 未知的帐户类型
else { error("Unknown account type!");}
}

这种方法远不够理想,但至少可以检测到转换失败,而用dynamic_cast是无法做到的。但要注意,对所有转换都失败的情况也要检查。这正是上面代码中最后一个else语句的用意所在。采用虚函数,就不必进行这样的检查,因为每个虚函数调用必然都会被解析为某个函数。然而,一旦打算进行转换,这一切好处都化为乌有。例如,如果某个人在类层次结构中增加了一种新类型的帐户,但又忘了更新上面的代码,所有对它的转换就会失败。所以,处理这种可能发生的情况十分重要。大部分情况下,并非所有的转换都会失败;但是,一旦允许转换,再好的程序员也会碰上麻烦。

上面if语句的条件部分,有些看上去象变量定义的东西,看到它你是不是慌张地擦了擦眼镜?如果真这样,别担心,你没看错。这种定义变量的方法是和dynamic_cast同时增加到C++语言中的。这一特性使得写出的代码更简洁,因为对psa或pca来说,它们只有在被dynamic_cast成功初始化的情况下,才会真正被用到;使用新的语法,就不必在(包含转换的)条件语句外定义这些变量。(条款32解释了为什么通常要避免多余的变量定义)如果编译器尚不支持这种定义变量的新方法,可以按老方法来做:

for (list::iterator p = allAccounts.begin();p != allAccounts.end();++p) {
SavingsAccount *psa; // 传统定义
CheckingAccount *pca; // 传统定义
if (psa = dynamic_cast(*p)) {
psa->creditInterest();
}
else if (pca = dynamic_cast(*p)) {
pca->creditInterest();
}
else {error("Unknown account type!");}
}

当然,从处理事情的重要性来说,把psa和pca这样的变量放在哪儿定义并不十分重要。重要之处在于:用if-then-else风格的编程来进行向下转换比用虚函数要逊色得多,应该将这种方法保留到万不得已的情况下使用。运气好的话,你的程序世界里将永远看不到这样悲惨荒凉的景象。

条款40:通过layering技术来模塑has a 或is implemented in terms of 的关系

使某个类的对象成为另一个类的数据成员,从而实现将一个类构筑在另一个类之上,这一过程称为 "分层"(Layering)。例如:

class Address { ... }; // 某人居住之处

class PhoneNumber { ... };

class Person {
public:
...

private:
string name; // 下层对象
Address address; // 同上
PhoneNumber voiceNumber; // 同上
PhoneNumber faxNumber; // 同上
};

本例中,Person类被认为是置于string,Address和PhoneNumber类的上层,因为它包含那些类型的数据成员。"分层" 这一术语有很多同义词,它也常被称为:构成(composition),包含(containment)或嵌入(embedding)。

条款35解释了公有继承的含义是 "是一个"。对应地,分层的含义是 "有一个" 或 "用...来实现"。

上面的Person类展示了 "有一个" 的关系。一个Person对象 "有一个" 名字,地址,电话号码和传真号码。你不能说,一个人 "是一个" 名字或一个人 "是一个" 地址;你得说,一个人 "有一个" 名字, "有一个" 地址,等等。大多数人对区分这些没什么困难,所以混淆 "是一个" 和 "有一个" 的情况相对来说比较少见。

稍微有点麻烦的是区分 "是一个" 和 "用...来实现"。例如,假设需要一个类模板,用来表示任意对象的集合,并且集合中没有重复元素。程序设计中,重用(Reuse)是再好不过的一件事了,而且你也许已经读过条款49中关于C++标准库的总体介绍,那么,你的第一反应一定是想采用标准库中的set模板。是啊,既然可以使用别人所写的东西,为什么还要再去写一个新的模板呢?

但是,深入研究set的帮助文档后,你会发现,set的下述限制将不能满足你的程序要求:set要求包含在它内部的元素必须是完全有序的,即,对set中的任两个元素a和b来说,一定可以确定:要么a将从list继承。因为,在你的实现中,Set对象实际上将是list对象。于是你这样声明Set模板:

// Set中错误地使用了list
template
class Set: public list { ... };

至此,一切好象都很正确,但实际上错误不小。正如条款35所说明的,如果D "是一个" B,对B成立的所有事实对D也成立。但是,list对象可以包含重复元素,所以如果3051这个值被增加到list中两次,list中将包含3051的两个拷贝。相反,Set不可以包含重复元素,所以如果3051被增加到Set中两次,Set中将只包含这个值的一个拷贝。于是,说一个Set "是一个" list就是弥天大谎,因为如上所述,有一些在list对象中成立的事实在Set对象中不成立。

因为这两个类的关系并非 "是一个",所以用公有继承来表示它们的关系就是一个错误。正确的方法是让Set对象 "用list对象来实现":

// Set中使用list的正确方法
template
class Set {
public:
bool member(const T& item) const;

void insert(const T& item);
void remove(const T& item);

int cardinality() const;

private:
list rep; // 表示一个Set
};

Set的成员函数可以利用list以及标准库其它部分所提供的大量功能,所以,实现代码既不难写也很易读:

template
bool Set::member(const T& item) const
{ return find(rep.begin(), rep.end(), item) != rep.end(); }

template
void Set::insert(const T& item)
{ if (!member(item)) rep.push_back(item); }

template
void Set::remove(const T& item)
{
list::iterator it =
find(rep.begin(), rep.end(), item);

if (it != rep.end()) rep.erase(it);
}

template
int Set::cardinality() const
{ return rep.size(); }

这些函数很简单,所以很自然地想到将它们作为内联函数;但在做最后决定前,还是回顾一下条款33所做的讨论。(上面的代码中,find, begin, end, push_back等函数是标准库基本框架的一部分,它们可用来对list这样的容器模板进行操作。标准库框架的总体介绍参见条款49和M35。)

值得指出的是,Set类的接口没有做到完整并且最小(参见条款18)。从完整性上来说,它最大的遗漏在于不能对Set中的内容进行循环,而这一功能对很多程序来说是必需的(标准库中的所有成员都提供了这一功能,包括set)。Set的另一个缺陷是没有遵循标准库所采用的容器类常规(见条款49和M35),从而造成使用Set时更难以利用库中其它的部分。

Set的接口尽管有这些瑕疵,但下面这一点不能被掩盖:Set在理解它和list的关系上,具有无可辩驳的正确性。这种关系并非 "是一个"(虽然初看会以为是),而是 "用...来实现",通过分层来实现这种关系是类的设计者应该感到自豪的。

顺便说一句,当通过分层使两个类产生联系时,实际上在两个类之间建立了编译时的依赖关系。关于为什么要考虑到这一点以及如何减少这方面的麻烦,参见条款34。

条款41:区分inheritance和templates

考虑下面两个设计问题:

· 作为一位立志献身计算机科学的学生,你想设计一个类来表示对象的堆栈。这将需要多个不同的类,因为每个堆栈中的元素必须是同类的,即,它里面包含的必须只是同种类型的对象。例如,会有一个类来表示int的堆栈,第二个类来表示string的堆栈,第三个类来表示string的堆栈的堆栈,等等。你也许对设计一个最小的类接口(参见条款18)很感兴趣,所以会将对堆栈的操作限制在:创建堆栈,销毁堆栈,将对象压入堆栈,将对象弹出堆栈,以及检查堆栈是否为空。设计中,你不会借助标准库中的类(包括stack ---- 参见条款49),因为你渴望亲手写这些代码。重用(Reuse)是一件美事,但当你的目标是探究事情的工作原理时,那就只有挖地三尺了。

· 作为一位爱猫的宠物迷,你想设计一个类来表示猫。这也将需要多个不同的类,因为每个品种的猫都会有点不同。和所有对象一样,猫可以被创建和销毁,但,正如所有猫迷所知道的,猫所做的其它事不外乎吃和睡。然而,每一种猫吃和睡都有各自惹人喜爱的方式。

这两个问题的说明听起来很相似,但却导致完全不同的两种设计。为什么?

答案涉及到"类的行为" 和 "类所操作的对象的类型"之间的关系。对于堆栈和猫来说,要处理的都是各种不同的类型(堆栈包含类型为T的对象,猫则为品种T),但你必须问自己这样一个问题:类型T影响类的行为吗?如果T不影响行为,你可以使用模板。如果T影响行为,你就需要虚函数,从而要使用继承。

下面的代码通过定义一个链表来实现Stack类,假设堆栈的对象类型为T:

class Stack {
public:
Stack();
~Stack();

void push(const T& object);
T pop();

bool empty() const; // 堆栈为空?

private:
struct StackNode { // 链表节点
T data; // 此节点数据
StackNode *next; // 链表中下一节点

// StackNode构造函数,初始化两个域
StackNode(const T& newData, StackNode *nextNode)
: data(newData), next(nextNode) {}
};

StackNode *top; // 堆栈顶部

Stack(const Stack& rhs); // 防止拷贝和
Stack& operator=(const Stack& rhs); // 赋值(见条款27)
};

于是,Stack对象将构造如下所示的数据结构:

Stack对象 top--> data+next--> data+next--> data+next--> data+next
------------------------------------------------------------------------------------
StackNode对象

链表本身是由StackNode对象构成的,但那只是Stack类的一个实现细节,所以StackNode被声明为Stack的私有类型。注意StackNode有一个构造函数,用来确保它所有的域都被正确初始化。即使你闭着眼睛都可以写出一个链表,但也不要忽视了C++的一些新特性,如struct中的构造函数。

下面看看你对Stack成员函数的实现。和许多原型(prototype)的实现(离制作成软件产品相差太远)一样,这里没有错误检查,因为在原型世界里,没有东西会出错。

Stack::Stack(): top(0) {} // 顶部初始化为null

void Stack::push(const T& object)
{
top = new StackNode(object, top); // 新节点放在
} // 链表头部

T Stack::pop()
{
StackNode *topOfStack = top; // 记住头节点
top = top->next;

T data = topOfStack->data; // 记住节点数据
delete topOfStack;

return data;
}

Stack::~Stack() // 删除堆栈中所有对象
{
while (top) {
StackNode *toDie = top; // 得到头节点指针
top = top->next; // 移向下一节点
delete toDie; // 删除前面的头节点
}
}

bool Stack::empty() const
{ return top == 0; }

这些代码毫无吸引人之处。实际上,唯一有趣的一点在于:即使对T一无所知,你还是能够写出每个成员函数。(上面的代码中实际上有个假设,即,假设可以调用T的拷贝构造函数;但正如条款45所说明的,这是一个绝对合理的假设)不管T是什么,对构造,销毁,压栈,出栈,确定栈是否为空等操作所写的代码不会变。除了 "可以调用T的拷贝构造函数" 这一假设外,stack的行为在任何地方都不依赖于T。这就是模板类的特点:行为不依赖于类型。

将stack类转化成一个模板就很简单了,即使是Dilbert的老板都会写:

template class Stack {

... // 完全和上面相同

};

但是,猫呢?为什么猫不适合模板?

重读上面的说明,注意这一条:"每一种猫吃和睡都有各自惹人喜爱的方式"。这意味着必须为每种不同的猫实现不同的行为。不可能写一个函数来处理所有的猫,所能做的只能是制定一个函数接口,所有种类的猫都必须实现它。啊哈!衍生一个函数接口的方法只能是去声明一个纯虚函数(参见条款36):

class Cat {
public:
virtual ~Cat(); // 参见条款14

virtual void eat() = 0; // 所有的猫吃食
virtual void sleep() = 0; // 所有的猫睡觉
};

Cat的子类 ---- 比如,Siamese和BritishShortHairedTabby ---- 当然得重新定义继承而来的eat和sleep函数接口:

class Siamese: public Cat {
public:
void eat();
void sleep();

...

};

class BritishShortHairedTabby: public Cat {
public:
void eat();
void sleep();

...

};

好了,现在知道了为什么模板适合Stack类而不适合Cat类,也知道了为什么继承适合Cat类。唯一剩下的问题是,为什么继承不适合Stack类。想知道为什么,不妨试着去声明一个Stack层次结构的根类 ---- 所有其它的堆栈类都从这个唯一的类继承:

class Stack { // a stack of anything
public:
virtual void push(const ??? object) = 0;
virtual ??? pop() = 0;

...

};

现在问题很明显了。该为纯虚函数push和pop声明什么类型呢?记住,每一个子类必须重新声明继承而来的虚函数,而且参数类型和返回类型都要和基类的声明完全相同。不幸的是,一个int堆栈只能压入和弹出int对象,而一个Cat堆栈只能压入和弹出Cat对象。Stack类要怎样声明它的纯虚函数才能使用户既可以创建出int堆栈又可以创建出Cat堆栈呢?冷酷而严峻的事实是,做不到。这就是为什么说继承不适合创建堆栈。

但也许你做事喜欢偷偷摸摸。或许你认为自己可以通过使用通用(void*)指针来骗过编译器。但事实证明,现在这种情况下,通用指针也帮不上忙。因为你无法避开这一条件:派生类虚函数的声明永远不能和它在基类中的声明相抵触。但是,通用指针可以帮助解决另外一个不同的问题,它和模板所生成的类的效率有关。详细介绍参见条款42。

讲完了堆栈和猫,下面将本条款得到的结论总结如下:

· 当对象的类型不影响类中函数的行为时,就要使用模板来生成这样一组类。
· 当对象的类型影响类中函数的行为时,就要使用继承来得到这样一组类。

真正消化了以上两点的含义,你就可以在设计中游刃于继承或模板之间。

条款42:明智地运用private inheritance(私有继承)

条款35说明,C++将公有继承视为 "是一个" 的关系。它是通过这个例子来证实的:假如某个类层次结构中,Student类从Person类公有继承,为了使某个函数成功调用,编译器可以在必要时隐式地将Student转换为Person。这个例子很值得再看一遍,只是现在,公有继承换成了私有继承:

class Person { ... };

class Student: // 这一次我们
private Person { ... }; // 使用私有继承

void dance(const Person& p); // 每个人会跳舞

void study(const Student& s); // 只有学生才学习

Person p; // p是一个人
Student s; // s是一个学生

dance(p); // 正确, p是一个人

dance(s); // 错误!一个学生不是一个人

很显然,私有继承的含义不是 "是一个",那它的含义是什么呢?

"别忙!" 你说。"在弄清含义之前,让我们先看看行为。私有继承有那些行为特征呢?" 那好吧。关于私有继承的第一个规则正如你现在所看到的:和公有继承相反,如果两个类之间的继承关系为私有,编译器一般不会将派生类对象(如Student)转换成基类对象(如Person)。这就是上面的代码中为对象s调用dance会失败的原因。第二个规则是,从私有基类继承而来的成员都成为了派生类的私有成员,即使它们在基类中是保护或公有成员。行为特征就这些。

这为我们引出了私有继承的含义:私有继承意味着 "用...来实现"。如果使类D私有继承于类B,这样做是因为你想利用类B中已经存在的某些代码,而不是因为类型B的对象和类型D的对象之间有什么概念上的关系。因而,私有继承纯粹是一种实现技术。用条款36引入的术语来说,私有继承意味着只是继承实现,接口会被忽略。如果D私有继承于B,就是说D对象在实现中用到了B对象,仅此而已。私有继承在软件 "设计" 过程中毫无意义,只是在软件 "实现" 时才有用。

私有继承意味着 "用...来实现" 这一事实会给程序员带来一点混淆,因为条款40指出,"分层" 也具有相同的含义。怎么在二者之间进行选择呢?答案很简单:尽可能地使用分层,必须时才使用私有继承。什么时候必须呢?这往往是指有保护成员和/或虚函数介入的时候 ---- 但这个问题过一会儿再深入讨论。

条款41提供了一种方法来写一个Stack 模板,此模板生成的类保存不同类型的对象。你应该熟悉一下那个条款。模板是C++最有用的组成部分之一,但一旦开始经常性地使用它,你会发现,如果实例化一个模板一百次,你就可能实例化了那个模板的代码一百次。例如Stack模板,构成Stack成员函数的代码和构成Stack成员函数的代码是完全分开的。有时这是不可避免的,但即使模板函数实际上可以共享代码,这种代码重复还是可能存在。这种目标代码体积的增加有一个名字:模板导致的 "代码膨胀"。这不是件好事。

对于某些类,可以采用通用指针来避免它。采用这种方法的类存储的是指针,而不是对象,实现起来就是:

· 创建一个类,它存储的是对象的void*指针。
· 创建另外一组类,其唯一目的是用来保证类型安全。这些类都借助第一步中的通用类来完成实际工作。

下面的例子使用了条款41中的非模板Stack类,不同的是这里存储的是通用指针,而不是对象:

class GenericStack {
public:
GenericStack();
~GenericStack();

void push(void *object);
void * pop();

bool empty() const;

private:
struct StackNode {
void *data; // 节点数据
StackNode *next; // 下一节点

StackNode(void *newData, StackNode *nextNode)
: data(newData), next(nextNode) {}
};

StackNode *top; // 栈顶

GenericStack(const GenericStack& rhs); // 防止拷贝和
GenericStack& // 赋值(参见
operator=(const GenericStack& rhs); // 条款27)
};

因为这个类存储的是指针而不是对象,就有可能出现一个对象被多个堆栈指向的情况(即,被压入到多个堆栈)。所以极其重要的一点是,pop和类的析构函数销毁任何StackNode对象时,都不能删除data指针 ---- 虽然还是得要删除StackNode对象本身。毕竟,StackNode 对象是在GenericStack类内部分配的,所以还是得在类的内部释放。所以,条款41中Stack类的实现几乎完全满足the GenericStack的要求。仅有的改变只是用void*来替换T。

仅仅有GenericStack这一个类是没有什么用处的,但很多人会很容易误用它。例如,对于一个用来保存int的堆栈,一个用户会错误地将一个指向Cat对象的指针压入到这个堆栈中,但编译却会通过,因为对void*参数来说,指针就是指针。

为了重新获得你所习惯的类型安全,就要为GenericStack创建接口类(interface class),象这样:

class IntStack { // int接口类
public:
void push(int *intPtr) { s.push(intPtr); }
int * pop() { return static_cast(s.pop()); }
bool empty() const { return s.empty(); }

private:
GenericStack s; // 实现
};

class CatStack { // cat接口类
public:
void push(Cat *catPtr) { s.push(catPtr); }
Cat * pop() { return static_cast(s.pop()); }
bool empty() const { return s.empty(); }

private:
GenericStack s; // 实现
};

正如所看到的,IntStack和CatStack只是适用于特定类型。只有int指针可以被压入或弹出IntStack,只有Cat指针可以被压入或弹出CatStack。IntStack和CatStack都通过GenericStack类来实现,这种关系是通过分层(参见条款40)来体现的,IntStack和CatStack将共享GenericStack中真正实现它们行为的函数代码。另外,IntStack和CatStack所有成员函数是(隐式)内联函数,这意味着使用这些接口类所带来的开销几乎是零。

但如果有些用户没认识到这一点怎么办?如果他们错误地认为使用GenericStack更高效,或者,如果他们鲁莽而轻率地认为类型安全不重要,那该怎么办?怎么才能阻止他们绕过IntStack和CatStack而直接使用GenericStack(这会让他们很容易地犯类型错误,而这正是设计C++所要特别避免的)呢?

没办法!没办法防止。但,也许应该有什么办法。

在本条款的开始我就提到,要表示类之间 "用...来实现" 的关系,有一个选择是通过私有继承。现在这种情况下,这一技术就比分层更有优势,因为通过它可以让你告诉别人:GenericStack使用起来不安全,它只能用来实现其它的类。具体做法是将GenericStack的成员函数声明为保护类型:

class GenericStack {
protected:
GenericStack();
~GenericStack();

void push(void *object);
void * pop();

bool empty() const;

private:
... // 同上
};

GenericStack s; // 错误! 构造函数被保护

class IntStack: private GenericStack {
public:
void push(int *intPtr) { GenericStack::push(intPtr); }
int * pop() { return static_cast(GenericStack::pop()); }
bool empty() const { return GenericStack::empty(); }
};

class CatStack: private GenericStack {
public:
void push(Cat *catPtr) { GenericStack::push(catPtr); }
Cat * pop() { return static_cast(GenericStack::pop()); }
bool empty() const { return GenericStack::empty(); }
};

IntStack is; // 正确

CatStack cs; // 也正确

和分层的方法一样,基于私有继承的实现避免了代码重复,因为这个类型安全的接口类只包含有对GenericStack函数的内联调用。

在GenericStack类之上构筑类型安全的接口是个很花俏的技巧,但需要手工去写所有那些接口类是件很烦的事。幸运的是,你不必这样。你可以让模板来自动生成它们。下面是一个模板,它通过私有继承来生成类型安全的堆栈接口:

template
class Stack: private GenericStack {
public:
void push(T *objectPtr) { GenericStack::push(objectPtr); }
T * pop() { return static_cast(GenericStack::pop()); }
bool empty() const { return GenericStack::empty(); }
};

这是一段令人惊叹的代码,虽然你可能一时还没意识到。因为这是一个模板,编译器将根据你的需要自动生成所有的接口类。因为这些类是类型安全的,用户类型错误在编译期间就能发现。因为GenericStack的成员函数是保护类型,并且接口类把GenericStack作为私有基类来使用,用户将不可能绕过接口类。因为每个接口类成员函数被(隐式)声明为inline,使用这些类型安全的类时不会带来运行开销;生成的代码就象用户直接使用GenericStack来编写的一样(假设编译器满足了inline请求 ---- 参见条款33)。因为GenericStack使用了void*指针,操作堆栈的代码就只需要一份,而不管程序中使用了多少不同类型的堆栈。简而言之,这个设计使代码达到了最高的效率和最高的类型安全。很难做得比这更好。

本书的基本认识之一是,C++的各种特性是以非凡的方式相互作用的。这个例子,我希望你能同意,确实是非凡的。

从这个例子中可以发现,如果使用分层,就达不到这样的效果。只有继承才能访问保护成员,只有继承才使得虚函数可以重新被定义。(虚函数的存在会引发私有继承的使用,例子参见条款43)因为存在虚函数和保护成员,有时私有继承是表达类之间 "用...来实现" 关系的唯一有效途径。所以,当私有继承是你可以使用的最合适的实现方法时,就要大胆地使用它。同时,广泛意义上来说,分层是应该优先采用的技术,所以只要有可能,就要尽量使用它。

条款43:明智地运用多继承(multiple inheritance,MI)

要看是谁来说,多继承(MI)要么被认为是神来之笔,要么被当成是魔鬼的造物。支持者宣扬说,它是对真实世界问题进行自然模型化所必需的;而批评者争论说,它太慢,难以实现,功能却不比单继承强大。更让人为难的是,面向对象编程语言领域在这个问题上至今仍存在分歧:C++,Eiffel和the Common LISP Object System (CLOS)提供了MI;Smalltalk,Objective C和Object Pascal没有提供;而Java只是提供有限的支持。可怜的程序员该相信谁呢?

在相信任何事情之前,首先得弄清事实。C++中,关于MI一条不容争辩的事实是,MI的出现就象打开了潘朵拉的盒子,带来了单继承中绝对不会存在的复杂性。其中,最基本的一条是二义性(参见条款26)。如果一个派生类从多个基类继承了一个成员名,所有对这个名字的访问都是二义的;你必须明确地说出你所指的是哪个成员。下面的例子取自ARM(参见条款50)中的一个专题讨论:

class Lottery {
public:
virtual int draw();

...

};

class GraphicalObject {
public:
virtual int draw();

...

};

class LotterySimulation: public Lottery,
public GraphicalObject {

... // 没有声明draw

};

LotterySimulation *pls = new LotterySimulation;

pls->draw(); // 错误! ---- 二义
pls->Lottery::draw(); // 正确
pls->GraphicalObject::draw(); // 正确

这段代码看起来很笨拙,但起码可以工作。遗憾的是,想避免这种笨拙很难。即使其中一个被继承的draw函数是私有成员从而不能被访问,二义还是存在。(对此有一个很好的理由来解释,但完整的说明在条款26中提供,所以此处不再重复。)

显式地限制修饰成员不仅很笨拙,而且还带来限制。当显式地用一个类名来限制修饰一个虚函数时,函数的行为将不再具有虚拟的特征。相反,被调用的函数只能是你所指定的那个,即使调用是作用在派生类的对象上:

class SpecialLotterySimulation: public LotterySimulation {
public:
virtual int draw();

...

};

pls = new SpecialLotterySimulation;

pls->draw(); // 错误! ---- 还是有二义
pls->Lottery::draw(); // 调用Lottery::draw
pls->GraphicalObject::draw(); // 调用GraphicalObject::draw

注意,在这种情况下,即使pls指向的是SpecialLotterySimulation对象,也无法(没有 "向下转换" ---- 参见条款39)调用这个类中定义的draw函数。

没完,还有呢。Lottery和GraphicalObject中的draw函数都被声明为虚函数,所以子类可以重新定义它们(见条款36),但如果LotterySimulation想对二者都重新定义那该怎么办?令人沮丧的是,这不可能,因为一个类只允许有唯一一个没有参数、名称为draw的函数。(这个规则有个例外,即一个函数为const而另一个不是的时候 ---- 见条款21)

从某一方面来说,这个问题很严重,严重到足以成为修改C++语言的理由。ARM中就讨论了一种可能,即,允许被继承的虚函数可以 "改名" ;但后来又发现,可以通过增加一对新类来巧妙地避开这个问题:

class AuxLottery: public Lottery {
public:
virtual int lotteryDraw() = 0;

virtual int draw() { return lotteryDraw(); }
};

class AuxGraphicalObject: public GraphicalObject {
public:
virtual int graphicalObjectDraw() = 0;

virtual int draw() { return graphicalObjectDraw(); }
};

class LotterySimulation: public AuxLottery,
public AuxGraphicalObject {
public:
virtual int lotteryDraw();
virtual int graphicalObjectDraw();

...

};

这两个新类, AuxLottery和AuxGraphicalObject,本质上为各自继承的draw函数声明了新的名字。新名字以纯虚函数的形式提供,本例中即lotteryDraw和graphicalObjectDraw;函数是纯虚拟的,所以具体的子类必须重新定义它们。另外,每个类都重新定义了继承而来的draw函数,让它们调用新的纯虚函数。最终效果是,在这个类体系结构中,有二义的单个名字draw被有效地分成了无二义但功能等价的两个名字:lotteryDraw和graphicalObjectDraw:

LotterySimulation *pls = new LotterySimulation;

Lottery *pl = pls;
GraphicalObject *pgo = pls;

// 调用LotterySimulation::lotteryDraw
pl->draw();

// 调用LotterySimulation::graphicalObjectDraw
pgo->draw();

这是一个集纯虚函数,简单虚函数和内联函数(参见条款33)综合应用之大成的方法,值得牢记在心。首先,它解决了问题,这个问题说不定哪天你就会碰到。其次,它可以提醒你,使用多继承会导致复杂性。是的,这个方法解决了问题,但仅仅为了重新定义一个虚函数而不得不去引入新的类,你真的愿意这样做吗?AuxLottery和AuxGraphicalObject类对于保证类层次结构的正确运转是必需的,但它们既不对应于问题范畴(problem domain )的某个抽象,也不对应于实现范畴(implementation domain)的某个抽象。它们单纯是作为一种实现设备而存在,再没有别的用处。你一定知道,好的软件是 "设备无关" 的,这条法则在此也适用。

将来使用MI还会面临更多的问题,二义性问题(尽管有趣)只不过是刚开始。另一个问题基于这样一个实践经验:一个起初象下面这样的继承层次结构:

class B { ... };
class C { ... };
class D: public B, public C { ... };

B C
\ /
\ /
\/
D

往往最后悲惨地发展成象下面这样:

class A { ... };
class B : virtual public A { ... };
class C : virtual public A { ... };
class D: public B, public C { ... };

A
/\
/ \
/ \
B C
\ /
\ /
\/
D

钻石可能是女孩最好的朋友,也许不是;但肯定的是,象这样一种钻石形状的继承结构绝对不可能成为我们的朋友。如果创建了象这样的层次结构,就会立即面临这样一个问题:是不是该让A成为虚基类呢?即,从A的继承是否应该是虚拟的呢?现实中,答案几乎总是 ---- 应该;只有极少数情况下会想让类型D的对象包含A的数据成员的多个拷贝。正是认识到这一事实,上面的B和C将A声明为虚基类。

遗憾的是,在定义B和C的时候,你可能不知道将来是否会有类去同时继承它们,而且知不知道这一点实际上对正确地定义这两个类没有必要。对类的设计者来说,这实在是进退两难。如果不将A声明为B和C的虚基类,今后D的设计者就有可能需要修改B和C的定义,以便更有效地使用它们。通常,这很难做到,因为A,B和C的定义往往是只读的。例如这样的情况:A,B和C在一个库中,而D由库的用户来写。

另一方面,如果真的将A声明为B和C的虚基类,往往会在空间和时间上强加给用户额外的开销。因为虚基类常常是通过对象指针来实现的,并非对象本身。自不必说,内存中对象的分布是和编译器相关的,但一条不变的事实是:如果A作为 "非虚" 基类,类型D的对象在内存中的分布通常占用连续的内存单元;如果A作为 "虚" 基类,有时,类型D的对象在内存中的分布占用连续的内存单元,但其中两个单元包含的是指针,指向包含虚基类数据成员的内存单元:

A是非虚基类时D对象通常的内存分布:

A部分+ B部分+ A部分 + C部分 + D部分

A是虚基类时D对象在某些编译器下的内存分布:

------------------------------------------------
| |
| +
B部分 + 指针 + C部分 + 指针 + D部分 + A部分
| +
| |
------------------------

即使编译器不采用这种特殊的实现策略,使用虚继承通常也会带来某种空间上的惩罚。

考虑到这些因素,看来,在进行高效的类设计时如果涉及到MI,作为库的设计者就要具有超凡的远见。然而现在的年代,常识都日益成为了稀有品,因而你会不明智地过多依赖于语言特性,这就不仅要求设计者能够预计得到未来的需要,而且简直就是要你做到彻底的先知先觉(参见条款M32)。

当然,这也可以说成是在虚函数和非虚函数间选择,但还是有重大的不同。条款36说明,虚函数具有定义明确的高级含义,非虚函数也同样具有定义明确的高级含义,而且它们的含义有显著的不同,所以在清楚自己想对子类的设计者传达什么含义的基础上,在二者之间作出选择是可能的。但是,决定基类是否应该是虚拟的,则缺乏定义明确的高级含义;相反,决定通常取决于整个继承的层次结构,所以除非知道了整个层次结构,否则无法做出决定。如果正确地定义出个类之前需要清楚地知道将来怎么使用它,这种情况下将很难设计出高效的类。

就算避开了二义性问题,并且解决了是否应该从基类虚拟继承的疑问,还是会有许多复杂性问题等着你。为了长话短说,在此我仅提出应该记住的其它两点:

· 向虚基类传递构造函数参数。非虚继承时,基类构造函数的参数是由紧临的派生类的成员初始化列表指定的。因为单继承的层次结构只需要非虚基类,继承层次结构中参数的向上传递采用的是一种很自然的方式:第n层的类将参数传给第n-1层的类。但是,虚基类的构造函数则不同,它的参数是由继承结构中最底层派生类的成员初始化列表指定的。这就造成,负责初始化虚基类的那个类可能在继承图中和它相距很远;如果有新类增加到继承结构中,执行初始化的类还可能改变。(避免这个问题的一个好办法是:消除对虚基类传递构造函数参数的需要。最简单的做法是避免在这样的类中放入数据成员。这本质上是Java的解决之道:Java中的虚基类(即,"接口")禁止包含数据)

· 虚函数的优先度。就在你自认为弄清了所有的二义之时,它们却又在你面前摇身一变。再次看看关于类A,B,C和D的钻石形状的继承图。假设A定义了一个虚成员函数mf,C重定义了它;B和D则没有重定义mf:

A virtual void mf();
/\
/ \
/ \
B C virtual void mf();
\ /
\ /
\/
D

根据以前的讨论,你会认为下面有二义:

D *pd = new D;
pd->mf(); // A::mf或者C::mf?

该为D的对象调用哪个mf呢,是直接从C继承的还是间接(通过B)从A继承的那个呢?答案取决于B和C如何从A继承。具体来说,如果A是B或C的非虚基类,调用具有二义性;但如果A是B和C的虚基类,就可以说C中mf的重定义优先度高于最初A中的定义,因而通过pd对mf的调用将(无二义地)解析为C::mf。如果你坐下来仔细想想,这正是你想要的行为;但需要坐下仔细想想才能弄懂,也确实是一种痛苦。

也许至此你会承认MI确实会导致复杂化。也许你认识到每个人其实都不想使用它。也许你准备建议国际C++标准委员会将多继承从语言中去掉;或者至少你想向你的老板建议,全公司的程序员都禁止使用它。

也许你太性急了。

请记住,C++的设计者并没有想让多继承难以使用;恰恰是,想让一切都能以更合理的方式协调工作,这本身会带来某些复杂性。上面的讨论中你会注意到,这些复杂性很多是由于使用虚基类引起的。如果能避免使用虚基类 ---- 即,如果能避免产生那种致命的钻石形状继承图 ---- 事情就好处理多了。

例如,条款34中讲到,协议类(Protocol class)的存在仅仅是为派生类制定接口;它没有数据成员,没有构造函数,有一个虚析构函数(参见条款14),有一组用来指定接口的纯虚函数。一个Person协议类看起来象下面这样:

class Person {
public:
virtual ~Person();

virtual string name() const = 0;
virtual string birthDate() const = 0;
virtual string address() const = 0;
virtual string nationality() const = 0;
};

这个类的用户在编程时必须使用Person的指针或引用,因为抽象类不能被实例化。

为了创建 "可以作为Person对象而使用" 的对象,Person的用户使用工厂函数(factory function,参见条款34)来实例化具体的子类:

// 工厂函数,从一个唯一的数据库ID
// 创建一个Person对象
Person * makePerson(DatabaseID personIdentifier);

DatabaseID askUserForDatabaseID();

DatabaseID pid = askUserForDatabaseID();

Person *pp = makePerson(pid); // 创建支持Person
// 接口的对象

... // 通过Person的成员函数
// 操作*pp

delete pp; // 删除不再需要的对象

这就带来一个问题:makePerson返回的指针所指向的对象如何创建呢?显然,必须从Person派生出某种具体类,使得makePerson可以对其进行实例化。

假设这个类被称为MyPerson。作为一个具体类,MyPerson必须实现从Person继承而来的纯虚函数。这可以从零做起,但如果已经存在一些组件可以完成大多数或全部所需的工作,那么从软件工程的角度来说,能利用这些组件将再好不过。例如,假设已经有一个和数据库有关的旧类PersonInfo,它提供的功能正是MyPerson所需要的:

class PersonInfo {
public:
PersonInfo(DatabaseID pid);
virtual ~PersonInfo();

virtual const char * theName() const;
virtual const char * theBirthDate() const;
virtual const char * theAddress() const;
virtual const char * theNationality() const;

virtual const char * valueDelimOpen() const; // 看下文
virtual const char * valueDelimClose() const;

...

};

可以断定这是一个很旧的类,因为它的成员函数返回的是const char*而不是string对象。但是,如果鞋合脚,为什么不穿呢?这个类的成员函数名暗示,这双鞋穿上去会很舒服。

随之你会发现,当初设计PersonInfo是用来方便地以各种不同格式打印数据库字段,每个字段值的开头和结尾用特殊字符串分开。默认情况下,字段值的起始分隔符和结束分隔符为括号,所以字段值 "Ring-tailed Lemur" 将会这样被格式化:

[Ring-tailed Lemur]

因为括号不是所有PersonInfo的用户都想要的,虚函数valueDelimOpen和valueDelimClose允许派生类指定它们自己的起始分隔符和结束分隔符。PersonInfo类的theName,theBirthDate,theAddress以及theNationality的实现将调用这两个虚函数,在它们的返回值中添加适当的分隔符。拿PersonInfo::name作为例子,代码看起来象这样:

const char * PersonInfo::valueDelimOpen() const
{
return "["; // 默认起始分隔符
}

const char * PersonInfo::valueDelimClose() const
{
return "]"; // 默认结束分隔符
}

const char * PersonInfo::theName() const
{
// 为返回值保留缓冲区。因为是静态
// 类型,它被自动初始化为全零。
static char value[MAX_FORMATTED_FIELD_VALUE_LENGTH];

// 写起始分隔符
strcpy(value, valueDelimOpen());

将对象的名字字段值添加到字符串中

// 写结束分隔符
strcat(value, valueDelimClose());

return value;
}

有些人会挑剔PersonInfo::theName的设计(特别是使用了固定大小的静态缓冲区 ---- 参见条款23),但请将你的挑剔放在一边,关注这一点:首先,theName调用valueDelimOpen,生成它将要返回的字符串的起始分隔符;然后,生成名字值本身;最后,调用valueDelimClose。因为valueDelimOpen和valueDelimClose是虚函数,theName返回的结果既依赖于PersonInfo,也依赖于从PersonInfo派生的类。

作为MyPerson的实现者,这是条好消息,因为在研读Person文档的细则时你发现,name及其相关函数需要返回的是不带修饰的值,即,不允许带分隔符。也就是说,如果一个人来自Madagascar,调用这个人的nationality函数将返回"Madagascar",而不是 "[Madagascar]"。

MyPerson和PersonInfo之间的关系是,PersonInfo刚好有些函数使得MyPerson易于实现。仅次而已。没看到有 "是一个" 或 "有一个" 的关系。它们的关系是 "用...来实现",而且我们知道,这可以用两种方式来表示:通过分层(见条款40)和通过私有继承(见条款42)。条款42指出,分层一般来说是更好的方法,但在有虚函数要被重新定义的情况下,需要使用私有继承。现在的情况是,MyPerson需要重新定义valueDelimOpen和valueDelimClose,所以不能用分层,而必须用私有继承:MyPerson必须从PersonInfo私有继承。

但MyPerson还必须实现Person接口,因而需要公有继承。这导致了多继承一个很合理的应用:将接口的公有继承和实现的私有继承结合起来:

class Person { // 这个类指定了
public: // 需要被实现
virtual ~Person(); // 的接口

virtual string name() const = 0;
virtual string birthDate() const = 0;
virtual string address() const = 0;
virtual string nationality() const = 0;
};

class DatabaseID { ... }; // 被后面的代码使用;
// 细节不重要

class PersonInfo { // 这个类有些有用
public: // 的函数,可以用来
PersonInfo(DatabaseID pid); // 实现Person接口
virtual ~PersonInfo();

virtual const char * theName() const;
virtual const char * theBirthDate() const;
virtual const char * theAddress() const;
virtual const char * theNationality() const;

virtual const char * valueDelimOpen() const;
virtual const char * valueDelimClose() const;

...

};

class MyPerson: public Person, // 注意,使用了
private PersonInfo { // 多继承
public:
MyPerson(DatabaseID pid): PersonInfo(pid) {}

// 继承来的虚分隔符函数的重新定义
const char * valueDelimOpen() const { return ""; }
const char * valueDelimClose() const { return ""; }

// 所需的Person成员函数的实现
string name() const
{ return PersonInfo::theName(); }

string birthDate() const
{ return PersonInfo::theBirthDate(); }

string address() const
{ return PersonInfo::theAddress(); }

string nationality() const
{ return PersonInfo::theNationality(); }
};

用图形表示,看起来象下面这样:

Person PersonInfo
\ /
\ /
\/
MyPerson

这种例子证明,MI会既有用又易于理解,尽管可怕的钻石形状继承图不会明显消失。

然而,必须当心诱惑。有时你会掉进这样的陷阱中:对某个需要改动的继承层次结构来说,本来用一个更基本的重新设计可以更好,但你却为了追求速度而去使用MI。例如,假设为可以活动的卡通角色设计一个类层次结构。至少从概念上来说,让各种角色能跳舞唱歌将很有意义,但每一种角色执行这些动作时方式都不一样。另外,跳舞唱歌的缺省行为是什么也不做。

所有这些用C++来表示就象这样:

class CartoonCharacter {
public:
virtual void dance() {}
virtual void sing() {}
};

虚函数自然地体现了这样的约束:唱歌跳舞对所有CartoonCharacter对象都有意义。什么也不做的缺省行为通过类中那些函数的空定义来表示(参见条款36)。假设有一个特殊类型的卡通角色是蚱蜢,它以自己特殊的方式跳舞唱歌:

class Grasshopper: public CartoonCharacter {
public:
virtual void dance(); // 定义在别的什么地方
virtual void sing(); // 定义在别的什么地方
};

现在假设,在实现了Grasshopper类后,你又想为蟋蟀增加一个类:

class Cricket: public CartoonCharacter {
public:
virtual void dance();
virtual void sing();
};

当坐下来实现Cricket类时,你意识到,为Grasshopper类所写的很多代码可以重复使用。但这需要费点神,因为要到各处去找出蚱蜢和蟋蟀唱歌跳舞的不同之处。你猛然间想出了一个代码复用的好办法:你准备用Grasshopper类来实现Cricket类,你还准备使用虚函数以使Cricket类可以定制Grasshopper的行为。

你立即认识到这两个要求 ---- "用...来实现" 的关系,以及重新定义虚函数的能力 ---- 意味着Cricket必须从Grasshopper私有继承,但蟋蟀当然还是一个卡通角色,所以你通过同时从Grasshopper和CartoonCharacter继承来重新定义Cricket:

class Cricket: public CartoonCharacter,
private Grasshopper {
public:
virtual void dance();
virtual void sing();
};

然后准备对Grasshopper类做必要的修改。特别是,需要声明一些新的虚函数让Cricket重新定义:

class Grasshopper: public CartoonCharacter {
public:
virtual void dance();
virtual void sing();

protected:
virtual void danceCustomization1();
virtual void danceCustomization2();

virtual void singCustomization();
};

蚱蜢跳舞现在被定义成象这样:

void Grasshopper::dance()
{
执行共同的跳舞动作;

danceCustomization1();

执行更多共同的跳舞动作;

danceCustomization2();

执行最后共同的跳舞动作;
}

蚱蜢唱歌的设计与此类似。

很明显,Cricket类必须修改一下,因为它必须重新定义新的虚函数:

class Cricket:public CartoonCharacter,
private Grasshopper {
public:
virtual void dance() { Grasshopper::dance(); }
virtual void sing() { Grasshopper::sing(); }

protected:
virtual void danceCustomization1();
virtual void danceCustomization2();

virtual void singCustomization();
};

这看来很不错。当需要Cricket对象去跳舞时,它执行Grasshopper类中共同的dance代码,然后执行Cricket类中定制的dance代码,接着继续执行Grasshopper::dance中的代码,等等。

然而,这个设计中有个严重的缺陷,这就是,你不小心撞上了 "奥卡姆剃刀" ---- 任何一种奥卡姆剃刀都是有害的思想,William of Occam的尤其如此。奥卡姆者鼓吹:如果没有必要,就不要增加实体。现在的情况下,实体就是指的继承关系。如果你相信多继承比单继承更复杂的话(我希望你相信),Cricket类的设计就没必要复杂。(译注:1) William of Occam(1285-1349),英国神学家,哲学家。2) 奥卡姆剃刀(Occam's razor)是一种思想,主要由William of Occam提出。之所以将它称为 "奥卡姆剃刀",是因为William of Occam经常性地、很锐利地运用这一思想。)

问题的根本之处在于,Cricket类和Grasshopper类之间并非 "用...来实现" 的关系。而是,Cricket类和Grasshopper类之间享有共同的代码。特别是,它们享有决定唱歌跳舞行为的代码 ---- 蚱蜢和蟋蟀都有这种共同的行为。

说两个类具有共同点的方式不是让一个类从另一个类继承,而是让它们都从一个共同的基类继承,蚱蜢和蟋蟀之间的公共代码不属于Grasshopper类,也不属于Cricket,而是属于它们共同的新的基类,如,Insect:

class CartoonCharacter { ... };

class Insect: public CartoonCharacter {
public:
virtual void dance(); // 蚱蜢和蟋蟀
virtual void sing(); // 的公共代码

protected:
virtual void danceCustomization1() = 0;
virtual void danceCustomization2() = 0;

virtual void singCustomization() = 0;
};

class Grasshopper: public Insect {
protected:
virtual void danceCustomization1();
virtual void danceCustomization2();

virtual void singCustomization();
};

class Cricket: public Insect {
protected:
virtual void danceCustomization1();
virtual void danceCustomization2();

virtual void singCustomization();
};

CartoonCharacter
|
|
Insect
/\
/ \
/ \
Grasshopper Cricket

可以看到,这个设计更清晰。只是涉及到单继承,此外,只是用到了公有继承。Grasshopper和Cricket定义的只是定制功能;它们从Insect一点没变地继承了dance和sing函数。William of Occam一定会很骄傲。

尽管这个设计比采用了MI的那个方案更清晰,但初看可能会觉得比使用MI的还要逊色。毕竟,和MI的方案相比,这个单继承结构中引入了一个全新的类,而使用MI就不需要。如果没必要,为什么要引入一个额外的类呢?

这就将你带到了多继承诱人的本性面前。表面看来,MI好象使用起来更容易。它不需要增加新的类,虽然它要求在Grasshopper类中增加一些新的虚函数,但这些函数在任何情况下都是要增加的。

设想有个程序员正在维护一个大型C++类库,现在需要在库中增加一个新的类,就象Cricket类要被增加到现有的的CartoonCharacter/Grasshopper层次结构中一样。程序员知道,有大量的用户使用现有的层次结构,所以,库的变化越大,对用户的影响越大。程序员决心将这种影响降低到最小。对各种选择再三考虑之后,程序员认识到,如果增加一个从Grasshopper到Cricket的私有继承连接,层次结构中将不需要任何其它变化。程序员不禁因为这个想法露出了微笑,暗自庆幸今后可以大量地增加功能,而代价仅仅只是增加很小一点复杂性。

现在设想这个负责维护的程序员是你。那么,请抵御这一诱惑!

条款44:

在本章关于 "继承和面向对象设计" 的简介中,我曾强调,理解不同的面向对象构件在C++中的含义十分重要。这和仅仅知道C++语言的规则有很大的不同。例如,C++规则说,如果类D从类B公有继承,从D的指针到B的指针就有一个标准转换;B的公有成员函数将被继承为D的公有成员函数,等等。这些规则都是正确的,但在将设计思想转化为C++的过程中,它们起不到任何作用。相反,你需要知道,公有继承意味着 "是一个",如果D从B公有继承,类型D的每一个对象也 "是一个" 类型B的对象。因而,如果想在设计中表示 "是一个",就自然会想到使用公有继承。

"说出你想说的" 只是成功的一半。事情的另一面是 "理解你所说的",这一点同样重要。例如,将成员函数声明为非虚函数会给子类带来限制,如果没有认识到这一点就随便这样做将是不负责任的行为 ---- 除非你完全是有意这么做。声明一个非虚成员函数,你实际上是在说这个函数表示了一种特殊性上的不变性;如果不明白这一点,将会给程序带来灾难。

公有继承和 "是一个" 的等价性,以及非虚成员函数和 "特殊性上的不变性" 的等价性,是C++构件如何和设计思想相对应的例子。下面的列表总结了这些对应关系中最重要的几个。

· 共同的基类意味着共同的特性。如果类D1和类D2都把类B声明为基类,D1和D2将从B继承共同的数据成员和/或共同的成员函数。见条款43。
· 公有继承意味着 "是一个"。如果类D公有继承于类B,类型D的每一个对象也是一个类型B的对象,但反过来不成立。见条款35。
· 私有继承意味着 "用...来实现"。如果类D私有继承于类B,类型D的对象只不过是用类型B的对象来实现而已;类型B和类型D的对象之间不存在概念上的关系。见条款42。
· 分层意味着 "有一个" 或 "用...来实现"。如果类A包含一个类型B的数据成员,类型A的对象要么具有一个类型为B的部件,要么在实现中使用了类型B的对象。见条款40。

下面的对应关系只适用于公有继承的情况:

· 纯虚函数意味着仅仅继承函数的接口。如果类C声明了一个纯虚函数mf,C的子类必须继承mf的接口,C的具体子类必须为之提供它们自己的实现。见条款36。
· 简单虚函数意味着继承函数的接口加上一个缺省实现。如果类C声明了一个简单(非纯)虚函数mf,C的子类必须继承mf的接口;如果需要的话,还可以继承一个缺省实现。见条款36。
· 非虚函数意味着继承函数的接口加上一个强制实现。如果类C声明了一个非虚函数mf,C的子类必须同时继承mf的接口和实现。实际上,mf定义了C的 "特殊性上的不变性"。见条款36。

我猜你可能也喜欢:

No Comments - Leave a comment

Leave a comment

电子邮件地址不会被公开。 必填项已用*标注

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>


Welcome , today is 星期六, 2017 年 10 月 21 日