C-到-C---迁移手册-十-

84 阅读27分钟

C 到 C++ 迁移手册(十)

原文:Moving From C to C++

协议:CC BY-NC-SA 4.0

二十、运行时类型识别(RTTI)

当您只有指向基类型的指针或引用时,运行时类型标识(RTTI)允许您查找对象的动态类型。

这可以被认为是 C++ 中的一个“次要”特性,当你陷入罕见的困境时,实用主义可以帮助你。通常,您会有意忽略对象的确切类型,让虚函数机制实现该类型的正确行为。然而,有时候,知道一个只有一个基指针的对象的确切的运行时(也就是最派生的)类型是有用的。有了这些信息,您可以更有效地执行特殊情况操作,或者防止基类接口变得笨拙。大多数类库都包含虚函数来产生运行时类型信息,这种情况时有发生。当异常处理被添加到 C++ 中时,该特性需要关于对象的运行时类型的信息,因此下一步构建对该信息的访问变得很容易。本章解释了 RTTI 的用途以及如何使用它。

运行时强制转换

通过指针或引用确定对象的运行时类型的一种方法是使用运行时转换,它验证尝试的转换是有效的。当您需要将基类指针强制转换为派生类型时,这很有用。由于继承层次结构通常用派生类之上的基类来描述,这样的转换被称为向下转换。考虑图 20-1 中的等级结构。

9781430260943_Fig20-01.jpg

图 20-1 。一个投资阶层的等级体系

在清单 20-1 中,Investment 类有一个其他类没有的额外操作,所以在运行时知道Security指针是否指向Investment对象是很重要的。为了实现检查的运行时强制转换,每个类都保留一个整数标识符,以区别于层次结构中的其他类。

清单 20-1 。运行时检查强制转换

//: C20:CheckedCast.cpp
// Checks casts at runtime.
#include <iostream>
#include <vector>
#include "../purge.h" // SEE ahead in this Section
using namespace std;

class Security {
protected:
  enum { BASEID = 0 };
public:
  virtualSecurity() {}
  virtual bool isA(int id) { return (id == BASEID); }
};

class Stock : public Security {
  typedef Security Super;
protected:
  enum { OFFSET = 1, TYPEID = BASEID + OFFSET };
public:
  bool isA(int id) {
    return id == TYPEID || Super::isA(id);
  }
  static Stock* dynacast(Security* s) {
    return (s->isA(TYPEID)) ? static_cast<Stock*>(s) : 0;
  }
};

class Bond : public Security {
  typedef Security Super;
protected:
  enum { OFFSET = 2, TYPEID = BASEID + OFFSET };
public:
  bool isA(int id) {
    return id == TYPEID || Super::isA(id);
  }
  static Bond* dynacast(Security* s) {
    return (s->isA(TYPEID)) ? static_cast<Bond*>(s) : 0;
  }
};

class Investment : public Security {
  typedef Security Super;
protected:
  enum { OFFSET = 3, TYPEID = BASEID + OFFSET };
public:
  bool isA(int id) {
    return id == TYPEID || Super::isA(id);
  }
  static Investment* dynacast(Security* s) {
    return (s->isA(TYPEID)) ?
      static_cast<Investment*>(s) : 0;
  }
  void special() {
    cout << "special Investment function" << endl;
  }
};

class Metal : public Investment {
  typedef Investment Super;

protected:
  enum { OFFSET = 4, TYPEID = BASEID + OFFSET };

public:
  bool isA(int id) {
    return id == TYPEID || Super::isA(id);
  }
  static Metal* dynacast(Security* s) {
    return (s->isA(TYPEID)) ? static_cast<Metal*>(s) : 0;
  }
};

int main() {
  vector<Security*> portfolio;
  portfolio.push_back(new Metal);
  portfolio.push_back(new Investment);
  portfolio.push_back(new Bond);
  portfolio.push_back(new Stock);
  for(vector<Security*>::iterator it = portfolio.begin();
       it != portfolio.end(); ++it) {
    Investment* cm = Investment::dynacast(*it);
    if(cm)
      cm->special();
    else
      cout << "not an Investment" << endl;
  }
  cout << "cast from intermediate pointer:" << endl;
  Security* sp = new Metal;
  Investment* cp = Investment::dynacast(sp);
  if(cp) cout << "  it's an Investment" << endl;
  Metal* mp = Metal::dynacast(sp);
  if(mp) cout << "  it's a Metal too!" << endl;
  purge(portfolio);
} ///:∼

//: :purge.h
// Delete pointers in an STL sequence container
#ifndef PURGE_H
#define PURGE_H
#include <algorithm>
template<class Seq> void purge(Seq& c) {
typename Seq::iterator i;
for(i = c.begin(); i != c.end(); i++) {
delete *i;
*i = 0;
}
}
// Iterator version:
template<class InpIt>
void purge(InpIt begin, InpIt end) {
while(begin != end) {
delete *begin;
*begin = 0;
begin++;
}
}
#endif // PURGE_H ///:∼

多态函数isA()检查它的参数是否与其类型参数(id)兼容,这意味着要么id与对象的typeID完全匹配,要么它与对象的祖先之一*匹配(在这种情况下,*因此调用Super::isA())。在每个类中都是静态的dynacast()函数调用isA()作为其指针参数,以检查强制转换是否有效。如果isA()返回true,则转换有效,并返回一个适当转换的指针。否则,将返回空指针,这告诉调用方强制转换无效,意味着原始指针没有指向与所需类型兼容(可转换为)的对象。所有这些机制都是检查中间类型转换所必需的,比如从指向Metal对象的Security指针到清单 20-1 中的Investment指针。

对于大多数程序来说,向下转换是不必要的,实际上也是不鼓励的,因为日常的多态解决了面向对象应用程序中的大多数问题。但是,对于调试器、类浏览器和数据库之类的实用程序来说,检查向更派生类型的强制转换的能力非常重要。C++ 用dynamic_cast操作符提供了这种检查转换。清单 20-2 是使用dynamic_cast的前一个例子的重写。

清单 20-2 。使用 dynamic_cast 修改清单 20-1

//: C20:Security.h
#ifndef SECURITY_H
#define SECURITY_H
#include <iostream>

class Security {
public:
  virtualSecurity() {}
};

class Stock : public Security {};
class Bond : public Security {};
class Investment : public Security {
public:
  void special() {
    std::cout << "special Investment function” << std::endl;
  }
};
class Metal : public Investment {};
#endif                // SECURITY_H ///:∼

//: C20:CheckedCast2.cpp
// Uses RTTI's dynamic_cast.

#include <vector>
#include "../purge.h"
#include "Security.h" // To be INCLUDED from Header FILE above
using namespace std;

int main() {
  vector<Security*> portfolio;
  portfolio.push_back(new Metal);
  portfolio.push_back(new Investment);
  portfolio.push_back(new Bond);
  portfolio.push_back(new Stock);
  for(vector<Security*>::iterator it =
       portfolio.begin();
       it != portfolio.end(); ++it) {
    Investment* cm = dynamic_cast<Investment*>(*it);
    if(cm)
      cm->special();
    else
      cout << "not a Investment" << endl;
  }
  cout << "cast from intermediate pointer:” << endl;
  Security* sp = new Metal;
  Investment* cp = dynamic_cast<Investment*>(sp);
  if(cp) cout << "  it's an Investment” << endl;
  Metal* mp = dynamic_cast<Metal*>(sp);
  if(mp) cout << "  it's a Metal too!” << endl;
  purge(portfolio);
} ///:∼

这个例子要短得多,因为原始例子中的大部分代码只是检查类型转换的开销。dynamic_cast的目标类型放在尖括号中,就像其他新式 C++ 强制转换一样(static_cast等等),要强制转换的对象作为操作数出现。如果你想要安全的向下转换,dynamic_cast要求你使用的类型是多态的。这反过来要求该类必须至少有一个虚函数。幸运的是,Security基类有一个虚拟析构函数,所以我们不必发明一个额外的函数来完成这项工作。因为dynamic_cast使用虚拟表在运行时工作,所以它比其他新类型的类型转换更昂贵。

您也可以将dynamic_cast与引用一起使用,而不是指针,但是因为没有空引用这种东西,所以您需要另一种方法来知道强制转换是否失败。那个“其他办法”就是抓一个bad_cast异常 ,如清单 20-3 所示。

清单 20-3 。捕获 bad_cast 异常

//: C20:CatchBadCast.cpp
#include <typeinfo>
#include "Security.h"
using namespace std;

int main() {
  Metal m;
  Security& s = m;

  try {
    Investment& c = dynamic_cast<Investment&>(s);
    cout << "It's an Investment" << endl;
  } catch(bad_cast&) {
    cout << "s is not an Investment type" << endl;
  }

  try {
    Bond& b = dynamic_cast<Bond&>(s);
    cout << "It's a Bond" << endl;
  } catch(bad_cast&) {
    cout << "It's not a Bond type" << endl;
  }
} ///:∼

bad_cast类在<typeinfo>头文件中定义,并且像大多数标准 C++ 库一样,在std名称空间中声明。

typeid 运算符

获取对象运行时信息的另一种方法是通过typeid操作符。这个操作符返回一个类为type_info的对象,它产生关于它所应用的对象类型的信息。如果类型是多态的,它给出了关于最适用的派生类型(动态类型)的信息;否则,它会产生静态类型信息。typeid操作符的一个用途是获取一个对象的动态类型名作为一个const char*,正如你在清单 20-4 中看到的。

清单 20-4 。阐释 typeid 运算符的用法

//: C20:TypeInfo.cpp
// Illustrates the typeid operator.
#include <iostream>
#include <typeinfo>
using namespace std;

struct PolyBase { virtualPolyBase() {} };
struct PolyDer : PolyBase { PolyDer() {} };
struct NonPolyBase {};
struct NonPolyDer : NonPolyBase { NonPolyDer(int) {} };

int main() {
  // Test polymorphic Types
  const PolyDerpd;
  const PolyBase* ppb = &pd;

  cout << typeid(ppb).name() << endl;
  cout << typeid(*ppb).name() << endl;
  cout << boolalpha << (typeid(*ppb) == typeid(pd))
       << endl;
  cout << (typeid(PolyDer) == typeid(const PolyDer))
       << endl;

  // Test non-polymorphic Types
  const NonPolyDernpd(1);
  const NonPolyBase* nppb = &npd;

  cout << typeid(nppb).name() << endl;
  cout << typeid(*nppb).name() << endl;
  cout << (typeid(*nppb) == typeid(npd)) << endl;

  // Test a built-in type
  int i;
  cout << typeid(i).name() << endl;
} ///:∼

这个程序使用一个特定的编译器的输出是

struct PolyBase const *
struct PolyDer
true
true
struct NonPolyBase const *
struct NonPolyBase
false
int

第一个输出行只是回显了ppb的静态类型,因为它是指针。要让 RTTI 开始工作,您需要查看指针或引用目标对象,如第二行所示。注意,RTTI 忽略了顶级的constvolatile限定符。对于非多态类型,您只能获得静态类型(指针本身的类型)。如您所见,内置类型也受支持。

原来你不能在一个type_info对象中存储一个typeid操作的结果,因为没有可访问的构造器,赋值是不允许的。你必须像我们展示的那样使用它。此外,type_info::name()返回的实际字符串是依赖于编译器的。例如,对于一个名为C的类,一些编译器返回“C 类”而不仅仅是“C”,将typeid应用到一个解引用空指针的表达式将导致抛出bad_typeid异常(也在<typeinfo>中定义)。

清单 20-5 显示了type_info::name()返回的类名是完全限定的。

清单 20-5 。说明了 RTTI 和嵌套和

//: C20:RTTIandNesting.cpp
#include <iostream>
#include <typeinfo>
using namespace std;

class One {
  class Nested {};
  Nested* n;
public:
  One() : n(new Nested) {}
  ∼One() { delete n; }
  Nested* nested() { return n; }
};

int main() {
  One o;
  cout << typeid(*o.nested()).name() << endl;
} ///:∼

因为NestedOne类的成员类型,所以结果是One::Nested

您还可以使用before(type_info&)询问一个type_info对象是否在实现定义的“排序序列”(文本的本地排序规则)中位于另一个type_info对象之前,这将返回truefalse。当你说,

if(typeid(me).before(typeid(you))) // ...

您在询问在当前的排序序列中,me是否出现在you之前。如果你使用type_info对象作为关键点,这很有用。

铸造到中级水平

正如您在使用了Security类的层次结构的清单 20-2 中所看到的,dynamic_cast可以检测精确类型,并且在具有多个层次的继承层次结构中,可以检测中间类型。清单 20-6 是另一个例子。

清单 20-6 。说明了中间铸造

//: C20:IntermediateCast.cpp
#include <cassert>
#include <typeinfo>
using namespace std;

class B1 {
public:
  virtualB1() {}
};

class B2 {
public:
  virtualB2() {}
};

class MI : public B1, public B2 {};
class Mi2 : public MI {};

int main() {
  B2* b2 = new Mi2;
  Mi2* mi2 = dynamic_cast<Mi2*>(b2);
  MI* mi = dynamic_cast<MI*>(b2);
  B1* b1 = dynamic_cast<B1*>(b2);
  assert(typeid(b2) != typeid(Mi2*));
  assert(typeid(b2) == typeid(B2*));
  delete b2;
} ///:∼

注意下面三行代码

Mi2* mi2 = dynamic_cast<Mi2*>(b2);
MI* mi = dynamic_cast<MI*>(b2);
B1* b1 = dynamic_cast<B1*>(b2);

可能会导致编译器(如 XCode)发出“未使用的变量”警告,但此示例的目的只是为了说明 dynamic_cast 既可以检测精确类型,也可以在多级继承层次结构中检测中间类型。

这个例子有额外的复杂性多重继承(你将在本章后面了解更多关于多重继承的知识)。如果您创建一个Mi2并将其向上转换到根(在这种情况下,选择两个可能的根中的一个),那么dynamic_cast返回到派生级别MIMi2是成功的。

您甚至可以从一个根转换到另一个根,如:

B1* b1 = dynamic_cast<B1*>(b2);

这是成功的,因为B2实际上是指向一个Mi2对象,它包含一个B1类型的子对象。

铸造到中级带来了一个有趣的差异dynamic_casttypeidtypeid操作符总是产生一个静态type_info对象的引用,描述对象的动态类型。因此,它不能给你中级水平的信息。在下面的表达式(也就是true)中,typeid不像dynamic_cast那样将b2视为指向派生类型的指针:

typeid(b2) != typeid(Mi2*)

b2 的类型就是指针的确切类型,如:

typeid(b2) == typeid(B2*)

虚空指针

RTTI 只适用于完整的类型,这意味着当使用typeid时,所有的类信息都必须可用。特别是,它不能与void指针一起工作,正如你在清单 20-7 中看到的。

清单 20-7 。阐释了 RTTI 指针和空指针

//: C20:VoidRTTI.cpp
// RTTI & void pointers.
//!#include <iostream>
#include <typeinfo>
using namespace std;

classStimpy {
public:
  virtual void happy() {}
  virtual void joy() {}
  virtualStimpy() {}
};

 int main() {
  void* v = new Stimpy;

  // Error:
//!  Stimpy* s = dynamic_cast<Stimpy*>(v);
  // Error:
//!  cout<<typeid(*v).name() <<endl;
} ///:∼

真正的意思是“没有类型信息”

使用带有模板的 RTTI

类模板与 RTTI 配合得很好,因为它们所做的只是生成类。像往常一样,RTTI 提供了一种便捷的方式来获取您所在的类的名称。清单 20-8 打印构造器和析构函数调用的顺序。

清单 20-8 。打印构造器/析构函数调用的顺序

//: C20:ConstructorOrder.cpp
// Order of constructor calls.
#include <iostream>
#include <typeinfo>
using namespace std;

template<int id> class Announce {
public:
  Announce() {
    cout << typeid(*this).name() << " constructor" << endl;
  }
  ∼Announce() {
    cout << typeid(*this).name() << " destructor" << endl;
  }
};

class X : public Announce<0> {
  Announce<1> m1;
  Announce<2> m2;
public:
  X() { cout << "X::X()" << endl; }
  ∼X() { cout << "X::∼X()" << endl; }
};

int main() { X x; } ///:∼

这个模板使用一个常量int来区分不同的类,但是类型参数也可以。在构造器和析构函数中,RTTI 信息产生要打印的类名。类X使用继承和组合来创建一个类,这个类有一个有趣的构造器和析构函数调用顺序。

输出是

Announce<0> constructor
Announce<1> constructor
Announce<2> constructor
X::X()
X::∼X()
Announce<2> destructor
Announce<1> destructor
Announce<0> destructor

当然,根据编译器如何表示其name()信息,您可能会得到不同的输出。

多重继承

RTTI 机制必须与多重继承的所有复杂性一起正常工作,包括virtual基类,如清单 20-9 所示(在下一章将深入讨论——你可能想在阅读完第二十一章后回到这里)。

清单 20-9 。说明了 RTTI 和多重继承

//: C20:RTTIandMultipleInheritance.cpp
#include <iostream>
#include <typeinfo>
using namespace std;

class BB {
public:
  virtual void f() {}
  virtualBB() {}
};

class B1 : virtual public BB {};
class B2 : virtual public BB {};

class MI : public B1, public B2 {};

int main() {
  BB* bbp = new MI;      // Upcast
  // Proper name detection:
  cout << typeid(*bbp).name() << endl;
  // Dynamic_cast works properly:
  MI* mip = dynamic_cast<MI*>(bbp);
  // Can't force old-style cast:
//! MI* mip2 = (MI*)bbp; // Compile error
} ///:∼

typeid()操作符正确地检测实际对象的名称,甚至通过virtual基类指针。dynamic_cast也能正常工作。但是编译器甚至不允许你尝试用老方法强制转换,比如:

MI* mip = (MI*)bbp; // Compile-time error

编译器知道这从来都不是正确的做法,所以它要求您使用dynamic_cast

RTTI 的合理使用

因为您可以从匿名多态指针中发现类型信息,所以 RTTI 很容易被新手误用,因为 RTTI 可能比虚函数更有意义。对于许多来自程序背景的人来说,很难不将程序组织成一组switch语句。他们可以用 RTTI 来实现这一点,从而失去了多态在代码开发和维护中的重要价值。C++ 的意图是在代码中使用虚函数,并且只在必要时使用 RTTI。

然而,使用虚函数需要您控制基类定义,因为在程序扩展的某个时候,您可能会发现基类不包含您需要的虚函数。如果基类来自一个库或者被其他人控制,这个问题的一个解决方案是 RTTI;你可以派生一个新的类型并添加额外的成员函数。在代码的其他地方,您可以检测您的特定类型并调用该成员函数。这不会破坏程序的多态和可扩展性,因为添加一个新类型不需要您寻找 switch 语句。然而,当您在主体中添加需要您的新特性的新代码时,您必须检测您的特定类型。

将一个特性放在基类中可能意味着,为了一个特定类的利益,从该基类派生的所有其他类都需要一些无意义的存根来实现一个纯虚函数。这使得界面不太清晰,并且惹恼了那些必须在从基类派生纯虚函数时重写它们的人。

最后,RTTI 有时会解决效率问题。如果您的代码以一种很好的方式使用了多态,但结果是您的一个对象以一种非常低效的方式对这个通用代码做出反应,您可以使用 RTTI 挑选出那个类型,并编写特定于案例的代码来提高效率。

一个垃圾回收商

为了进一步说明 RTTI 、的实际使用,清单 20-10 模拟了一个垃圾回收器。不同种类的“垃圾”被放入一个容器中,然后根据它们的动态类型进行分类。

清单 20-10 。模拟垃圾回收器

//: C20:Trash.h
// Describing trash.
#ifndef TRASH_H
#define TRASH_H
#include <iostream>

class Trash {
  float _weight;
public:
  Trash(float wt) : _weight(wt) {}
  virtual float value() const = 0;
  float weight() const { return _weight; }
  virtualTrash() {
    std::cout << "∼Trash()" << std::endl;
  }
};

class Aluminum : public Trash {
  static float val;
public:
  Aluminum(float wt) : Trash(wt) {}
  float value() const { return val; }
  static void value(float newval) {
    val = newval;
  }
};

class Paper : public Trash {
  static float val;
public:
  Paper(float wt) : Trash(wt) {}
  float value() const { return val; }
  static void value(float newval) {
    val = newval;
  }
};

class Glass : public Trash {
  static float val;
public:
  Glass(float wt) : Trash(wt) {}
  float value() const { return val; }
  static void value(float newval) {
    val = newval;
  }
};
#endif // TRASH_H ///:∼

代表垃圾类型的单位价格的static值在实现文件中定义(清单 20-11 )。

清单 20-11 。实现清单 20-10 (Trash.h)中的头文件

//: C20:Trash.cpp {O}
// A Trash Recycler.
#include "Trash.h"    // To be INCLUDED from Header FILE above

float Aluminum::val = 1.67;
float Paper::val = 0.10;
float Glass::val = 0.23;
///:∼

sumValue()模板遍历一个容器,显示和计算结果,如清单 20-12 所示。

清单 20-12 。使用 sumValue()模板演示回收

//: C20:Recycle.cpp
//{L} Trash
// A Trash Recycler.

#include <cstdlib>
#include <ctime>
#include <iostream>
#include <typeinfo>
#include <vector>
#include "Trash.h"
#include "../purge.h"
using namespace std;

// Sums up the value of the Trash in a bin:
template<class Container>
void sumValue(Container& bin, ostream&os) {
  typename Container::iterator tally = bin.begin();
  floatval = 0;
  while(tally != bin.end()) {
    val += (*tally)->weight() * (*tally)->value();
    os << "weight of " << typeid(**tally).name()
       << " = " << (*tally)->weight() << endl;
    ++tally;
  }
  os << "Total value = " << val << endl;
}

int main() {
  srand(time(0)); // Seed the random number generator
  vector<Trash*> bin;

  // Fill up the Trash bin:
  for(int i = 0; i < 30; i++)
    switch(rand() % 3) {
      case 0 :
        bin.push_back(new Aluminum((rand() % 1000)/10.0));
        break;
      case 1 :
        bin.push_back(new Paper((rand() % 1000)/10.0));
        break;
      case 2 :
        bin.push_back(new Glass((rand() % 1000)/10.0));
        break;
    }

  // Note: bins hold exact type of object, not base type:
  vector<Glass*> glassBin;
  vector<Paper*> paperBin;
  vector<Aluminum*> alumBin;
  vector<Trash*>::iterator sorter = bin.begin();

  // Sort the Trash:
  while(sorter != bin.end()) {
    Aluminum* ap = dynamic_cast<Aluminum*>(*sorter);
    Paper* pp = dynamic_cast<Paper*>(*sorter);
    Glass* gp = dynamic_cast<Glass*>(*sorter);
    if(ap) alumBin.push_back(ap);
    else if(pp) paperBin.push_back(pp);
    else if(gp) glassBin.push_back(gp);
    ++sorter;
  }
  sumValue(alumBin, cout);
  sumValue(paperBin, cout);
  sumValue(glassBin, cout);
  sumValue(bin, cout);
  purge(bin);
} ///:∼

垃圾被不加分类地扔进一个垃圾箱,因此具体的类型信息“丢失”但是后来必须恢复特定的类型信息来正确地对垃圾进行分类,因此使用了 RTTI。

您可以通过使用将指向type_info对象的指针与Trash指针的vector相关联的map来改进这个解决方案。因为一个map需要一个排序谓词,所以您提供一个名为TInfoLess的谓词来调用type_info::before()。当您将Trash指针插入地图时,它们会自动与它们的type_info键相关联。注意sumValue()在清单 20-13 中必须有不同的定义。

清单 20-13 。使用地图说明回收

//: C20:Recycle2.cpp
//{L} Trash
// Recyling with a map.
#include <cstdlib>
#include <ctime>
#include <iostream>
#include <map>
#include <typeinfo>
#include <utility>
#include <vector>
#include "Trash.h"
#include "../purge.h"
using namespace std;

// Comparator for type_info pointers
struct TInfoLess {
  bool operator()(const type_info* t1, const type_info* t2)
  const { return t1->before(*t2); }
};

typedef map<const type_info*, vector<Trash*>, TInfoLess>
  TrashMap;

// Sums up the value of the Trash in a bin:
void sumValue(const TrashMap::value_type& p, ostream& os) {
  vector<Trash*>::const_iterator tally = p.second.begin();
  float val = 0;

  while(tally != p.second.end()) {
    val += (*tally)->weight() * (*tally)->value();
    os << "weight of "
       << p.first->name()  // type_info::name()
       << " = " << (*tally)->weight() << endl;
    ++tally;
  }
  os << "Total value = " << val << endl;
}

int main() {
  srand(time(0));          // Seed the random number generator
  TrashMap bin;

  // Fill up the Trash bin:
  for(int i = 0; i < 30; i++) {
    Trash* tp;
    switch(rand() % 3) {
      case 0 :
        tp = new Aluminum((rand() % 1000)/10.0);
        break;
      case 1 :
        tp = new Paper((rand() % 1000)/10.0);
        break;
      case 2 :
        tp = new Glass((rand() % 1000)/10.0);
        break;
    }
    bin[&typeid(*tp)].push_back(tp);
  }

  // Print sorted results
  for(TrashMap::iterator p = bin.begin();
      p != bin.end(); ++p) {
    sumValue(*p, cout);
    purge(p->second);
  }
} ///:∼

您已经修改了sumValue()来直接调用type_info::name(),因为type_info对象现在可以作为TrashMap::value_type对的第一个成员。这避免了额外调用typeid来获取正在处理的Trash类型的名称,这在清单 20-12 中是必要的。

RTTI 的机制和开销

通常,RTTI 是通过在类的虚函数表中放置一个额外的指针来实现的。这个指针指向那个特定类型的type_info结构。

一个typeid()表达式的效果非常简单:虚函数表指针获取type_info指针,并产生一个对结果type_info结构的引用。因为这只是一个双指针解引用操作,所以它是一个常量时间操作。

对于一个dynamic_cast<destination*>(source_pointer),大多数情况非常简单:source_pointer的 RTTI 信息被检索,类型destination*的 RTTI 信息被获取。

然后,一个库例程确定source_pointer的类型是属于类型destination*还是属于destination*的基类。如果基类不是派生类的第一个基类,它返回的指针可能会因为多重继承而被调整。多重继承的情况更复杂,因为一个基类可能在继承层次结构中出现不止一次,并且使用了虚拟基类。

因为用于dynamic_cast的库例程必须检查一个基类列表,所以dynamic_cast的开销可能比typeid()高(但是你会得到不同的信息,这些信息对你的解决方案可能是必不可少的),而且发现一个基类可能比发现一个派生类花费更多的时间。

此外,dynamic_cast将任何类型与任何其他类型进行比较;您并不局限于比较同一层次结构中的类型。这给dynamic_cast使用的库例程增加了额外的开销。

审查会议

  1. 尽管通常情况下你会将一个指针向上指向一个基类,然后使用该基类的通用接口(通过虚函数),但有时你会陷入一个困境,如果你知道一个基类指针所指向的对象的动态类型,事情会变得更有效,这就是 RTTI 所提供的。
  2. 最常见的误用可能来自不懂虚函数的程序员,用 RTTI 来做类型检查编码,而不是
  3. C++ 的哲学似乎是为你提供强大的工具,保护类型违反和完整性,但是如果你想故意误用或避开语言特性,没有什么可以阻止你。在这种情况下,值得一提的是,有时轻微烧伤是获得有用经验的最快方式

二十一、多重继承

多重继承(MI)的基本概念听起来很简单:通过从多个基类继承来创建一个新类型。语法正是您所期望的,只要继承图简单,MI 也可以很简单。

然而,MI 可以引入许多歧义和奇怪的情况,这将在本章中讨论。但首先,对这个问题有一些看法是有帮助的。

远景

在 C++ 之前,最成功的面向对象语言是 Smalltalk。Smalltalk 是作为一种面向对象的语言从头开始创建的。它通常被称为纯语言,而 C++ 被称为混合语言,因为它支持多种编程范例,而不仅仅是面向对象的范例。Smalltalk 的一个设计决策是所有的类都在一个单一的层次结构中派生,以一个单一的基类为根(称为Object——这是基于对象的层次结构的模型)。在 Smalltalk 中,如果不从现有的类派生,就无法创建新的类,这就是为什么在 Smalltalk 中需要一定的时间才能变得高效:在开始创建新的类之前,您必须学习类库。因此,Smalltalk 类层次结构是一个单一的整体树。

Smalltalk 中的类通常有许多共同点,它们总是有一些的共同点(Object的特征和行为),所以你不会经常遇到需要从多个基类继承的情况。然而,使用 C++ 你可以创建任意多的不同继承树 。所以为了逻辑完整性,语言必须能够一次组合多个类——因此需要多重继承。

然而,程序员需要多重继承这一点并不明显,对于它在 C++ 中是否必不可少还存在很多争议。MI 于 1989 年被添加到美国电话电报公司 2.0 版本中,是该语言相对于 1.0 版本的第一个重大变化。从那以后,标准 C++ 中加入了许多其他特性(尤其是模板),这些特性改变了我们对编程的看法,并将 MI 置于一个不那么重要的位置。您可以将 MI 视为一个“次要”的语言特性,很少涉及到您的日常设计决策。

MI 最迫切的论点之一涉及容器 。假设您想要创建一个每个人都可以轻松使用的容器。一种方法是使用void*作为容器内部的类型。然而,Smalltalk 的方法是创建一个保存Object的容器,因为Object是 Smalltalk 层次结构的基本类型。因为 Smalltalk 中的一切最终都是从Object中派生出来的,一个容纳Object s 的容器可以容纳任何东西。

现在考虑一下 C++ 中的情况。假设供应商A创建了一个基于对象的层次结构,其中包含一组有用的容器,包括您想要使用的名为Holder的容器。接下来,您会遇到 vendor B的类层次结构,其中包含一些对您来说很重要的其他类,例如保存图形图像的BitImage类。制作BitImage s 的Holder的唯一方法是从两个Object派生一个新类,这样它就可以保存在HolderBitImage中,如图图 21-1 所示。

9781430260943_Fig21-01.jpg

图 21-1 。一个说明 MI 需要创建一个对象容器的例子。要成为位图像的持有者,您需要 MI

这被认为是 MI 的一个重要原因,许多类库都是基于这个模型构建的。您可能需要 MI 的另一个原因与设计有关。您可以有意地使用 MI 来使设计更加灵活或有用(或者至少看起来如此)。这样的例子在最初的iostream库设计 中图 21-2 (在今天的模板设计中依然坚持)。

9781430260943_Fig21-02.jpg

图 21-2 。有意使用 MI 使 iostream 设计更加灵活和有用

istreamostream本身都是有用的类,但是它们也可以由一个结合了它们的特征和行为的类同时派生出来。类ios提供了所有流类共有的东西,因此在这种情况下,MI 是一个代码分解机制 。

不管你使用 MI *,*的动机是什么,它比看起来更难使用。

接口继承

多重继承的一个没有争议的用途与接口继承有关。在 C++ 中,所有的继承都是实现继承,因为基类中的一切,接口和实现,都变成了派生类的一部分。不可能只继承类的一部分(比如说,只继承接口)。正如第十四章所解释的,privateprotected继承 使得当被一个派生类对象的客户使用时,限制对从基类继承的成员的访问成为可能,但是这并不影响派生类;它仍然包含所有基类数据,并且可以访问所有非private基类成员。

另一方面,接口继承只是将成员函数声明添加到一个派生类接口中,在 C++ 中不被直接支持。在 C++ 中模拟接口继承的常用技术是从一个接口类 派生而来,这个类只包含声明(没有数据或函数体)。这些声明将是纯虚函数,除了析构函数。清单 21-1 包含了一个例子。

清单 21-1 。阐释多接口继承

//: C21:Interfaces.cpp
// Multiple interface inheritance.
#include <iostream>
#include <sstream>
#include <string>
using namespace std;

class Printable {
public:
  virtualPrintable() {}
  virtual void print(ostream&) const = 0;
};

class Intable {
public:
  virtualIntable() {}
  virtual int toInt() const = 0;
};

class Stringable {
public:
  virtualStringable() {}
  virtual string toString() const = 0;
};

class Able : public Printable, public Intable,
             public Stringable {
  int myData;
public:
  Able(int x) { myData = x; }
  void print(ostream& os) const { os << myData; }
  int toInt() const { return myData; }
  string toString() const {
    ostringstream os;
    os << myData;
    return os.str();
  }
};

void testPrintable(const Printable& p) {
  p.print(cout);
  cout << endl;
}

void testIntable(const Intable& n) {
  cout << n.toInt() + 1 << endl;
}

void testStringable(const Stringable& s) {
  cout << s.toString() + "th" << endl;
}

int main() {
  Able a(7);
  testPrintable(a);
  testIntable(a);
  testStringable(a);
} ///:∼

Able“实现”接口PrintableIntableStringable,因为它为它们声明的函数提供了实现。因为Able从所有三个类派生而来,Able对象有多个 is-a 关系。例如,对象a可以作为一个Printable对象,因为它的类Able公开地从Printable派生,并为print()提供了一个实现。测试函数不需要知道它们的参数的最派生类型;他们只需要一个可以替代其参数类型的对象。

通常,模板解决方案更简洁;参见清单 21-2 。

清单 21-2 。说明隐式接口继承(使用模板)

//: C21:Interfaces2.cpp
// Implicit interface inheritance via templates.
#include <iostream>
#include <sstream>
#include <string>
using namespace std;

class Able {
  int myData;
public:
  Able(int x) { myData = x; }
  void print(ostream& os) const { os << myData; }
  int toInt() const { return myData; }
  string toString() const {
    ostringstream os;
    os << myData;
    return os.str();
  }
};
template<class Printable>
void testPrintable(const Printable& p) {
  p.print(cout);
  cout << endl;
}

template<class Intable>
void testIntable(const Intable& n) {
  cout << n.toInt() + 1 << endl;
}
template<class Stringable>
void testStringable(const Stringable& s) {
  cout << s.toString() + "th" << endl;
}
int main() {
  Able a(7);
  testPrintable(a);
  testIntable(a);
  testStringable(a);
} ///:∼

名字PrintableIntableStringable现在只是模板参数,假设在它们各自的上下文中指示的操作的存在。换句话说,测试函数可以接受任何类型的参数,只要这些参数能够为成员函数定义提供正确的签名和返回类型;不必从公共基类派生。有些人更喜欢第一个版本,因为类型名通过继承保证了预期接口的实现。其他人满足于这样一个事实,即如果测试函数所要求的操作不能被它们的模板类型参数所满足,错误仍然会在编译时被捕获。后一种方法在技术上是一种比前一种(继承)方法“更弱”的类型检查形式,但是对程序员(和程序)的影响是相同的。这是当今许多 C++ 程序员可以接受的弱类型的一种形式。

实现继承

如前所述,C++ 只提供实现继承,这意味着你总是从你的基类继承所有的东西。这可能很好,因为它使您不必实现派生类中的所有内容,如前面的接口继承示例所示。多重继承的一个常见用法是使用 mixin 类 ,这些类通过继承向其他类添加功能。Mixin 类不打算自己实例化。

举个例子,假设你是一个支持数据库访问的类的客户。在这种情况下,您只有一个头文件可用——部分原因是您无法访问实现的源代码。为了便于说明,假设清单 21-3 中所示的Database类 的实现。

清单 21-3 。实现数据库类

//: C21:Database.h
// A prototypical resource class.
#ifndef DATABASE_H
#define DATABASE_H
#include <iostream>
#include <stdexcept>
#include <string>

struct DatabaseError : std::runtime_error {
  DatabaseError(const std::string& msg)
    : std::runtime_error(msg) {}
};

class Database {
  std::string dbid;
public:
  Database(const std::string& dbStr) : dbid(dbStr) {}
  virtualDatabase() {}
  void open() throw(DatabaseError) {
    std::cout << "Connected to " << dbid << std::endl;
  }
  void close() {
    std::cout << dbid << " closed" << std::endl;
  }
  // Other database functions...
};
#endif // DATABASE_H ///:∼

/我们省略了实际的数据库功能(存储、检索等等),但这在这里并不重要。使用这个类需要一个数据库连接字符串,调用Database::open()来连接,调用Database::close()来断开:/

//: C21:UseDatabase.cpp
#include "Database.h" // To be INCLUDED from Header FILE
// above
int main() {
  Database db("MyDatabase");
  db.open();
  // Use other db functions...
  db.close();
}
/* Output:
connected to MyDatabase
MyDatabase closed
*/ ///:∼

在典型的客户机-服务器情况下,一个客户机将有多个对象共享一个到数据库的连接。重要的是,数据库最终要关闭,但只有在不再需要访问它之后。通常通过一个类来封装这种行为,该类跟踪使用数据库连接的客户端实体的数量,并在该数量变为零时自动终止连接。要将引用计数添加到Database类中,可以使用多重继承将名为Countable 的类混合到Database类中,以创建一个新类DBConnection。清单 21-4 包含了Countable mixin 类。

清单 21-4 。说明可数的“mixin”类

//: C21:Countable.h
// A "mixin" class.
#ifndef COUNTABLE_H
#define COUNTABLE_H
#include <cassert>

class Countable {
  long count;
protected:
  Countable() { count = 0; }
  virtualCountable() { assert(count == 0); }
public:
  long attach() { return ++count; }
  long detach() {
    return (--count > 0) ? count : (delete this, 0);
  }
  long refCount() const { return count; }
};
#endif // COUNTABLE_H ///:∼

很明显,这不是一个独立的类,因为它的构造器是protected;它需要一个朋友或一个派生类来使用。析构函数必须是虚拟的,这一点很重要,因为它只能从detach()中的delete this语句中调用,并且您希望派生的对象被正确地销毁。

DBConnection类继承了DatabaseCountable,并提供了一个静态的create()函数来初始化它的Countable子对象;参见清单 21-5 。

清单 21-5 。使用可数的“mixin”类

//: C21:DBConnection.h
// Uses a "mixin" class.
#ifndef DBCONNECTION_H
#define DBCONNECTION_H
#include <cassert>
#include <string>
#include "Countable.h"               // To be INCLUDED from Header FILE
                                     // above
#include "Database.h"
using std::string;

class DBConnection : public Database, public Countable {
  DBConnection(const DBConnection&); // Disallow copy
  DBConnection& operator=(const DBConnection&);
protected:
  DBConnection(const string& dbStr) throw(DatabaseError)
  : Database(dbStr) { open(); }
  ∼DBConnection() { close(); }
public:
  static DBConnection*
  create(const string& dbStr) throw(DatabaseError) {
    DBConnection* con = new DBConnection(dbStr);
    con->attach();
    assert(con->refCount() == 1);
    return con;
  }
  // Other added functionality as desired...
};
#endif                               // DBCONNECTION_H ///:∼

您现在有了一个引用计数的数据库连接,而无需修改Database类,并且您可以放心地假设它不会被秘密终止。打开和关闭是通过DBConnection构造器和析构函数使用资源获取初始化(RAII)习惯用法来完成的。这使得DBConnection易于使用,如清单 21-6 中的所示。

清单 21-6 。测试出可数的“mixin”类

//: C21:UseDatabase2.cpp
// Tests the Countable "mixin" class.
#include <cassert>
#include "DBConnection.h"   // To be INCLUDED from Header FILE
                            // above
class DBClient {
  DBConnection* db;
public:
  DBClient(DBConnection* dbCon) {
    db = dbCon;
    db->attach();
  }
  ∼DBClient() { db->detach(); }
  // Other database requests using db...
};
int main() {
  DBConnection* db = DBConnection::create("MyDatabase");
  assert(db->refCount() == 1);
  DBClient c1(db);
  assert(db->refCount() == 2);
  DBClient c2(db);
  assert(db->refCount() == 3);
  // Use database, then release attach from original create
  db->detach();
  assert(db->refCount() == 2);
} ///:∼

DBConnection::create()的调用调用attach(),所以当你完成时,你必须显式地调用detach()来释放最初对连接的保持。注意,DBClient类也使用 RAII 来管理它对连接的使用。当程序终止时,两个DBClient对象的析构函数将减少引用计数(通过调用detach(),它从Countable继承而来),当对象c1被销毁后计数达到零时,数据库连接将被关闭(因为Countable的虚拟析构函数)。

模板方法通常用于 mixin 继承,允许用户在编译时指定想要哪种风格的 mixin。这样,您可以使用不同的引用计数方法,而不用显式地定义两次DBConnection。清单 21-7 展示了它是如何完成的。

清单 21-7 。说明一个参数化的“mixin”类(使用模板)

//: C21:DBConnection2.h
// A parameterized mixin.
#ifndef DBCONNECTION2_H
#define DBCONNECTION2_H
#include <cassert>
#include <string>
#include "Database.h"
using std::string;

template<class Counter>
class DBConnection : public Database, public Counter {
  DBConnection(const DBConnection&); // Disallow copy
  DBConnection& operator=(const DBConnection&);
protected:
  DBConnection(const string& dbStr) throw(DatabaseError)
  : Database(dbStr) { open(); }
  ∼DBConnection() { close(); }
public:
  static DBConnection* create(const string& dbStr)
  throw(DatabaseError) {
    DBConnection* con = new DBConnection(dbStr);
    con->attach();
    assert(con->refCount() == 1);
    return con;
  }
  // Other added functionality as desired...
};
#endif                               // DBCONNECTION2_H ///:∼

这里唯一的变化是类定义的模板前缀(为了清楚起见,将Countable重命名为Counter)。您还可以将数据库类作为模板参数(如果您有多个数据库访问类可供选择),但它不是 mixin,因为它是一个独立的类。清单 21-8 使用最初的Countable作为Counter mixin 类型,但是你可以使用任何实现适当接口的类型(attach()detach()等等)。

清单 21-8 。测试参数化的“mixin”类

//: C21:UseDatabase3.cpp
// Tests a parameterized "mixin" class.
#include <cassert>
#include "Countable.h"
#include "DBConnection2.h"           // To be INCLUDED from Header FILE
                                     // above
class DBClient {
  DBConnection<Countable>* db;

public:
  DBClient(DBConnection<Countable>* dbCon) {
    db = dbCon;

    db->attach();
  }

  ∼DBClient() { db->detach(); }
};

int main() {
  DBConnection<Countable>* db =
    DBConnection<Countable>::create("MyDatabase");

  assert(db->refCount() == 1);
  DBClient c1(db);

  assert(db->refCount() == 2);
  DBClient c2(db);

  assert(db->refCount() == 3);
  db->detach();

  assert(db->refCount() == 2);
} ///:∼

多参数混合的一般模式很简单。

template<class Mixin1, class Mixin2, ... , class MixinK>
class Subject : public Mixin1,
                public Mixin2,
                ...
                publicMixinK {...};

重复子对象

当从基类继承时,您会在派生类中获得该基类的所有数据成员的副本。清单 21-9 显示了如何在内存中布置多个基础子对象。

清单 21-9 。使用 MI 演示子对象的布局

//: C21:Offset.cpp
// Illustrates layout of subobjects with MI.
#include <iostream>
using namespace std;

class A { int x; };
class B { int y; };
class C : public A, public B { int z; };

int main() {
  cout << "sizeof(A) == " << sizeof(A) << endl;
  cout << "sizeof(B) == " << sizeof(B) << endl;
  cout << "sizeof(C) == " << sizeof(C) << endl;

  C c;
  cout << "&c == " << &c << endl;

  A* ap = &c;
  B* bp = &c;

  cout << "ap == " << static_cast<void*>(ap) << endl;
  cout << "bp == " << static_cast<void*>(bp) << endl;

  C* cp = static_cast<C*>(bp);
  cout << "cp == " << static_cast<void*>(cp) << endl;
  cout << "bp == cp? " << boolalpha << (bp == cp) << endl;

  cp = 0;
  bp = cp;

  cout << bp << endl;
} ///:∼

/* 输出:

sizeof(A) == 4
sizeof(B) == 4
sizeof(C) == 12
&c == 1245052
ap == 1245052
bp == 1245056
cp == 1245052
bp == cp? true
0
*/

正如你所看到的,对象CB部分从整个对象的开始处偏移了 4 个字节,这暗示了图 21-3 中的布局。

9781430260943_Fig21-03.jpg

图 21-3 。输出数据的布局

对象C从它的A子对象开始,然后是B部分,最后是来自完整类型C本身的数据。因为一个C 是-an A是-a B,所以可以向上造型为任何一种基类。当向上转换到一个A时,产生的指针指向A部分,恰好在C对象的开头,所以地址ap与表达式&c相同。然而,当向上转换到B时,结果指针必须指向B子对象实际驻留的位置,因为类B对类C(或类A)一无所知。换句话说,bp指向的对象必须能够表现为一个独立的B对象(除了任何必需的多态行为)。

当将bp造型回C*时,由于原始对象首先是一个C,因此B子对象所在的位置是已知的,因此指针被调整回完整对象的原始地址。如果bp一开始就指向一个独立的B对象,而不是一个C对象,那么强制转换就是非法的。此外,在比较bp == cp中,cp被隐式转换为B*,因为这是使比较有意义的唯一方式(也就是说,总是允许向上转换),因此产生了true结果。因此,当在子对象和完整类型之间来回转换时,会应用适当的偏移量。

显然,空指针需要特殊处理,因为如果指针从零开始,在转换到B子对象或从B子对象转换时盲目减去偏移量将导致无效地址。由于这个原因,当转换到一个B*或从一个B*转换时,编译器生成逻辑首先检查指针是否为零。如果不是,它应用偏移量;否则,它将其保留为零。

用你目前看到的语法,如果你有多个基类,并且这些基类又有一个公共基类,你将有两个顶级基类的副本,如你在清单 21-10 中看到的。

清单 21-10 。演示重复的子对象

//: C21:Duplicate.cpp
// Shows duplicate subobjects.
#include <iostream>
using namespace std;

class Top {
  int x;
public:
  Top(int n) { x = n; }
};

class Left : public Top {
  int y;
public:
  Left(int m, int n) : Top(m) { y = n; }
};

class Right : public Top {
  int z;
public:
  Right(int m, int n) : Top(m) { z = n; }
};

class Bottom : public Left, public Right {
  int w;
public:
  Bottom(int i, int j, int k, int m)
  : Left(i, k), Right(j, k) { w = m; }
};

int main() {
  Bottom b(1, 2, 3, 4);
  cout << sizeof b << endl; // 20
} ///:∼

由于b的大小是 20 字节,所以在一个完整的Bottom对象中总共有五个整数。这个场景的典型类图 ?? 如图图 21-4 所示。

9781430260943_Fig21-04.jpg

图 21-4 。菱形继承场景的类图

这就是所谓的“钻石继承”,但在这种情况下,它会更好地呈现为图 21-5 。

9781430260943_Fig21-05.jpg

图 21-5 。相同场景下更好的类图

这种设计的笨拙表现在前面代码中的Bottom类的构造器中。用户认为只需要四个整数,但是LeftRight需要的两个参数应该传递哪些实参呢?尽管这种设计本质上并不是“错误的”,但它通常不是应用程序所需要的。当试图将指向Bottom对象的指针转换成指向Top的指针时,也会出现问题。如前所示,地址可能需要调整,这取决于子对象在完整对象中的位置,但这里有两个*Top子对象可供选择。编译器不知道选择哪个,所以这样的向上转换是不明确的,也是不允许的。同样的推理解释了为什么一个Bottom对象不能调用一个只在Top中定义的函数。如果这样的函数Top::f()存在,调用b.f()将需要引用一个Top子对象作为执行上下文,有两个可供选择。*

*虚拟基础类

在这种情况下,你通常想要的是钻石继承 ,其中一个单独的Top对象由一个完整的Bottom对象中的LeftRight子对象共享,这就是第一个类图所描述的。这是通过使Top成为LeftRight虚拟基类来实现的,如清单 21-11 所示。

清单 21-11 。展示真正的钻石继承

//: C21:VirtualBase.cpp
// Shows a shared subobject via a virtual base.
#include <iostream>
using namespace std;

class Top {
protected:
  int x;
public:
  Top(int n) { x = n; }
  virtualTop() {}
  friend ostream&
  operator<<(ostream& os, const Top& t) {
    return os << t.x;
  }
};

class Left : virtual public Top {
protected:
  int y;
public:
  Left(int m, int n) : Top(m) { y = n; }
}
class Right : virtual public Top {
protected:
  int z;
public:
  Right(int m, int n) : Top(m) { z = n; }
};

class Bottom : public Left, public Right {
  int w;
public:
  Bottom(int i, int j, int k, int m)
  : Top(i), Left(0, j), Right(0, k) { w = m; }
  friend ostream&
  operator<<(ostream& os, const Bottom& b) {
    return os << b.x << ',' << b.y << ',' << b.z
              << ',' << b.w;
  }
};
int main() {
  Bottom b(1, 2, 3, 4);
  cout << sizeof b << endl;
  cout << b << endl;
  cout << static_cast<void*>(&b) << endl;
  Top* p = static_cast<Top*>(&b);
  cout << *p << endl;
  cout << static_cast<void*>(p) << endl;
  cout << dynamic_cast<void*>(p) << endl;
} ///:∼

给定类型的每个虚拟基引用同一个对象,不管它出现在层次结构中的什么位置。这意味着当一个Bottom对象被实例化时,对象布局 可能看起来像图 21-6 。

9781430260943_Fig21-06.jpg

图 21-6 。对象布局

LeftRight子对象每个都有一个指向共享的Top子对象的指针(或一些概念上的等价物),并且在LeftRight成员函数中对该子对象的所有引用都将通过这些指针。这里,当从一个Bottom向上转换到一个Top对象时,没有歧义,因为只有一个Top对象要转换。

清单 21-11 中程序的输出如下:

36
1,2,3,4
1245032
1
1245060
1245032

打印的地址表明这个特定的实现确实在完整对象的末尾存储了Top子对象(尽管它放在哪里并不重要)。dynamic_castvoid*的结果总是解析为完整对象的地址。

尽管这样做在技术上是非法的,但是如果您删除虚拟析构函数(和dynamic_cast语句,这样程序将会编译),那么Bottom的大小将会减少到 24 个字节。这似乎是相当于三个指针大小的减少。为什么?

重要的是不要把这些数字看得太重。当添加虚拟构造器时,其他编译器只能将大小增加 4 个字节。不是编译器作者,我不能告诉你他们的秘密。但是,我可以告诉你,有了多重继承,一个派生的对象必须表现得好像它有多个 VPTRs,每个 VPTRs 对应于它的一个直接基类,这些基类也有虚函数。就这么简单。编译器进行作者发明的任何优化,但是行为必须是相同的。

清单 21-11 中最奇怪的事情是Bottom构造器中Top的初始化器。通常人们不担心初始化直接基类之外的子对象,因为所有的类都负责初始化它们自己的基类。然而,从BottomTop有多条路径,因此依靠中间类LeftRight来传递必要的初始化数据会导致一种不确定性——谁负责执行初始化?由于这个原因,大多数派生类必须初始化一个虚拟基 。但是同样初始化TopLeftRight构造器中的表达式呢?在创建独立的LeftRight对象时,它们当然是必要的,但是在创建Bottom对象时,它们必须被忽略(因此在Bottom构造器中,它们的初始值为零——当LeftRight构造器在Bottom对象的上下文中执行时,这些槽中的任何值都被忽略)。编译器会为您处理所有这些,但是理解责任在哪里是很重要的。始终确保多重继承层次结构中的所有具体(非抽象)类知道任何虚拟基,并适当地初始化它们。

这些责任规则不仅适用于初始化,还适用于跨越类层次结构的所有操作。考虑清单 21-11 中的流插入器。我们使数据受到保护,这样我们就可以“欺骗”并访问operator<<(ostream&, const Bottom&)中的继承数据。将打印每个子对象的工作分配给相应的类并让派生类根据需要调用其基类函数通常更有意义。如果我们像清单 21-12 所示的那样用operator<<()尝试会发生什么?

清单 21-12 。演示了一个错误的方法来实现运算符< < ()

//: C21:VirtualBase2.cpp
// How NOT to implement operator<<.
#include <iostream>
using namespace std;

class Top {
  int x;
public:
  Top(int n) { x = n; }
  virtualTop() {}
  friend ostream& operator<<(ostream& os, const Top& t) {
    return os << t.x;
  }
};

class Left : virtual public Top {
  int y;
public:
  Left(int m, int n) : Top(m) { y = n; }
  friend ostream& operator<<(ostream& os, const Left& l) {
    return os << static_cast<const Top&>(l) << ',' << l.y;
  }
};

class Right : virtual public Top {
  int z;
public:
  Right(int m, int n) : Top(m) { z = n; }
  friend ostream& operator<<(ostream& os, const Right& r) {
    return os << static_cast<const Top&>(r) << ',' << r.z;
  }
};

class Bottom : public Left, public Right {
  int w;
public:
  Bottom(int i, int j, int k, int m)
  : Top(i), Left(0, j), Right(0, k) { w = m; }
  friend ostream& operator<<(ostream& os, const Bottom& b){
    return os << static_cast<const Left&>(b)
      << ',' << static_cast<const Right&>(b)
      << ',' << b.w;
  }
};

int main() {
  Bottom b(1, 2, 3, 4);
  cout << b << endl;  // 1,2,1,3,4
} ///:∼

你不能像通常那样盲目地向上分担责任,因为LeftRight流插入器都调用Top插入器,同样会有数据重复。相反,你需要模仿编译器在初始化时做的事情。一个解决方案是在知道虚拟基类的类中提供特殊的函数,这些函数在打印时忽略虚拟基类(把工作留给最派生的类),如清单 21-13 所示。

清单 21-13 。演示正确的流插入器

//: C21:VirtualBase3.cpp
// A correct stream inserter.
#include <iostream>
using namespace std;

class Top {
  int x;
public:
  Top(int n) { x = n; }
  virtualTop() {}
  friend ostream& operator<<(ostream& os, const Top& t) {
    return os << t.x;
  }
};

class Left : virtual public Top {
  int y;
protected:
  void specialPrint(ostream& os) const {
    // Only print Left's part
    os << ',' << y;
  }
public:
  Left(int m, int n) : Top(m) { y = n; }
  friend ostream& operator<<(ostream& os, const Left& l) {
    return os << static_cast<const Top&>(l) << ',' << l.y;
  }
};

class Right : virtual public Top {
  int z;
protected:
  void specialPrint(ostream& os) const {
    // Only print Right's part
    os << ',' << z;
  }
public:
  Right(int m, int n) : Top(m) { z = n; }
  friend ostream& operator<<(ostream& os, const Right& r) {
    return os << static_cast<const Top&>(r) << ',' << r.z;
  }
};

class Bottom : public Left, public Right {
  int w;
public:
  Bottom(int i, int j, int k, int m)
  : Top(i), Left(0, j), Right(0, k) { w = m; }

  friend ostream& operator<<(ostream& os, const Bottom& b){
    os << static_cast<const Top&>(b);
    b.Left::specialPrint(os);
    b.Right::specialPrint(os);
    return os << ',' << b.w;
  }
};

int main() {
  Bottom b(1, 2, 3, 4);
  cout << b << endl;  // 1,2,3,4
} ///:∼

specialPrint()函数是protected,因为它们只会被Bottom调用。它们只打印自己的数据,忽略它们的Top子对象,因为在调用这些函数时Bottom插入器处于控制中。Bottom插入器必须知道虚拟基,就像Bottom构造器需要知道的一样。同样的推理也适用于具有虚拟基的层次结构中的赋值操作符,以及任何想要在层次结构中的所有类之间共享工作的函数,无论是否是成员。

讨论了虚拟基类之后,现在让我们来说明对象初始化 的“完整故事”。因为虚拟基产生共享子对象,所以在共享发生之前它们应该是可用的是有意义的。所以子对象的初始化顺序递归地遵循这些规则。

  1. 所有虚拟基类子对象根据它们在类定义中出现的位置,以自上而下、从左到右的顺序初始化。
  2. 然后,非虚拟基类按通常的顺序初始化。
  3. 所有成员对象都按声明顺序初始化。
  4. 完整对象的构造器执行。

清单 21-14 展示了这种行为。

清单 21-14 。用虚拟基类阐释初始化顺序

//: C21:VirtInit.cpp
// Illustrates initialization order with virtual bases.
#include <iostream>
#include <string>
using namespace std;

class M {
public:
  M(const string& s) { cout << "M " << s << endl; }
};
class A {
  M m;
public:
  A(const string& s) : m("in A") {
    cout << "A " << s << endl;
  }
  virtualA() {}
};

class B {
  M m;
public:
  B(const string& s) : m("in B")  {
    cout << "B " << s << endl;
  }
  virtualB() {}
};

class C {
  M m;
public:
  C(const string& s) : m("in C")  {
    cout << "C " << s << endl;
  }
  virtualC() {}
};

class D {
  M m;
public:
  D(const string& s) : m("in D") {
    cout << "D " << s << endl;
  }
  virtualD() {}
};

class E : public A, virtual public B, virtual public C {
  M m;
public:
  E(const string& s) : A("from E"), B("from E"),
  C("from E"), m("in E") {
    cout << "E " << s << endl;
  }
};

class F : virtual public B, virtual public C, public D {
  M m;
public:
  F(const string& s) : B("from F"), C("from F"),
  D("from F"), m("in F") {
    cout << "F " << s << endl;
  }
};

class G : public E, public F {
  M m;
public:
  G(const string& s) : B("from G"), C("from G"),
  E("from G"),  F("from G"), m("in G") {
    cout << "G " << s << endl;
  }
};
int main() {
  G g("from main");
} ///:∼

这个程序的输出是

M in B
B from G
M in C
C from G
M in A
A from E
M in E
E from G
M in D
D from F
M in F
F from G
M in G
G from main

9781430260943_Fig21-07.jpg

图 21-7 。显示各种类别

这段代码中的类可以用图 21-7 来表示。

每个类都有一个类型为M的嵌入成员。注意只有四个派生是虚拟的:BCE,以及BCF

G的初始化要求它的EF部分首先被初始化,但是BC子对象首先被初始化,因为它们是虚拟基,并且是从G的初始化器初始化的,G是最衍生的类。类B没有基类,所以根据规则 3,它的成员对象M被初始化,然后它的构造器从G打印出B,对于EC主题也是如此。E子对象需要ABC子对象。由于BC已经初始化,接下来初始化E子对象的A子对象,然后初始化E子对象本身。对GF子对象重复相同的场景,但是不重复虚拟基础的初始化。

姓名查询问题

用子对象说明的歧义适用于任何名称,包括函数名。如果一个类有多个共享同名成员函数的直接基类,而你调用了其中一个成员函数,编译器不知道选择哪个。清单 21-15 中的程序会报告这样一个错误。

清单 21-15 。说明不明确的函数名

//: C21:AmbiguousName.cpp {-xo}
class Top {
public:
  virtualTop() {}
};

class Left : virtual public Top {
public:
  void f() {}
};

class Right : virtual public Top {
public:
  void f() {}
};

class Bottom : public Left, public Right {};
int main() {
  Bottom b;
  b.f(); // Error here
} ///:∼

Bottom继承了两个同名的函数(签名是不相关的,因为名称查找发生在重载解析之前),没有办法在它们之间进行选择。消除调用歧义的常用技术是用基类名称限定函数调用;参见清单 21-16 。

清单 21-16 。解决清单 21-15 中的歧义

//: C21:BreakTie.cpp
class Top {
public:
  virtualTop() {}
};

class Left : virtual public Top {
public:
  void f() {}
};

class Right : virtual public Top {
public:
  void f() {}
};

class Bottom : public Left, public Right {
public:
  using Left::f;
};

int main() {
  Bottom b;
  b.f(); // Calls Left::f()
} ///:∼

Left::f这个名字现在在Bottom的范围内,所以Right::f这个名字甚至不在考虑范围内。为了引入超出Left::f()所提供的额外功能,您实现了一个调用Left::f()Bottom::f()函数。

在层次结构的不同分支中出现的同名函数经常会发生冲突。清单 21-17 中的层次结构没有这样的问题。

清单 21-17 。说明了解决类层次结构中函数名歧义的优势原则

//: C21:Dominance.cpp
class Top {
public:
  virtualTop() {}
  virtual void f() {}
};

class Left : virtual public Top {
public:
  void f() {}
};

class Right : virtual public Top {};

class Bottom : public Left, public Right {};

int main() {
  Bottom b;
  b.f(); // Calls Left::f()
} ///:∼

这里没有明确的Right::f()。由于Left::f()是最衍生的,所以选择它。为什么呢?假设Right不存在,给出单继承层次结构Top <= Left <= Bottom。由于正常的作用域规则:派生类被认为是基类的嵌套作用域,你当然会期望Left::f()是由表达式b.f()调用的函数。一般来说,如果A直接或间接地从B派生,或者换句话说,如果A在层次结构中比B更“派生”,则名称A::f 支配名称B::f。因此,在两个同名函数之间进行选择时,编译器会选择占优势的那个。如果没有主导名,就有歧义。

清单 21-18 进一步说明了优势原则。

清单 21-18 。说明优势原则(再次)来解决更多的歧义

//: C21:Dominance2.cpp
#include <iostream>
using namespace std;

class A {
public:
  virtualA() {}
  virtual void f() { cout << "A::f\n"; }
};

class B : virtual public A {
public:
  void f() { cout << "B::f\n"; }
};

class C : public B {};
class D : public C, virtual public A {};

int main() {
  B* p = new D;
  p->f(); // Calls B::f()
  delete p;
} ///:∼

该层次的类图如图 21-8 所示。

9781430260943_Fig21-08.jpg

图 21-8 。类图

AB的基类(在本例中是直接基类),因此名字B::f支配着A::f

避糜

当是否使用多重继承的问题出现时,至少要问两个问题。

  1. 你需要通过你的新类型显示这两个类的公共接口吗?(相反,看看一个类是否可以包含在另一个类中,只在新的类中暴露其部分接口。)
  2. 您需要向上转换到两个基类吗?(当您有两个以上的基类时,这也适用。)

如果你能对任何一个问题回答“不”,你就可以避免使用 MI,并且很可能应该这样做。

注意一个类只需要作为函数参数向上转换的情况。在这种情况下,可以嵌入该类,并在新类中提供自动类型转换功能,以产生对嵌入对象的引用。每当您使用新类的对象作为需要嵌入对象的函数的参数时,都会使用类型转换函数。但是,类型转换不能用于正常的多态成员函数选择;这需要继承。比起继承,更喜欢组合是一个好的总体设计准则。

扩展一个接口

多重继承的一个最好的用途涉及到不受你控制的代码。假设您已经获得了一个包含头文件和已编译成员函数的库,但是没有成员函数的源代码。这个库是一个带有虚函数的类层次结构,它包含一些全局函数,这些函数将指针指向库的基类;也就是说,它多态地使用库对象*。现在,假设您围绕这个库构建了一个应用程序,并编写了自己的代码,以多种形式使用基类。*

*在项目开发的后期或维护期间,您发现供应商提供的基类接口没有提供您需要的东西:函数可能是非虚拟的,而您需要它是虚拟的,或者接口中完全没有虚拟函数,但它对解决您的问题是必不可少的。多重继承可能是解决方案。例如,清单 21-19 包含了你获得的一个库的头文件。

清单 21-19 。说明供应商提供的类头

//: C21:Vendor.h
// Vendor-supplied class header
// You only get this & the compiled Vendor.obj.
#ifndef VENDOR_H
#define VENDOR_H

class Vendor {
public:
  virtual void v() const;
  void f() const; // Might want this to be virtual...
  ∼Vendor();      // Oops! Not virtual!
};

class Vendor1 : public Vendor {
public:
  void v() const;
  void f() const;
  ∼Vendor1();
};

void A(const Vendor&);
void B(const Vendor&);
// Etc.
#endif            // VENDOR_H ///:∼

假设库要大得多,有更多的派生类和更大的接口。注意,它还包括函数A()B(),这两个函数接受一个基本引用,并对其进行多态处理。清单 21-20 包含了库的实现文件。

清单 21-20 。实现清单 21-19 (Vendor.h) 中的头文件

//: C21:Vendor.cpp {O}
// Assume this is compiled and unavailable to you.
#include "Vendor.h"   // To be INCLUDED from Header FILE
                      // above
#include <iostream>
using namespace std;

void Vendor::v() const { cout << "Vendor::v()" << endl; }

void Vendor::f() const { cout << "Vendor::f()" << endl; }

Vendor::∼Vendor() { cout << "∼Vendor()" << endl; }

void Vendor1::v() const { cout << "Vendor1::v()" << endl; }

void Vendor1::f() const { cout << "Vendor1::f()" << endl; }

Vendor1::∼Vendor1() { cout << "∼Vendor1()" << endl; }

void A(const Vendor& v) {
  // ...
  v.v();
  v.f();
  // ...
}

void B(const Vendor& v) {
  // ...
  v.v();
  v.f();
  // ...
} ///:∼

在您的项目中,该源代码对您不可用。相反,您会得到一个编译后的文件,名为Vendor.objVendor.lib(或者,带有适用于您的系统的等效文件后缀)。

问题出现在这个库的使用上。首先,析构函数不是虚拟的。此外,f()未造虚;你假设库的创建者决定不需要它。您还发现基类的接口缺少一个对解决问题至关重要的功能。还假设你已经使用现有的接口编写了相当多的代码(更不用说功能A()B(),它们已经超出了你的控制范围),并且你不想改变它。

为了修复这个问题,你创建你自己的类接口,并从你的接口和现有的类中多重继承一组新的派生类,如清单 21-21 所示。

清单 21-21 。说明了如何使用 MI 修复清单 21-20 中的混乱

//: C21:Paste.cpp
//{L} Vendor
// Fixing a mess with MI.
#include <iostream>
#include "Vendor.h"
using namespace std;

class MyBase { // Repair Vendor interface

public:
  virtual void v() const = 0;
  virtual void f() const = 0;
  // New interface function:
  virtual void g() const = 0;
  virtualMyBase() { cout << "∼MyBase()" << endl; }
};

class Paste1 : public MyBase, public Vendor1 {

public:
  void v() const {
    cout << "Paste1::v()" << endl;
    Vendor1::v();
  }

  void f() const {
    cout << "Paste1::f()" << endl;
    Vendor1::f();
  }
  void g() const { cout << "Paste1::g()" << endl; }

  ∼Paste1() { cout << "∼Paste1()" << endl; }
};

int main() {
  Paste1& p1p = *new Paste1;
  MyBase& mp = p1p; // Upcast
  cout << "calling f()" << endl;
  mp.f();           // Right behavior
  cout << "calling g()" << endl;
  mp.g();           // New behavior
  cout << "calling A(p1p)" << endl;
  A(p1p);           // Same old behavior
  cout << "calling B(p1p)" << endl;
  B(p1p);           // Same old behavior
  cout << "delete mp" << endl;
  // Deleting a reference to a heap object:
  delete &mp;       // Right behavior
} ///:∼

MyBase(其中没有使用 MI)中,f()和析构函数现在都是虚拟的,一个新的虚函数g()被添加到接口中。现在必须重新创建原始库中的每个派生类,在新接口中混合 MI。函数Paste1::v()Paste1::f()只需要调用它们函数的原始基类版本。但是现在,如果你像在main()中一样向上投射到MyBase

MyBase* mp = p1p; // Upcast

任何通过mp进行的函数调用都将是多态的,包括delete。另外,新的接口函数g()可以通过mp调用。下面是程序的输出:

calling f()
Paste1::f()
Vendor1::f()
calling g()
Paste1::g()
calling A(p1p)
Paste1::v()
Vendor1::v()
Vendor::f()
calling B(p1p)
Paste1::v()
Vendor1::v()
Vendor::f()
delete mp
∼Paste1()Vendor1()Vendor()MyBase()

原来的库函数A()B()仍然工作相同(假设新的v()调用它的基类版本)。析构函数现在是virtual,并显示出的正确行为

虽然这是一个混乱的例子,但它确实在实践中发生了,并且很好地演示了多重继承显然是必要的:您必须能够向上转换到两个基类。

审查会议

  1. C++ 中存在 MI ?? 的一个原因是,它是一种 ?? 混合语言,不能像 Smalltalk 和 Java 那样实现单一的类层次结构。
  2. 相反,C++ 允许形成许多继承树,所以有时你可能需要将两个或更多树的接口组合成一个新类。
  3. 如果没有“钻石”出现在你的类层次结构中,那么 MI 相当简单(尽管基类中相同的函数签名仍然必须被解析)。如果出现菱形,您可能希望通过引入虚拟基类来消除重复的子对象。这不仅增加了混乱,而且底层表示变得更加复杂和低效。
  4. 多重继承被称为 90 年代的“goto”。“这看起来很合适,因为像 goto 一样, MI 最好在普通编程中避免,但偶尔会非常有用。这是 C++ 的一个“次要”但更高级的特性,旨在解决特殊情况下出现的问题。
  5. 如果你发现自己经常使用它,你可能想看看你的推理。问问你自己,“我必须将向上转换为所有的基类吗?”如果没有,如果将所有不需要的类的实例嵌入到中,你的生活会更容易。**