Effective C++ 2e Item43

    技术2022-05-11  129

    条款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::drawpls->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::lotteryDrawpl->draw();

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

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


    最新回复(0)