读书笔记 effective C++ Item 40 明智而谨慎的使用多继承
1. 多继承的两个阵营
当我们谈论到多继承(MI)的时候,C++委员会被分为两个基本阵营。一个阵营相信如果单继承是好的C++性质,那么多继承肯定会更好。另外一个阵营则争辩道单继承诚然是好的,但多继承太麻烦,而不值得去使用它。在这个条款中,我的主要目标是让你明白多继承的这两个观点。
2. 从多个基类中继承的名字不能相同
第一件事情是你需要认识到使用MI进行设计时,从多个基类中可能会继承相同的名字(例如函数或者typedef等等)。这就会导致模棱两可的问题,例如:
1 class BorrowableItem { // something a library lets you borrow 2 3 public: 4 5 void checkOut(); // check the item out from the library 6 7 ... 8 9 }; 10 11 class ElectronicGadget { 12 13 private: 14 15 16 bool checkOut() const; // perform self-test, return whether 17 ... // test succeeds 18 }; 19 class MP3Player: // note MI here 20 21 public BorrowableItem, // (some libraries loan MP3 players) 22 23 public ElectronicGadget 24 25 26 27 { ... }; // class definition is unimportant 28 29 MP3Player mp; 30 mp.checkOut(); // ambiguous! which checkOut?
注意在这个例子中,对checkout的调用是模棱两可的,即使只有两个函数中的一个是可访问的。(checkout在BorrowableItem中是public的而在ElectronicGadget中是private的)。这对应着C++规则中对重载函数是如何解决的:在寻找是否有一个函数可被访问之前,C++首先首先识别出函数调用的最佳匹配函数。找到最佳匹配函数之后才会检查函数的可访问性。在这种情况下,搜寻名字的时候checkOut是模棱两可的,所以既不能解决函数重载的问题也不能决定最佳匹配函数。ElectronicGadget::checkOut的可访问性根本不会被检查到。
为了解决这个模棱两可的问题,你必须指定调用哪个基类的函数:
1 mp.BorrowableItem::checkOut(); // ah, that checkOut
当然你也可以显示调用ElectronicGadget::checkOut,但是模棱两可的错误将会被下面的错误所替代——“你在尝试调用一个private成员函数”
3. 钻石型多继承
3.1 钻石型继承中数据成员的重复问题
多继承仅仅意味着从多个基类(多于一个)中继承,但是对于多继承来说在继承体系中发现更高层次的基类也并不是不常见。这就导致了我们常说的致命的“钻石型多继承”:
任何时候在一个继承体系中,如果基类和派生类之间的路径多于一条,你必须面对基类中的数据成员是否在每条路径上都要被复制的问题。例如,假设File类有一个数据成员,fileName。IOFile应该有它的几份拷贝?从一方面讲,它从每个基类中都继承了一份拷贝,所以表明IOFile应该会有两个fileName数据成员。从另外一方面讲,一个IOFIle只有一个文件名,所以从两个基类中继承的fileName部分不应该被重复。
3.2 C++如何处理钻石型继承
C++在这场辩论中没有任何立场。它很高兴的支持两种选择,默认的选择是执行重复。如果这不是你想要的,你必须将包含数据(也即是File)的类变为虚基类。为了达到这个目的,你会对所有继承自它的类使用虚继承。
标准C++库包含一个多继承体系,就像上面的继承一样,但是类模版的类不在其中,这些类的名字是basic_ios,basic_istream,basic_ostream和basic_iostream,它们分别替换了File,InputFile,OutputFile和IOFile。
3.3 虚继承耗费资源,不能随意使用
从正确行为的角度,public继承应该总是virtual的。如果你只考虑这一点,规则会很简单:在你使用public继承的任何时候都要使用virtual public继承。但正确性不是我们要唯一关注的,为了阻止对继承而来的字段进行重复,编译器会在背后耍一些花招,结果是使用虚继承的类创建出来的对象会比不使用虚继承的类创建出来的对象要大。访问虚基类中的数据成员比访问非虚基类中的数据成员要慢。详细情况随编译器的不同而不同,但是基本的观点很清楚:虚继承耗费资源。
资源耗费也体现在其他方面。管理虚基类初始化列表的规则比非虚基类更加复杂,更加不直观。初始化虚基类部分的责任由继承体系中最底层的派生类承担。这种规则就意味着:(1)继承自虚基类的类如果需要初始化,它们必须意识到虚基类的存在,无论这个虚基类离派生类有多远。(2)当一个派生类被添加到继承体系中的时候,它必须承担初始化虚基类的责任(无论是直接的还是间接的虚基类)。
我对于使用虚基类(也就是虚继承)的建议很简单。首先,不要使用虚基类,除非你需要它。默认情况下使用非虚基类。第二,如果你必须使用虚基类,尝试着不要在这些类中放置数据。这样你就不必为这些类的初始化(还有赋值)规则的古怪行为进行担心了。值得注意的是,Java和.NET中的接口(在许多方面相当于C++的虚基类)是不允许包含任何数据的。
4. 另外一种多继承的使用场景
让我们看一下下面的C++接口类(见Item 31),这个类为persons建模:
1 class IPerson { 2 public: 3 virtual ~IPerson(); 4 virtual std::string name() const = 0; 5 virtual std::string birthDate() const = 0; 6 };
IPerson的客户必须依赖IPerson指针和引用来进行编程,因为抽象类不能被实例化。为了创建可以被IPerson对象操作的对象,IPerson的客户使用工厂函数(Item 31)来实例化派生自IPerson的具现类:
1 // factory function to create a Person object from a unique database ID; 2 // see Item 18 for why the return type isn’t a raw pointer 3 std::tr1::shared_ptr<IPerson> makePerson(DatabaseID personIdentifier); 4 5 // function to get a database ID from the user 6 DatabaseID askUserForDatabaseID(); 7 DatabaseID id(askUserForDatabaseID()); 8 std::tr1::shared_ptr<IPerson> pp(makePerson(id)); // create an object 9 // supporting the 10 // IPerson interface 11 12 ... // manipulate *pp via 13 // IPerson’s member 14 // functions
但是如何使用makePerson创建返回指针指向的对象呢?很清楚,必须有一些派生自IPerson具现类,使得makePerson能够对这些具现类进行实例化。
我们把这个类叫做CPerson。作为一个具现类,CPerson必须为继承自IPerson的纯虚函数提供一份实现。我们可以从头开始实现这个函数,但是利用现成的组件来对其进行实现更好,这些现成的组件实现了大部分或者全部的必要功能。例如,假设一个旧数据库指定的类PersonInfo为CPerson提供了它需要的最基本的东西:
1 class PersonInfo { 2 public: 3 explicit PersonInfo(DatabaseID pid); 4 virtual ~PersonInfo(); 5 virtual const char * theName() const; 6 virtual const char * theBirthDate() const; 7 ... 8 private: 9 10 11 virtual const char * valueDelimOpen() const; // see 12 virtual const char * valueDelimClose() const; // below 13 ... 14 };
你能够识别出来这是一个旧类,因为成员函数返回的是const char*而不是string对象。并且如果鞋是合适的,为什么不穿上呢?这个类的成员函数的名字也表明了使用结果会非常棒。
你现在发现了PersonInfo的设计是用来帮助打印不同格式的数据库字段的,这些字段值的开始和结尾处都由特殊的字符串进行分隔。默认情况下,字段值的开始和结束分隔符是方括号,所以值”Ring-tailed Lemur”会被格式化成下面的字符串:
[Ring-tailed Lemur]
认识到方括号并不能被PersonInfo的客户普遍接受,虚函数valueDelimOpen和valueDelimClose允许派生类指定它们自己的开始和结束分隔字符串。PersonInfo的成员函数的实现会调用这些虚函数来为返回值添加合适的分隔符。使用PersonInfo::theName作为例子,代码可能像下面这样:
1 const char * PersonInfo::valueDelimOpen() const 2 { 3 4 return "["; // default opening delimiter 5 6 } 7 8 const char * PersonInfo::valueDelimClose() const 9 10 { 11 12 13 14 return "]"; // default closing delimiter 15 16 } 17 18 const char * PersonInfo::theName() const 19 20 { 21 22 // reserve buffer for return value; because this is 23 24 // static, it’s automatically initialized to all zeros 25 26 static char value[Max_Formatted_Field_Value_Length]; 27 28 // write opening delimiter 29 30 std::strcpy(value, valueDelimOpen()); 31 32 append to the string in value this object’s name field (being careful 33 34 to avoid buffer overruns!) 35 36 // write closing delimiter 37 38 std::strcat(value, valueDelimClose()); 39 40 return value; 41 42 }
有人可能对PersonInfo::theName的过时设计发出疑问(特使是对固定大小的static缓存的使用,有时会造成越界或者线程问题,见Item 21),但是将这种问题放到一边,我们将焦点放在下面这件事情上:theName调用valueDelimOpen来为返回的string生成开始分隔符,然后生成名字本身,最后调用valueDelimClose。
因为valueDelimOpen和valueDelimClose是虚函数,由theName返回的值不仅依赖于PerosnInfo同时依赖于PersonInfo的派生类。
作为CPerson的实现者,这是个好消息,因为当对IPerson的文档进行精读时,你发现name和birthDate需要返回没有分隔符的值。也就是一个叫做”Homer”的人,对这个名字进行函数调用会返回“Homer”而不是“[Homer]”。
CPerson和PersonInfo之间的关系是PersonInfo恰好有一些函数使得CPerson的实现更加容易。它们的关系因此为“is-implemented-in-terms-of”,我们知道这种关系可以被表现为其它两种形式:通过组合(Item 38)和private继承(Item 39)。Item 39指出组合通常讲比Private继承要更好,但如果虚函数需要重定义,private继承就是必须的。在这种情况中,CPerson需要重新定义valueDelimOpen和valueDelimClose,所以使用组合在这里不能工作。最简单直接的解决方案是让CPerson private继承PersonInfo,虽然Item 39解释道如果多做一些工作,CPerson可以使用组合和继承的结合体来有效重定义PersonInfo的虚函数。在这里,我们使用private继承。
但是CPerson同样必须实现IPerson接口,这些接口为public继承所用。这也导致了合理的多继承应用:将一个接口的public接口和一个实现的private继承结合起来使用:
1 class IPerson { // this class specifies the 2 3 public: // interface to be implemented 4 5 virtual ~IPerson(); 6 7 virtual std::string name() const = 0; 8 9 virtual std::string birthDate() const = 0; 10 11 }; 12 13 14 15 class DatabaseID { ... }; // used below; details are 16 // unimportant 17 18 class PersonInfo { // this class has functions 19 20 21 public: // useful in implementing 22 explicit PersonInfo(DatabaseID pid); // the IPerson interface 23 virtual ~PersonInfo(); 24 virtual const char * theName() const; 25 virtual const char * theBirthDate() const; 26 ... 27 private: 28 virtual const char * valueDelimOpen() const; 29 virtual const char * valueDelimClose() const; 30 ... 31 }; 32 33 class CPerson: public IPerson, private PersonInfo { // note use of MI 34 public: 35 explicit CPerson(DatabaseID pid): PersonInfo(pid) {} 36 virtual std::string name() const // implementations 37 { return PersonInfo::theName(); } // of the required 38 // IPerson member 39 virtual std::string birthDate() const // functions 40 { return PersonInfo::theBirthDate(); } 41 private: // redefinitions of 42 const char * valueDelimOpen() const { return ""; } // inherited virtual 43 const char * valueDelimClose() const { return ""; } // delimiter 44 45 }; // functions
用UML来表示:
这个例子表明了多继承是有用的和可被理解的。
5. 何时使用多继承
最后,多继承只是面向对象工具包中的另外一个工具。和单继承相比,使用和理解都更加复杂,所以如果你碰到一个和多继承设计基本相当的单继承设计,使用单继承设计基本上会是更好的选择。如果你只能想出多继承设计,你应该更加努力的思考——肯定有一些方法来让单继承正常的工作。同时,多继承有时是最干净的,最容易维护以及最合理的,它能使工作有效进行。如果你碰到这种情况,不要害怕使用它。确保合理而谨慎的使用private继承就可以了。
6. 总结
- 多继承比单继承更加复杂。它会引起新的模棱两可的问题,因此需要使用虚继承。
- 虚继承的使用会增大体积,降低速度,增加初始化和赋值的复杂度。在虚基类中没有数据的情况下使用多继承是最实际的。
- 多继承也有合理的使用场景。一种使用场景涉及到将对接口类的public继承和将对实现类的private继承相结合的情况。
作者:
HarlanC
博客地址:
http://www.cnblogs.com/harlanc/
个人博客:
http://www.harlancn.me/
本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出,
原文链接