Effective C++条款40:明智而审慎地使用多重继承(Use multiple inheritance judiciously)
- 条款40:明智而审慎地使用多重继承
- 1、多重继承的两个阵营
- 2、多重继承中,接口调用的歧义性
- 3、菱形继承与虚(virtual)继承
- 3.1 菱形继承中数据成员的重复问题
- 3.2 C++如何处理菱形继承
- 4、虚继承的代价
- 5、多重继承案例
- 6、牢记
- 总结
《Effective C++》是一本轻薄短小的高密度的“专家经验积累”。本系列就是对Effective C++进行通读:
第6章:继承与面向对象设计
条款40:明智而审慎地使用多重继承
1、多重继承的两个阵营
当涉及到多重继承(MI)的时候,C++社群便被分为两个基本阵营。其一是:认为如果单一继承(SI)是好的,多重继承肯定会更好。另一个则主张:单一继承是好的,但多重继承不值得拥有(或使用)。本条款中,我的主要目标是让你明白多重继承的这两个观点。
2、多重继承中,接口调用的歧义性
当一个类继承自两个基类时,两个基类包含有相同的名称(如函数、typedef等),那么调用时就会产生歧义性
class BorrowableItem { // 图书馆允许你借某些东西
public:
void checkOut(); // 离开进行检查
...
};
class ElectronicGadget {
private:
bool checkOut() const; //注意,此处的为private
...
};
//多重继承
class MP3Player:
public BorrowableItem,
public ElectronicGadget
{ ... };
MP3Player mp;
mp.checkOut(); //歧义!调用的是哪个checkOut
注意此例子中,对checkout的调用是歧义的,即使只有两个函数中的一个是可取用的。(checkout在BorrowableItem中是public的而在ElectronicGadget中是private的)。这与C++用来解析(resolving)重载函数调用的规则相符:在看到是否有个函数可取用之前,C++首先首先识别出函数调用的最佳匹配函数。找到最佳匹配函数之后才会检查函数的可取用性。
本例中两个checkout有相同的匹配程度,所以既不能解决函数重载的问题也不能决定最佳匹配函数。ElectronicGadget::checkOut的可访问性根本不会被检查到。
为了解决这个歧义,你必须指定调用哪个基类的函数:
mp.BorrowableItem::checkOut();
当然你也可以显示调用ElectronicGadget::checkOut,但是你会获得一个“尝试调用private成员函数”的错误。
3、菱形继承与虚(virtual)继承
3.1 菱形继承中数据成员的重复问题
多重继承仅仅意味着从多个基类(多于一个)中继承,但是对于多重继承来说在继承体系中发现更高层次的基类也并不是不常见。这就导致了我们常说的致命的“钻石型多重继承”:
class File { ... };
class InputFile: public File { ... };
class OutputFile: public File { ... };
class IOFile: public InputFile, public OutputFile
{ ... };
任何时候在一个继承体系中,如果基类和派生类之间有一条以上的相通路线,你必须面对基类中的数据成员是否在每条路径上都要被复制的问题。假设File类有一个数据成员,fileName。IOFile应该有它的几份拷贝?从一方面讲,它从每个基类中都继承了一份拷贝,所以表明IOFile应该会有两个fileName数据成员。从另外一方面讲,一个IOFIle只有一个文件名,所以从两个基类中继承的fileName部分不应该被重复。
3.2 C++如何处理菱形继承
C++在这场辩论中没有倾斜立场。两个方案它都支持——虽然其缺省做法是执行复制(也就是上一段所说的第一个做法)。如果那不是你想要的,你必须将包含数据(也即是File)的类变为虚基类。为了达到这个目的,你会对所有继承自它的类使用虚继承。
class File { ... };
class InputFile: virtual public File { ... };
class OutputFile: virtual public File { ... };
class IOFile: public InputFile, public OutputFile
{ ... };
C++标准库程序内含一个多重继承体系,如上图,但是类模版的类不在其中,这些类的名字是basic_ios,basic_istream,basic_ostream和basic_iostream,它们分别替换了File,InputFile,OutputFile和IOFile。
4、虚继承的代价
从正确行为的观点看,public继承应该总是virtual的。如果你只是唯一一个观点,规则会很简单:在你使用public继承的任何时候都要使用virtual public继承。但正确性不是我们要唯一关注的,为了阻止对继承而来的字段进行重复,编译器会在背后耍一些花招,结果是使用虚继承的类创建出来的对象会比不使用虚继承的类创建出来的对象要大。访问虚基类中的数据成员比访问非虚基类中的数据成员要慢。详细情况随编译器的不同而不同,但是基本重点也很清楚:虚继承耗费资源。
虚继承的成本还包括其他方面。支配虚基类初始化列表的规则比非虚基类更加复杂,且不直观。初始化虚基类部分的责任由继承体系中最底层的派生类承担。这种规则就意味着:
-
① 继承自虚基类的类如果需要初始化,它们必须意识到虚基类的存在,无论这个虚基类离派生类有多远。
-
② 当一个派生类被添加到继承体系中的时候,它必须承担初始化虚基类的责任(无论是直接的还是间接的虚基类)。
我对于使用虚基类(也就是虚继承)的建议很简单。
-
① 不要使用虚基类,除非你需要它。默认情况下使用非虚基类。
-
② 如果你必须使用虚基类,尝试着不要在这些类中放置数据。这样你就不必为这些类的初始化(还有赋值)规则的古怪行为进行担心了。值得注意的是,Java和.NET中的接口(在许多方面相当于C++的虚基类)是不允许包含任何数据的。
5、多重继承案例
让我们看一下下面的C++接口类(见条款31)
IPerson类
下面是一个抽象类:其中包含纯虚函数name()和birthDate()
class IPerson {
public:
virtual ~IPerson();
virtual std::string name() const = 0; //返回人的名称
virtual std::string birthDate() const = 0; //返回生日
};
IPerson的客户必须依赖IPerson指针和引用来进行编程,因为抽象类不能被实例化。为了创建可以被IPerson对象操作的对象,IPerson的客户使用工厂函数(条款31)来实例化派生自Person的具现类:
std::tr1::shared_ptr<IPerson> makePerson(DatabaseID personIdentifier);
DatabaseID askUserForDatabaseID();
DatabaseID id(askUserForDatabaseID());
std::tr1::shared_ptr<IPerson> pp(makePerson(id)); // create an object
...
但是如何使用makePerson创建返回指针指向的对象呢?无疑地一定有些派生自IPerson具现类,使得makePerson能够对这些具现类进行实例化。
我们把这个类叫做CPerson。就像具现类一样,CPerson必须为继承自IPerson的纯虚函数提供一份实现代码。我们可以从头开始实现这个函数,但是利用现成的组件来对其进行实现更好,这些现成的组件实现了大部分或者全部的必要功能。例如,假设一个旧数据库指定的类PersonInfo为CPerson提供了它需要的最基本的东西:
PersonInfo类
class PersonInfo {
public:
explicit PersonInfo(DatabaseID pid);
virtual ~PersonInfo();
virtual const char * theName() const;
virtual const char * theBirthDate() const;
...
private:
virtual const char * valueDelimOpen() const;
virtual const char * valueDelimClose() const;
...
};
valueDelimOpen()、valueDelimClose():
-
功能:每个字段值的起点和结尾都以特殊字符串为界
-
缺省的头尾界限符号是方括号。例如Ring-tailed Lemur将被格式化为:[Ring-tailed Lemur]
-
每个人可能喜欢不同的界限符号,所以这两个virtual函数允许派生类自己定义不同的头尾界限符号。例如可能PersonInfo的派生类可能会重写这两个虚函数,代码如下:
//缺省的虚函数,派生类可以重写
const char* valueDelimOpen()const
{
return "[";
}
const char* valueDelimClose()const
{
return "]";
}
theName()、theBirthDate():用来返回相关的数据库字段(名字、生日等)。下面以theName()为例:
const char* theName()const {
static char value[Max_Formatted_Field_Value_Length];
std::strcpy(value, valueDelimOpen());
//将名字添加进value
std::strcat(value, valueDelimClose());
return value;
}
作为CPerson的实现者,这是个好消息,因为当对IPerson的文档进行精读时,你发现name和birthDate需要返回没有分隔符的值。也就是一个叫做”Homer”的人,对这个名字进行函数调用会返回“Homer”而不是“[Homer]”。
CPerson和PersonInfo之间的关系是PersonInfo恰好有一些函数使得CPerson的实现更加容易。它们的关系因此为“is-implemented-in-terms-of”,我们知道这种关系可以被表现为其它两种形式:通过组合(条款38)和private继承(条款39)。条款39指出组合通常讲比Private继承要更好,但如果虚函数需要重定义,private继承就是必须的。在这种情况中,CPerson需要重新定义valueDelimOpen和valueDelimClose,所以使用组合在这里不能工作。最简单直接的解决方案是让CPerson private继承PersonInfo,虽然Item 39解释道如果多做一些工作,CPerson可以使用组合和继承的结合体来有效重定义PersonInfo的虚函数。在这里,我们使用private继承。
但是CPerson同样必须实现IPerson接口,这些接口为public继承所用。这也导致了合理的多重继承应用:将一个接口的public接口和一个实现的private继承结合起来使用:
CPerson类
CPerson是最终的表示“人”的类,其继承于IPerson和PersonInfo
公有继承于IPerson:
- 因为IPerson的name()和birthDate()两个虚函数返回未经修饰的人物的名称和生日,并且IPerson为抽象类,因此CPerson以public继承于IPerson
私有继承于PersonInfo:
- PersonInfo已经提供了返回修饰的人名和生日的虚函数,因此CPerson可以利用PersonInfo来实现,这是一种is-implemented-in-terms-of(根据某物实现出)模式
class IPerson {
public:
virtual ~IPerson();
virtual std::string name()const = 0;
virtual std::string birthDate()const = 0;
};
class DatabaseID {};
class PersonInfo {
public:
explicit PersonInfo(DatabaseID pid);
virtual ~PersonInfo();
virtual const char* theName()const;
virtual const char* theBirthDate()const;
private:
virtual const char* valueDelimOpen()const;
virtual const char* valueDelimClose()const;
};
class CPerson :public IPerson, private PersonInfo {
public:
explicit CPerson(DatabaseID pid) :PersonInfo(pid) {}
virtual std::string name()const = 0{
return PersonInfo::theName();
}
virtual std::string birthDate()const = 0 {
return PersonInfo::theBirthDate();
}
private:
virtual const char* valueDelimOpen()const;
virtual const char* valueDelimClose()const;
};
在UML图中这个设计看起来像这样:
这个例子告诉我们,多重继承也有它的合理用途的。
6、牢记
-
多重继承比单一继承复杂。它可可能导致新的歧义性,以及对virtual继承的需要。
-
virtual继承会增加大小、速度、初始化(及赋值)复杂度等等成本。如果virtual base classes不带任何数据,将是最具使用价值的情况。
-
多重继承的确有正当用途。其中一个情节涉及“public继承某个Interface class”和“private继承某个协助实现的class”的两项组合。
总结
期待大家和我交流,留言或者私信,一起学习,一起进步!