条款43: 明智地使用多继承 - Effective C++ 中文版

返回介绍

条款43: 明智地使用多继承

发布于 2019-08-06 字数 21094 浏览 1001 评论 0

条款43: 明智地使用多继承

要看是谁来说,多继承(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的私有继承连接,层次结构中将不需要任何其它变化。程序员不禁因为这个想法露出了微笑,暗自庆幸今后可以大量地增加功能,而代价仅仅只是增加很小一点复杂性。


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

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

支持 Markdown 语法,需要帮助?

目前还没有任何评论,快来抢沙发吧!