StoneのBLOG

生活这种事情,从来都是自我陶醉

0%

C++编程思想-第一卷-第十五章-多态性和虚函数

多态性(在C++中通过虚函数来实现)是面向对象程序设计中数据抽象和继承之外的第三个基本特征。

多态性和虚函数

15.1 C++程序员的演变

虚函数增强了类型概念,而不是只在结构内部隐蔽的封装代码,所以毫无疑问,对于新的C++程序员来说,这些概念是最困难的。然而它们是理解面向对象程序设计的转折点。如果不用虚函数,就等于还不懂得面向对象程序设计(OOP)

15.2 向上类型转换

在第14章中,我们已经看到对象如何能作为它自己的类或作为它的基类的对象使用。另外,还能通过基类的地址操作它。取一个对象的地址(指针或者引用),并将其作为基类的地址来处理,这被称为向上类型转换(upcasting),因为继承树的绘制方式是以基类为顶点的

15.3 捆绑(binding)

15.3.1 函数调用捆绑

把函数体与函数调用相联系称为捆绑。当捆粄在程序运行之前(由编译器和连接器)完成时,称为早捆绑(early binding)。

晚捆绑(late bingding)根据对象的类型,发生在运行时。又称为动态捆绑(dynamic binding)或运行时捆绑(runtime binding)。

15.4 虚函数

对于特定的函数,为了引起晚捆绑,C++要求在基类中声明这个函数的时候使用virtual关键字。晚捆绑只对virtual函数起作用,而且只在使用含有virtual函数的基类的地址时发生,尽管它们也可以在更早的基类中定义。

仅仅在声明的时候需要使用virtual关键字,定义时不需要。 如果一个函数在基类中被声明为virtual,那么在所有的派生类中它都是virtual。在派生类中virtual函数的重定义通常称为重写(overriding)。

注意,仅需要在基类中声明一个函数为virtual。调用所有匹配基类声明行为的派生类函数都将使用虚机制。虽然可以在派生类声明前使用关键字virtual(这也是无害的),但这样会使程序段显得冗余和混乱。

15.4.1 扩展性

编译器保证对于虚函数总是有某种定义,所以绝不会出现最终调用不与函数体捆绑的情况(这种情况将导致灾难)。


这里我有相当大的疑问:

按照上面的说法,继承的基类中有虚函数的情况下,派生类都将使用虚机制

  • 虚机制是什么

其次,按照上面的描述在派生类声明前加上virtual也可以,也就是说派生类中的函数也称为了虚函数?

然后是在派生类中定义了一个名为FunctionA()的新的函数的话,这个函数尽管没有加入virtual关键字,实际上也是虚函数?

然后假设从相同基类派生的派生类PAClass()PBClass()两个同时新定义了一个名为FunctionA()的函数的话,这种情况下他们是相同的虚函数吗?


按照书上说的意思(我的理解),出现上面情况的时候,将会自动的调用继承层次中“最近”的定义

但我不太懂。还是得继续摸索下去。

15.5 C++如何实现晚捆绑

当告诉编译器要晚捆绑时(通过创建虚函数来告诉),编译器安装必要的晚捆绑机制。

关键字virtual告诉编译器它不应当执行早捆绑,相反,它应当自动安装对于实现晚捆绑必须的所有机制。

为了达到这个目的,典型的编译器(通用的方法)对每个包含虚函数的类创建一个表(称为VTABLE)。在VTABLE中,编译器放置特定类的虚函数的地址。在每个带有虚函数的类中,编译器秘密的放置一个指针,称为vpointer(缩写为VPTR),指向这个对象的VTABLE。当通过基类指针做虚函数调用时(也就是做多态调用),编译器静态的插入能取得这个VPTR并在VTABLE表中查找函数地址的代码,这样就能调用正确的函数并引起晚捆绑的发生。

15.5.1 存放类型信息

必须有一些类型信息放在对象中,否则类型将不能在运行时建立。但是类型信息被隐蔽了。

不带虚函数,对象的长度恰好就是所期望的长度。而带有单个或多个虚函数的对象,是所期望的长度加上一个void指针的长度。

它反映出,如果有一个或多个虚函数,编译器都只在这个结构中插入一个单个指针(VPTR)。这是因为VPTR指向一个存放函数地址的表。

15.5.2 虚函数功能图示


上不了图了

每当创建一个包含有虚函数的类或从包含有虚函数的类派生一个类时,编译器就为这个类型创建一个唯一的VTABLE。在这个表中,编译器放置了在这个类中或在它的基类中所有已声明为virtual的函数的地址。如果在这个派生类中没有对基类中声明为virtual的函数进行重新定义,编译器就使用基类的这个函数的虚函数地址。

当使用简单继承时,对于每个对象只有一个VPTR。VPTR必须被初始化为指向VTABLE的起始地址。(这个在构造函数中发生,在稍后会看的更清楚)

一旦VPTR被初始化为指定相应的VPTR。对象就知道它自己是什么类型。但只有当虚函数被调用的时候这种自我认知才有用。

当通过基类地址调用一个虚函数时(此时编译器没有能完成早捆绑所需的所有信息),要特殊处理。它不是实现典型的函数调用,那样只是简单的用汇编语言CALL特定的地址,而是编译器为完成这个函数调用而产生不同的代码。


此处应有图但是上不了。

编译器从这个Instrument(基类)指针开始,这个指针指向这个对象的起始地址。对于所有的Instrument对象和由Instrument派生的对象,它们的VPTR都在对象的相同位置(常常在对象的开头),所以编译器就能取出这个对象的VPTR。VPTR指向VTABLE的起始地址。所有的VTABLE都具有相同的顺序,不管何种类型的对象。Play()是第一个, What()是第二个, Adjust()是第三个。所以无论是什么特殊的对象类型,编译器都知道Adjust()是必在VPTR+2处。这样就不是以“Instrument::Adjust”地址调用这个函数,而实际上是在“VPTR+2”处调用这个函数。因为获取VPTR和确定实际函数地址发生在运行时,所以这样就得到了所希望的晚捆绑。

15.5.3 揭开面纱

看一下虚函数调用产生的汇编语言代码。下面是在函数f(Instrument&i)内部调用

i.Adjust(1);

某个编译器所产生的输出:

1
2
3
4
5
push  1
push si
mov bx, word ptr [si]
call word ptr [bx+4]
add sp, 4


说实话我是看不懂汇编的。

C++函数调用的参数与C函数调用一样,是从右向左进栈的(这个顺序是为了支持C的变量参数表),所以参数1首先压栈。对于这个函数,寄存器si(Intel x86处理器的一部分)存放i的地址。因为它是被选中对象的首地址,它也被压进栈。记住,这个首地址对应this的值,正因为调用每个成员函数时this都必须作为参数压进栈,所以成员函数知道它工作在哪个特殊对象上。这样我们总能看到,在成员函数调用之前压栈的次数等于参数个数加1(除了static成员函数,因为它没有this)。

然后必须实现实际的虚函数调用。首先,必须产生VPTR,找到VTABLE。对于这个编译器,VPTR在对象的开头,所以this的内容对应于VPTR。

1
mov   bx, word ptr [si]

取出si(即this)所指的字,它就是VPTR。将VPTR放入bx寄存器中。

在bx中这个VPTR指向VTABLE的首地址,调用的函数在VTABLE中的第二个位置(0,1,2,它是表中的第三个函数)。对于这种内存模式,每个函数指针是两个字节长,所以VPTR+4,计算相应的函数地址所在的地方。

幸好编译器仔细处理,并保证VTABLE中的所有函数指针都以相同的次序出现,而不论我们在派生类中是以什么样的顺序覆盖它们。

一旦VTABLE中相应函数指针的地址被计算出来,就调用这个函数。所以取出这个地址并马上在这个句子中调用。

1
call  word ptr [bx+4]

最后栈指针移回去,以清除在调用之前压入栈的参数。在C和C++汇编代码中,将经常看到调用者清除这些参数,但这可能依据处理器和编译器的实现而有所不同。

15.5.4 安装vpointer

因为VPTR决定了对象的虚函数的行为,所以我们看到VPTR总是指向相应的VTABLE是多么重要。在VPTR适当初始化之前绝不能调用虚函数。

15.5.5 对象是不同的

认识到向上类型转换仅处理地址,这是重要的。

如果编译器有一个它知道确切类型的对象,那么(在C++中)对任何函数的额调用不再使用晚捆绑,或至少编译器不必使用晚捆绑。因为编译器知道对象的确切类型,为了提高效率,当调用这些对象的虚函数时,很多编译器使用早捆绑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Early binding & virtual functions
#include <iostream>
#include <stream>
using namespace std;

class Pet{
public:
virtual string speak() const {return "";}
};

class Dog : public Pet{
public:
string speak() const {return "Bark";}
};

int main() {
Dog ralph;
Pet* p1 = &ralph;
Pet& p2 = ralph;
Pet p3;

p1->speak();
p2.speak();
p3.speak();
};

使用地址就意味着不完全,p1,p2可能表示Pet的地址,也可能是其派生对象的地址,所以必须使用虚函数。而当调用p3的时候,不存在含糊,编译器知道确切的类型并且知道它是一个对象,这样可以使用早捆绑。

15.6 为什么需要虚函数

virtual关键字可以改变程序的效率。

从前面的汇编语言输出可以看出,它并不是对于绝对地址的一个简单的CALL,而是为设置虚函数调用需要两条以上的复杂的汇编指令。这既需要代码空间,又需要执行时间。

15.7 抽象基类和纯虚函数

在基类中加入至少一个纯虚函数(pure virtual function),来使基类称为抽象(abstract)类。纯虚函数使用virtual关键字,并且在后面加上=0.如果某人试着生成一个抽象类的对象,编译器就会制止他。

当继承一个抽象类时,必须实现所有的虚函,否则继承出的类也将是一个抽象类。

纯虚函数的声明语法:

1
virtual void f() = 0;

这样做,等于告诉编译器在VTABLE中为函数保留一个位置,但在这个特定位置中不放地址(或者说是放不了地址),只要有一个函数在类中被声明为纯虚函数,则VTABLE就是不完全的。编译器不能安全的创建一个纯抽象类的对象,保证了抽象类的纯洁性,就不会被误用了。

注意,纯虚函数禁止对抽象类的函数以传值方式调用。

这也是防止对象切片(object slicing)的一种方法。通过抽象类,可以保证在向上类型转换期间总是使用指针或引用。

纯虚函数防止产生完全的VTABLE,但这并不意味着我们不希望对其他一些函数产生函数体。我们常常希望调用一个函数的基类版本,即使它是虚拟的。把公共代码尽可能靠近我们的类层次根的地方,这是很好的想法。

也就是说下面的纯虚函数定义了

15.7.1 纯虚定义

在基类中,对纯虚函数提供定义是可能的。我们仍然告诉编译器不允许产生抽象基类的对象,如果想要创建对象,则纯虚函数必须在派生类中定义。

好处一:

然而我们希望一段公共代码,使一些或所有派生类都能调用,而不必在每个函数中重复这段代码。

如下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Pure virtual base definitions
#include <iostream>
using namespace std;

class Pet{
public:
virtual void speak() const = 0;
virtual void eat() const =0;
// Inline pure virtual definitions illegal:
//! virtual void sleep() const = 0 {}
};

// OK, not defined inline
void Pet::eat() const{
// Do something
}

void Pet::speak() const{
// Do something
}

class Dog : public Pet {
void speak() const { Pet::speak(); }
void eat() const { Pet::eat(); }
};

int main() {
Dog d;
d.speak();
d.eat();
}

Pet的VTABLE依然空着,但这个派生类中刚好有一个函数,可以通过名字调用它。

好处二:

这个特点的另一个好处是,它允许我们实现从常规函数到纯虚函数的改变,而无需打乱存在的代码。(这是一个处理不用重新定义虚函数的类的方法)

隐约觉得这个功能很强,让我联想到了静态函数,你看它直接通过类名调用就很厉害。这应该又涉及到另外的机制了吧。

15.8 继承和VTABLE

当实现继承和重新定义一些虚函数时,编译器对新类创建一个新的VTABLE表,并且插入新函数的地址,对于没有重新定义的虚函数使用基类函数的地址。无论如何,对于被创建的每个对象(即它的类不含有纯虚函数),在VTABLE中总有一个函数地址的全集,所以绝对不能对不在其中的地址进行调用(否则结果将是灾难性的)。

当派生类继承了基类中的虚函数之后又增加了新的虚函数。可以知道VTABLE中增加了新的虚函数。

然而在这里,编译器只对指向基类对象的指针工作。即基类的虚函数是指向基类对象指针的编译器唯一允许调用的函数。

这跟我的理解是一致的,使用指向基类的指针并不能够调用子类新添加的函数时理所应当的。

只有基类对象的指针,那么编译器也不知道这个指针指向的内容是不是派生类,所以编译器通过防止我们对只存在于派生类中的函数做虚函数调用来完成工作。

但是当我们知道指针实际上指向哪一种特殊对象时,还想要去使用的少数情况时,则必须类型转换这个指针。

这就是运行时类型辨认(Run-Time Type Identification, RTTI) 问题。

RTTI是有关向下类型转换基类指针到派生类指针的问题(向上和向下是相对典型类图而言的,典型类图以基类为顶点)。向上类型转换是自动发生的,不需强制,因为它是绝对安全的。向下类型转换是不安全的,因为这里没有实际类型的编译时信息,所以必须准确的知道这个类实际上是什么类型。

15.8.1 对象切片

对象切片实际上是当它拷贝到一个新的对象时,去掉原来对象的一部分,而不是使用指针或者引用那样简单的改变地址的内容。

15.9 重载和重新定义

在第14章中,我们看到重新定义一个基类中的重载函数将会隐藏所有该函数的其他基类版本。 而当对虚函数进行这些操作时,情况会有点不同。

编译器不允许我们改变重新定义过的函数的返回值(如果该函数不是虚函数,则是允许的)。

1
2
3
4
5
6
7
8
9
10
class Base {
public:
virtual int f() const {}
}

class Derived3 : public Base {
public:
// Can not change return type:
//! void f() const { // do something}
}

如果重新定义了基类中的一个重载成员函数,则在派生类中其它的重载函数将会被隐藏。

虚函数还写函数重载这个想法我还真没想到。

有的时候这种情况还真有,也许是考虑不足的缘故,当试着写了一个共通的虚函数,这个虚函数并不能适应所有的情况,比如说参数个数或者类型不匹配的情况,就会写出将父类继承来的虚函数进行重载的情况。

这里需要理清的的是,我们是因为什么才引进的虚函数。

我自己的理解是,是为了一劳永逸,调用一处的代码(使用父类虚函数调用)然后传递子类指针,子类的虚函数实现就被执行。

但是发生上面的虚函数重载情况怎么说?

首先,需要理解虚函数重载之后发生了什么,即上面所说的,该函数的重载,使得该函数的所有其他版本被隐藏。救我自己的观察来看,造成的现象是:

  1. member function does not override any base class virtual member function 并没有重写任何基类函数?!
  2. no override available for virtual member function from base 'BaseFunction'; function is hidden 基类虚函数没有被实现,函数被隐藏?

上面的这是编译器给出的警告信息,并不是错误。从信息来看,意思完全不一样。完全成了同名的函数,跟基类完全没有关系似的。

override Specifier

1
function-declaration override;
  • 对基类的虚函数进行重载时,加override会出编译错误(修改参数或者const修饰符等)
  • 试图重载基类的非虚函数时,加override会出编译错误

都是上面官方链接的例子。

final Specifier

1
2
function-declaration final;
class class-name final base-classes

15.9.1 变量返回类型

上面显示了我们不能重新定义过程中修改虚函数的返回类型。通常是这样的,但也有特例,我们可以稍稍修改返回值类型。如果返回一个指向基类的指针或引用,则该函数的重新定义版本将会从基类返回的内容中返回一个指向派生类的指针或引用。

返回确切类型更通用些,而且在自动进行向上类型转换时不丢失特定的信息。然而,返回基类类型通常会解决我们的问题,所以这是一个特殊的功能。

这个感觉就像是编译器会做返回值的类型检查,对于指针而言,返回基类类型指针的函数得到了一个派生类类型的指针,由于继承的特性,所以这是成立的。反过来应该就不行。

15.10 虚函数和构造函数

编译器在构造函数的开头部分秘密地插入能初始化VPTR的代码。正如第14张所述,如果我们没有为一个类显式创造构造函数,则编译器会为我们生成构造函数。如果该类含有虚函数,则生成的构造函数将会包含相应的VPTR初始化代码。这有几个含义。

首先,这涉及效率。内联(inline)函数 的作用是对小函数减少调用代价。如果C++不提供内联函数,则预处理器就可能被用来创建这些“宏”。然而预处理器没有访问或类的概念。因此不能被用来创建成员函数宏。另外,有了由编译器插入的隐藏代码的构造函数,预处理宏根本不能工作。

上面这段话看得我不知所谓,事实来说C++提供了内联函数。我不太理解预处理宏是什么,最后一句为什么那种情况下预处理宏不能工作的具体原因是什么?

当寻找效率漏洞时,我们必须明白,编译器正在插入隐藏代码到我们的构造函数中。这些隐藏代码不仅必须初始化VPTR,而且还必须检查this的值(以免operator new返回零)和调用基类构造函数。放在一起,这些代码可以影响我们认为是一个小内联函数的调用。特别是构造函数的规模会抵消函数调用代价的减少。如果做大量的内联函数调用,代码长度就会增长。而在速度上没有任何好处。

当然也许并不会立即把所有这些小构造函数都变成非内联,因为它们更容易写为内联构造函数。但是,当我们正在调整我们的代码时,记住,务必去掉这些内联构造函数。

所以这些内联构造函数是可以删除的?还是说变成非内联?

15.10.1 构造函数调用次序

所有基类构造函数总是在继承类构造函数中被调用。

15.10.2 虚函数在构造函数中的行为

对于在构造函数中调用一个虚函数的情况,被调用的只是这个函数的本地版本。也就是说,虚机制在构造函数中不工作。

这种行为有两个理由。在概念上,构造函数的工作是构造一个对象。在构造函数中此时可能只是部分形成对象–我们只能知道基类已被初始化,但并不能知道是那个类从这个基类继承来的。然而,虚函数在继承层次上是“向前”和“向外”的进行调用。它可以调用在派生类中的函数。如果我们在构造函数中也这样做,那么我们所调用的函数可能操作尚未初始化的成员,导致灾难的发生。

第二个理由是机械的。当一个构造函数被调用的时候,它做的首要事情之一是初始化它的VPTR。然儿它知道它属于“当前”类,即构造函数所在的类。当编译器为这个构造函数产生代码时,它使用的VPTR必须是对于这个类的VTABLE。VPTR的状态是由最后被调用的构造函数确定的。 这就是为什么构造函数调用是按照从基类到最晚派生类的顺序的另一个理由。

另外,许多编译器认识到,如果在构造函数中进行虚函数调用,应该使用早捆绑,因为它们知道晚捆绑将只对本地函数产生调用。无论哪种情况,在构造函数中调用虚函数都不能得到预期的结果。

15.11 析构函数和虚拟析构函数

构造函数是不能为虚函数的。但析构函数能够且常常必须是虚的。

构造函数有一项特殊的工作,就是一块一块的组合成一个对象。它首先调用基类构造函数,然后调用继承顺序中的更晚派生的构造函数(同样,它也必须按此方法调用成员对象构造函数)。类似的,析构函数也有一项特殊工作,即它必须拆卸属于某层次类的对象。为了做这些工作,编译器来生成代码来调用所有的析构函数,但它必须按照与构造函数调用相反的顺序。

应当记住,构造函数和析构函数是类层次进行调用的唯一地方(因此,编译器自动的生成适当的类层次)。在所有其它函数中,只有这个函数会被调用(非基类版本),而无论它是虚的还是非虚的。同一函数的基类版本在普通函数中被调用(无论是虚的还是非虚的)的唯一方法是显式的调用这个函数。

如果这个指针是指向基类的,在delete期间,编译器只能知道调用这个析构函数的基类版本。这听起来很耳熟,虚函数被创建恰恰是为了解决这个问题。幸运的是,就像除了构造函数以外的所有其他函数一样,析构函数可以是虚函数。

上面的指针指向基类的例子中,如果使用的delete,会依次调用自身的析构函数,然后调用基类的析构函数。前提是:基类的析构函数是虚函数。

这正是我们所期望的。不把析构函数设为虚函数是一个隐匿的错误,因为它常常不会对程序有直接的影响。 但要注意它不知不觉的引入存储器泄漏(关闭程序时内存未释放)。同样,这样的析构操作还有可能掩盖发生的问题。

我靠这到底想让我怎么写?!

即使析构函数像构造函数一样,是“例外”函数,但是析构函数可以是虚的,这是因为这个对象已经知道它是什么类型(而在构造期间则不然)。一旦对象已被构造,它的VPTR就已被初始化,所以能发生虚函数调用。

15.11.1 纯虚析构函数

尽管纯虚析构函数在标准C++中是合法的,但在使用的时候有一个限制:必须为纯虚析构函数提供一个函数体。

不像其他的纯虚函数,我们不要求派生类中提供纯虚函数的定义。

1
virtual ~AbstractBase() = 0;

析构函数的纯虚性的唯一效果是阻止基类的实例化。如果有其他的纯虚函数,则它们会阻止,否则,纯虚析构函数会执行这项操作。所以当虚析构函数是十分必要时,则它是不是纯虚的就不是那么重要了。

15.11.2 析构函数中的虚机制

在析构函数中,只有成员函数的“本地”版本被调用;虚机制被忽略。

在构造函数的情况下这样做是因为类型信息还不可用,然而在析构函数中,这样做是因为信息(也就是VPTR)虽存在,但不可靠。(可能派生的对象已被析构)。

15.11.3 创建基于对象的继承

利用多态性,强制容器中的所有对象从一个基类中继承而来,随后调用虚函数(虚析构函数)来解决所有权问题。

这种解决方法使用单根继承(singly-rooted hierarchy)或基于对象的继承(object-based hierachy)。

事实上,除了C++,每种面向对象的语言都强制使用这样的体系,这个基类由该语言的创建者生成的。C++中认为,强制使用这个公共基类会引起太多的开销,所以便没有使用它。

这里需要警惕多重继承(multiple inheritance)。多重继承是非常复杂的,应尽量少用这一功能。

创建包容Object的容器是一种合理的方法–如果使用单根继承(由于语言本身或需要的缘故,强制每个类继承自Object)。这时,保证一切都是一个Object,因此使用容器的时候并不是十分复杂。然而在C++中不能期望这适用于每一个类,所以如果有多重继承就会出现问题。在第16章中会看到模板可以使用更简单更灵巧的方式处理这个问题。

15.2 运算符重载

就像对成员函数那样,我们可以使用virtual运算符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// 对于一个处理矩阵向量标量的系统,这三个成分都是派生自Math类
// Polymorphism with overload operators
#include <iostream>
using namespace std;

class Matrix;
class Scalar;
class Vector;

class Math{
piblic:
virtual Math& operator*(Math& rv) = 0;
virtual Math& multiply(Matrix*) = 0;
virtual Math& multiply(Scalar*) = 0;
virtual Math& nultiply(Vector*) = 0;
virtual ~Math() {}
};

class Matrix : public Math{
public:
Math& operator*(Math& rv){
return rv.multiply(this); // 2nd dispatch
}
Math& multiply(Matrix*){
cout<< "Matrix*Matrix"<<endl;
return *this;
}
Math& multiply(Scalar*){
cout<<"Scalar*Matrix"<<endl;
return *this;
}
Math& multiply(Vector*){
cout<<"Vector*Matrix"<<endl;
return *this;
}
};

class Scalar : public Math{
public:
// 内容省略,跟Matrix类的实现基本一致
};

class Vector : public Math{
public:
// 内容省略,跟Matrix类的实现基本一致
};

int main(){
Martix m;Vector v;Scalar s;
Math* math[] = {&m, &v, &s};
for (int i =0 ;i < 3; i++){
for (int j = 0;j<3;j++){
Math& m1 = *math[i];
Math& m2 = *math[j];
m1*m2;
}
}
}

main()中的问题在于,表达式m1*m2包含了两个向上类型转换的Math引用,因此不知道这两个对象的类型。一个虚函数仅能进行单一指派–即判定一个未知对象的类型。 本例中使用的判定两个对象类型的技术称之为多重指派(multiple dispatching) ,一个单一虚函数调用引起了第二个虚函数的调用。在完成第二个调用的时候,已经得到了两个对象的类型,于是可以执行正确的操作。

15.13 向下类型转换(downcasting)

C++提供了一个特殊的称为dynamic_cast的显式类型转换(explict cast),它就是一种安全型向下类型转换(type-safe downcasting)的操作。当使用dynamic_cast来试着向下类型转换一个特定的类型,仅当类型转换是正确的并且是成功的时,返回值会是一个指向所需类型的指针,否则它将返回0来表示这并不是正确的类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
using namespace std;

class Pet { public: virtual ~Pet(){} };

class Dog : public Pet {};
class Cat : public Pet {};

int main() {
Pet* b = new Cat; // Upcast

Dog* d1 = dynamic_cast<Dog*>(b);

Cat* d2 = dynamic_cast<Cat*>(b);
}

当使用dynamic_cast时,必须对一个真正多态的层次进行操作–它含有虚函数–这是因为dynamic_cast使用了存储在VTABLE中的信息来判断实际的类型。

这里基类含有一个虚析构函数,这就足够了。

无论何时进行向下类型转换,我们都有责任进行检验以确保类型转换的返回值不为0。

dynamic_cast运行时需要一点额外开销;不多,但如果大量执行(程序设计有问题),就会影响性能。有时在进行向下类型转换的时候,我们知道正在处理的是何种类型,这时使用dynamic_cast产生的额外开销就没有必要,可以通过static_cast来代替它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <iostream>
#include <typeinfo>
using namespace std;

class Shape {public: virtual ~Shape() {};};
class Circle : public Shape {};
class Square : public Shape {};
class Other {};

int main(){
Circle c;
Shape * s =c; //Upcast: normal and ok
// More explicit but unnecessary
s = static_cast<Shape*>(&c);
// (Since upcating is such a safe and common operation, the cast becomes cluttering)

Circle* cp = 0;
Square* sp = 0;
// Static Navigation of class hierarchies requires extra type information:
if(typeid(s) == typeid(cp)) //C++ RTTI
cp = static_cast<Circle*>(s);
if(typeid(s) == typeid(sp))
sp = static_cast<Circle*>(s);

if(cp != 0)
cout<<"It is a Circle"<<endl;
if(sp != 0)
cout<<"It is a Square"<<endl;

// Static navigation is ONLY an efficiency hack;
// dynamic_cast is always safer.However:
// Other* op = static_cast<Other*>(s);
// Conveniently gives an error message,while
Other *op2 = (Other*)s;
// does not
}

RTTI允许我们得到向上类型转换时丢失的类型信息。dynamic_cast实际上就是RTTI的一种形式。这里typeid关键字(在<typeinfo>中声明)用来检测指针类型。RTTI的内容远不止typeid,我们也可以想象它能通过虚函数简单合理的实现我们自己的类型信息系统。

RTTI用于判定类型,static_cast用于执行向下类型转换。但要注意,这个设计中,处理效率同dynamic_cast是一样的,并且必须检测那些实际成功的类型转换。

如果类层次中没有虚函数(这是一个有问题的设计),或者如果有其他的需要,要求我们安全的进行向下类型转换,与使用dynamic_cast相比静态的执行向下类型转换会稍微快一点。另外,static_cast不允许类型转换到该类层次的外面,而传统的类型转换是允许的,所以他们会更安全。但是静态的浏览类层次是有风险的,所以除非特殊情况我们一般使用dynamic_cast。