条款40:明智而审慎地使用多重继承
Use multiple inheritance judiciously
触及multiple inheritance (MI)(多继承)的时候,C++ 社区就会鲜明地分裂为两个基本的阵营。一个阵营认为如果single inheritance (SI)(单继承)是有好处的,multiple inheritance(多继承)一定更有好处。另一个阵营认为single inheritance(单继承)有好处,但是多继承引起的麻烦使它得不偿失。在这个 Item 中,我们的主要目的是理解在 MI 问题上的这两种看法。
当将多重继承(multiple inheritance,MI)引入设计领域时,程序有可能从多于一个的基类中继承相同名称(如函数,typedef等等)。这就为歧义性提供了新的时机,例如:
class BorrowableItem {
public:
void checkOut();
...
};
class ElectronicGadget {
private:
bool checkOut() const;
...
};
class MP3Player: // 多重继承
public BorrowableItem, public ElectronicGadget
{ ... }; // class definition is unimportant
MP3Playermp;
mp.checkOut(); // 调用哪个checkOut?
注意此例中对checkOut的调用是有歧义的,即使两个函数之中只有一个可取用(checkOut在BorrowableItem中是 public,但在 ElectronicGadget 中是 private)。C++解析重载函数调用的规则是:在看到一个函数可访问性之前,C++首先确定这个函数对此调用是最佳匹配。确定了最佳匹配函数之后,才检查可访问性。这目前的情况下,两个checkOuts具有匹配程度相同,所以就不存在最佳匹配。因此也不会检查到 ElectronicGadget::checkOut 的可访问性。为了消除歧义性,你必须指定哪一个基类的函数被调用:
mp.BorrowableItem::checkOut();
当然,你也可以尝试显式调用 ElectronicGadget::checkOut,但这样做会有一个“试图调用一个私有成员函数”的错误代替歧义性错误。
multiple inheritance意味着从多于一个的基类继承,但最致命的是“钻石型多重继承”:
classFile { ... };
class InputFile: public File { ... };
class OutputFile: public File { ... };
class IOFile: public InputFile, public OutputFile { ... };
任何时候如果你有一个继承体系而其中某个基类和派生类之间有一条以上的通路(就像在 File 和IOFile之间,有通过InputFile和OutputFile两条路径),你都必须面对“是否需要为每一条路径复制基类中的成员变量”的问题。例如,假设 File有一个成员变量fileName。IOFile中应该有多少个这个字段的拷贝呢?一方面,它从它的每一个基类继承一个拷贝,这就暗示IOFile应该有两个fileName成员变量;另一方面,简单的逻辑告诉我们一个IOFile对象应该仅有一个filename,所以通过它的两个基类继承来的fileName字段不应该被复制。
C++两种选项都支持,虽然它的缺省方式是执行复制。如果那不是你想要的,你必须这样做:
classFile { ... };
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。
从正确行为的观点看,public inheritance应该总是 virtual。但使用virtualinheritance(虚拟继承)的类创建的对象通常比不使用 virtual inheritance(虚拟继承)的要大。访问虚拟基类中的成员变量也比那些非虚拟基类中的要慢。基本的要点很清楚:虚拟继承要付出成本,还包括一些初始化成本。
对于虚拟基类(也就是virtual继承)的建议:
1. 除非必需,否则不要使用virtualbases。缺省情况下,使用non-virtual继承。
2. 如果你必须使用虚拟基类,试着避免在其中放置数据。这样你就不必在意这些类身上初始化和赋值所带来的麻烦了。
现在我们使用下面的 C++接口类来为“人”建模:
class IPerson {
public:
virtual ~IPerson();
virtual std::string name() const = 0;
virtual std::string birthDate() const= 0;
};
IPerson 的客户只能使用IPerson的指针和引用进行编程,因为抽象类不能被实例化。为了创建能被当作IPerson对象使用的对象,IPerson的客户使用工厂函数将“派生自IPerson的具象类”实例化:
// 工厂函数,根据一个独一无二的数据库ID创建一个Person对象
std::tr1::shared_ptr<IPerson> makePerson(DatabaseID personIdentifier);
// 这个函数从使用者手上取得一个数据库ID
DatabaseID askUserForDatabaseID();
DatabaseID id(askUserForDatabaseID());
std::tr1::shared_ptr<IPerson> pp(makePerson(id));
// 创建一个对象支持IPerson接口,借由IPerson成员函数处理*pp
...
要使makePerson创建对象并返回一个指针指向它,显然必须有某些派生自IPerson的具象类,在其中makePerson可以创建对象。假设这个类叫做CPerson,作为一个具象类,CPerson 必须提供它 IPerson 继承来的purevirtual函数的实现代码。它可以从头开始写,但利用现有组件更好一些。例如,假设有个既有的数据库相关类,名为PersonInfo,提供了CPerson 所需要的实质东西:
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;
...
};
你会发现PersonInfo 是设计用来帮助以不同的格式打印数据库字段的,每一个字段值的开始和结尾通过特殊字符串为界。缺省情况下,字段值开始和结尾定界符是方括号,所以字段值"Ring-tailed Lemur" 很可能被安排成这种格式:
[Ring-tailedLemur]
因为方括号并非人人喜爱,virtual函数valueDelimOpen和valueDelimClose允许派生类指定它们自己的开始和结尾定界字符串。PersonInfo成员函数将调用这些virtual函数,把适当的界限符号添加到它们的返回值上。以PersonInfo::theName为例,代码如下:
const char * PersonInfo::valueDelimOpen()const
{ return "["; }// 缺省的起始符号
const char * PersonInfo::valueDelimClose()const
{ return "]"; }// 缺省的结尾符号
const char * PersonInfo::theName() const
{
// 保留缓冲区给返回值使用;由于static,会被自动初始化为“全部是0”
static char value[Max_Formatted_Field_Value_Length];
// 写入起始符号
std::strcpy(value, valueDelimOpen());
现在将value内的字符串添加到这个对象的name成员变量中(注意缓冲区越界)
// 写入结尾符号
std::strcat(value, valueDelimClose());
return value;
}
忽略固定大小静态缓冲区的越界和线程问题,焦点在于:theName调用valueDelimOpen 生成它要返回的字符串的开始定界符,然后它生成名字值本身,最后调用valueDelimClose。因为valueDelimOpen和valueDelimClose是virtual函数,theName返回的结果不仅依赖于PersonInfo,也依赖于从PersonInfo派生的类。
对于CPerson的实现者,这是好消息,因为IPerson文档中规定, name和birthDate需要返回未经修饰的值,也就是不允许有定界符。即"Homer",而不是"[Homer]"。
CPerson和PersonInfo之间的关系是,PersonInfo有一些函数使得CPerson更容易实现,因而它们的关系就是 is-implemented-in-terms-of。在当前情况下,CPerson需要重定义 valueDelimOpen和valueDelimClose,所以简单的复合不行,最直截了当的解决方案是让 CPerson从PersonInfo私有继承。
但是CPerson还必须实现 IPerson接口,而这需要公有继承。因此这里多重继承的实现为:组合接口的公有继承和实现的私有继承:
class IPerson { // 这个类指出需要实现的接口
public:
virtual ~IPerson();
virtual std::string name() const = 0;
virtual std::string birthDate() const= 0;
};
class DatabaseID { ... }; // 稍后被使用,细节不重要
class PersonInfo { // 这个类有若干有用函数,可用以实现IPerson接口
public:
explicit PersonInfo(DatabaseID pid);
virtual ~PersonInfo();
virtual const char * theName() const;
virtual const char * theBirthDate()const;
virtual const char * valueDelimOpen()const;
virtual const char *valueDelimClose() const;
...
};
class CPerson: public IPerson, private PersonInfo { // 多重继承
public:
//用PersonInfo的构造函数
explicit CPerson( DatabaseID pid):PersonInfo(pid) {}
// public继承IPerson的接口并用PersonInfo来实现!
virtual std::string name() const
{ return PersonInfo::theName(); }
// 同上
virtual std::string birthDate() const
{ return PersonInfo::theBirthDate();}
private: // 重新定义继承而来的virtual“界限函数”
const char * valueDelimOpen() const {return ""; }
const char * valueDelimClose() const{ return ""; }
};
这个例子告诉我们多重继承也有它的合理用途。
多重继承的使用和理解更加复杂,所以等同于多重继承设计的单一继承设计总是更加可取。如果仅有的设计包含多重继承,应该考虑一下总会方法使得单一继承也能做到。但同时,多重继承有时是最清晰的、最易于维护的、最合理的方法,在这种情况下毫不畏惧地使用它,但确保明智和审慎。
· 多重继承比单一继承更复杂,它能导致新的歧义性和对virtual继承的需要。
· virtual继承会增加大小和速度成本,以及初始化和赋值的复杂度。当 virtual base classes(虚拟基类)不带任何数据,将是最具实用价值的情况。
· 多重继承的确有合理的用途:接口的公有继承+实现类的私有继承。