多继承(MI)的用法

  
多继承(MI)要么被认为是神来之笔,要么被当成是魔鬼的造物。支持者说,它是对真实世界问题进行自然模型化所必需的;而批评者说,它太慢,难以实现,功能却不比单继承强大。更让人为难的是,面向对象编程语言领域在这个问题上至今仍存在分歧:C++,Eiffel和the Common LISP Object System (CLOS)提供了MI;Smalltalk,Objective C和Object Pascal没有提供;而Java只是提供有限的支持。

   C++中,关于MI一条不容争辩的事实是,MI的出现带来了单继承中绝对不会存在的复杂性。其中,最基本的一条是二义性。如果一个派生类从多个基类继承了一个成员名,所有对这个名字的访问都是二义的;必须明确地说出所指的是哪个成员。看一个例子:
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函数是私有成员从而不能被访问,二义还是存在。

 显式地限制修饰成员不仅很笨拙,而且还带来限制。当显式地用一个类名来限制修饰一个虚函数时,函数的行为将不再具有虚拟的特征。相反,被调用的函数只能是所指定的那个,即使调用是作用在派生类的对象上:
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对象,也无法(没有 "向下转换" )调用这个类中定义的draw函数。

    Lottery和GraphicalObject中的draw函数都被声明为虚函数,所以子类可以重新定义它们,但如果LotterySimulation想对二者都重新定义,令人沮丧的是,这不可能,因为一个类只允许有唯一一个没有参数、名称为draw的函数。(这个规则有个例外,即一个函数为const而另一个不是的时候)

 从某一方面来说,这个问题很严重,严重到足以成为修改C++语言的理由。允许被继承的虚函数可以 "改名" ;但后来又发现,可以通过增加一对新类来巧妙地避开这个问题:

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();

 这是一个集纯虚函数,简单虚函数和内联函数综合应用之大成的方法,值得牢记在心。首先,它解决了问题,这个问题说不定哪天就会碰到。其次,它可以提醒使用多继承会导致复杂性。这个方法解决了问题,但仅仅为了重新定义一个虚函数而不得不去引入新的类。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,作为库的设计者就要具有超凡的远见。

  在虚函数和非虚函数间选择,还是有重大的不同。虚函数具有定义明确的高级含义,非虚函数也同样具有定义明确的高级含义,而且它们的含义有显著的不同,所以在清楚自己想对子类的设计者传达什么含义的基础上,在二者之间作出选择是可能的。但是,决定基类是否应该是虚拟的,则缺乏定义明确的高级含义;相反,决定通常取决于整个继承的层次结构,所以除非知道了整个层次结构,否则无法做出决定。如果正确地定义出个类之前需要清楚地知道将来怎么使用它,这种情况下将很难设计出高效的类。

 就算避开了二义性问题,并且解决了是否应该从基类虚拟继承的疑问,还是会有许多复杂性问题。为了长话短说,在此仅提出应该记住的其它两点:
· 向虚基类传递构造函数参数。非虚继承时,基类构造函数的参数是由紧临的派生类的成员初始化列表指定的。因为单继承的层次结构只需要非虚基类,继承层次结构中参数的向上传递采用的是一种很自然的方式:第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。如果仔细想想,这正是想要的行为。

   C++的设计者并没有想让多继承难以使用;恰恰是,想让一切都能以更合理的方式协调工作,这本身会带来某些复杂性。上面的讨论中会注意到,这些复杂性很多是由于使用虚基类引起的。如果能避免使用虚基类,即如果能避免产生那种致命的钻石形状继承图,事情就好处理多了。

   协议类(Protocol class)的存在仅仅是为派生类制定接口;它没有数据成员,没有构造函数,有一个虚析构函数,有一组用来指定接口的纯虚函数。一个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)来实例化具体的子类:

// 工厂函数,从一个唯一的数据库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;
}

 现在让name及其相关函数需要返回的是不带修饰的值,即,不允许带分隔符。MyPerson和PersonInfo之间的关系是,PersonInfo刚好有些函数使得MyPerson易于实现。仅此而已。没看到有 "是一个" 或 "有一个" 的关系。它们的关系是 "用...来实现",这可以用两种方式来表示:通过分层和通过私有继承。分层一般来说是更好的方法,但在有虚函数要被重新定义的情况下,需要使用私有继承。现在的情况是,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对象都有意义。什么也不做的缺省行为通过类中那些函数的空定义来表示。假设有一个特殊类型的卡通角色是蚱蜢,它以自己特殊的方式跳舞唱歌:
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中的代码,等等。

 问题的根本之处在于,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();
};
           C artoonCharacter
                        |
                        |
                   Insect
                       //
                      /  /
                     /    /
 Grasshopper     Cricket


 可以看到,这个设计更清晰。只是涉及到单继承,此外,只是用到了公有继承。Grasshopper和Cricket定义的只是定制功能;它们从Insect一点没变地继承了dance和sing函数。

 尽管这个设计比采用了MI的那个方案更清晰,但初看可能会觉得比使用MI的还要逊色。毕竟,和MI的方案相比,这个单继承结构中引入了一个全新的类,而使用MI就不需要。如果没必要,为什么要引入一个额外的类呢?表面看来,MI好象使用起来更容易。它不需要增加新的类,虽然它要求在Grasshopper类中增加一些新的虚函数,但这些函数在任何情况下都是要增加的。

 设想有个程序员正在维护一个大型C++类库,现在需要在库中增加一个新的类,就象Cricket类要被增加到现有的的CartoonCharacter/Grasshopper层次结构中一样。程序员知道,有大量的用户使用现有的层次结构,所以,库的变化越大,对用户的影响越大。程序员决心将这种影响降低到最小。对各种选择再三考虑之后,程序员认识到,如果增加一个从Grasshopper到Cricket的私有继承连接,层次结构中将不需要任何其它变化。而代价仅仅只是增加很小一点复杂性。那么,请抵御这一诱惑。
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值