多态 ( 用虚函数在 C++ 中实现)是继数据抽象和继承之后,面向对象编程语言的第三个基本特征。
它提供了接口与实现分离的另一个维度,将什么与如何解耦。 What 表示接口细节, how 表示实现细节。你已经在第 5 章的中学习了隐藏实现。这里的想法类似于首先模拟一个系统(什么方面),而不用担心让一个工作系统自己就位(如何到位方面)。
多态允许改进代码组织和可读性,以及创建可扩展的程序,这些程序不仅可以在最初创建项目时“成长”,还可以在需要新功能时“成长”。
封装通过组合特征和行为来创建新的数据类型。访问控制通过制作细节private
将接口与实现分开。这种机械的组织对于具有过程化编程背景的人来说很有意义。但是虚函数 在类型方面处理解耦。在第 14 章的中,你看到了继承是如何允许把一个对象当作它自己的类型或它的基础类型。这种能力至关重要,因为它允许许多类型(派生自相同的基类型)被视为一种类型,并且一段代码可以平等地处理所有这些不同的类型。虚函数允许一种类型表达它与另一种相似类型的区别,只要它们都是从同一个基类型派生的。这种区别通过可以通过基类调用的函数的行为差异来表达。
在这一章中,你将学习虚函数,从简单的例子开始,除了函数的虚拟部分外,所有的东西都被去掉了。
C++ 程序员的进化
C 程序员似乎分三步习得 C++。首先,简单地说是“更好的 C”,因为 C++ 强迫你在使用它们之前声明所有的函数,并且对如何使用变量更加挑剔。用 C++ 编译器编译一个 C 程序,你就能发现其中的错误。
第二步是“基于对象”的 C++。这意味着您很容易看到将数据结构与作用于它的函数、构造器和析构函数的值以及一些简单的继承组合在一起的代码组织的好处。大多数已经使用 C 语言一段时间的程序员很快就看到了它的用处,因为无论何时他们创建一个库,这正是他们想要做的。有了 C++,你有了编译器的帮助。
你可能会在基于对象的层次上停滞不前,因为你可以很快到达那里,并且不需要太多的脑力劳动就可以获得很多好处。也很容易让人觉得你在创建数据类型——你创建类和对象,你向这些对象发送消息,一切都很好很整洁。
但是不要被骗了。如果您就此打住,您就错过了这门语言最伟大的部分,那就是向真正的面向对象编程的飞跃。您只能通过虚函数来实现这一点。
虚函数增强了类型的概念,而不仅仅是将代码封装在结构内部和墙的后面,因此它们无疑是新 C++ 程序员最难理解的概念。然而,它们也是理解面向对象编程的转折点。如果你没有使用虚函数,你还不了解 OOP。
因为虚函数与类型的概念密切相关,而类型是面向对象编程的核心,所以在传统的过程语言中没有虚函数的类似物。作为一个过程化的程序员,你在考虑虚函数时没有参照物,就像你在考虑语言中的其他特性一样。过程语言中的特性可以在算法层面上理解,但是虚函数只能从设计的角度来理解。
向上抛
在第 14 章中,你看到了一个对象如何被用作它自己的类型或者它的基本类型。此外,还可以通过基本类型的地址对其进行操作。取一个对象的地址(或者是一个指针或者是一个引用)并把它当作基类的地址被称为向上转换,因为继承树是以基类在顶部的方式绘制的。
你也看到了一个问题的出现,它体现在清单 15-1 中。
清单 15-1 。说明继承和向上转换问题
//: C15:Instrument2.cpp
// Inheritance & upcasting
#include <iostream>
using namespace std;
enum note { middleC, Csharp, Eflat }; // Etc.
class Instrument {
public:
void play(note) const {
cout << "Instrument::play" << endl;
}
};
// Wind objects are Instruments
// because they have the same interface:
class Wind : public Instrument {
public:
// Redefine interface function:
void play(note) const {
cout << "Wind::play" << endl;
}
};
void tune(Instrument &i) {
// ...
i.play(middleC);
}
int main() {
Wind flute;
tune(flute); // Upcasting
} ///:∼
函数tune( )
接受(通过引用)一个Instrument
,但也毫无怨言地接受从Instrument
派生的任何东西。在main( )
中,当Wind
对象被传递给tune( )
时,你可以看到这种情况,不需要强制转换。这是可以接受的;Instrument
中的接口必须存在于Wind
中,因为Wind
是从Instrument
中公开继承的。从Wind
到Instrument
的向上转换可能会“缩小”该界面,但绝不会小于到Instrument
的完整界面。
在处理指针时也是如此;唯一的区别是,当对象被传递给函数时,用户必须显式地获取对象的地址。
问题
运行程序就能看出Instrument2.cpp
的问题。输出为Instrument::play
。这显然不是期望的输出,因为您碰巧知道该对象实际上是一个Wind
,而不仅仅是一个Instrument
。这个称呼竟然产生了Wind::play
。就此而言,从Instrument
派生的类的任何对象都应该使用它的play( )
版本,不管情况如何。
鉴于 C 语言处理函数的方式,Instrument2.cpp
的行为并不令人惊讶。为了理解这些问题,您需要了解绑定的概念。
函数调用绑定
将函数调用连接到函数体称为绑定。当在程序运行前进行绑定时(由编译器和链接器进行*,称为早期绑定。你可能以前没有听说过这个术语,因为它从来都不是过程语言的选项:C 编译器只有一种函数调用,那就是早期绑定。*
清单 15-1 中的程序问题是由早期绑定引起的,因为当它只有一个Instrument
地址时,编译器不知道该调用哪个正确的函数。这个解决方案叫做延迟绑定,这意味着绑定发生在运行时,基于对象的类型。后期绑定也叫动态绑定 或运行时绑定 。当一种语言实现后期绑定时,必须有某种机制在运行时确定对象的类型,并调用适当的成员函数。在编译语言的情况下,编译器仍然不知道实际的对象类型,但是它会插入代码来找出并调用正确的函数体。后期绑定机制因语言而异,但是您可以想象某种类型的信息必须安装在对象中。稍后您将看到这是如何工作的。
使用虚函数
为了对特定的函数进行后期绑定,C++ 要求在基类中声明函数时使用virtual
关键字。后期绑定只发生在virtual
函数中,并且只有当你使用那些virtual
函数存在的基类的地址时,尽管它们也可能在早期的基类中定义。
要创建一个成员函数作为virtual
,只需在函数声明之前加上关键字virtual
。只有声明需要virtual
关键字,而不是定义。如果一个函数在基类中被声明为virtual
,那么它在所有的派生类中都是virtual
。派生类中virtual
函数的重定义通常称为覆盖 。
注意,您只需要在基类中声明一个函数virtual
。所有与基类声明的签名匹配的派生类函数都将使用虚拟机制来调用。您可以在派生类声明中使用virtual
关键字(这样做没有坏处),但是这是多余的,而且可能会引起混淆。
要从Instrument2.cpp
获得想要的行为,只需在play( )
前的基类中添加virtual
关键字,如清单 15-2 所示。
清单 15-2 。用虚拟关键字 说明后期绑定
//: C15:Instrument3.cpp
// Late binding with the virtual keyword
#include <iostream>
using namespace std;
enum note { middleC, Csharp, Cflat }; // Etc.
class Instrument {
public:
virtual void play(note) const {
cout << "Instrument::play" << endl;
}
};
// Wind objects are Instruments
// because they have the same interface:
class Wind : public Instrument {
public:
// Override interface function:
void play(note) const {
cout << "Wind::play" << endl;
}
};
void tune(Instrument &i) {
// ...
i.play(middleC);
}
int main() {
Wind flute;
tune(flute);
// Upcasting
} ///:∼
除了添加了关键字virtual
之外,这个文件与Instrument2.cpp
完全相同,但是行为却有很大的不同:现在输出是Wind::play
。
扩展性
当play( )
在基类中被定义为virtual
时,您可以在不改变tune( )
函数的情况下添加任意多的新类型。在一个设计良好的 OOP 程序中,你的大部分或者全部函数都会遵循tune( )
的模型,只与基类接口进行通信。这样的程序是可扩展的,因为您可以通过从公共基类继承新的数据类型来添加新的功能。操纵基类接口的函数根本不需要改变来适应新的类。
清单 15-3 显示了带有更多虚函数和许多新类的仪器示例,所有这些都可以与旧的、未改变的tune( )
函数一起正常工作。
清单 15-3 。在 OOP 中展示可扩展性
//: C15:Instrument4.cpp
// Extensibility in OOP
#include <iostream>
using namespace std;
enum note { middleC, Csharp, Cflat }; // Etc.
class Instrument {
public:
virtual void play(note) const {
cout << "Instrument::play" << endl;
}
virtual char* what() const {
return "Instrument";
}
// Assume this will modify the object:
virtual void adjust(int) {}
};
class Wind : public Instrument {
public:
void play(note) const {
cout << "Wind::play" << endl;
}
char* what() const { return "Wind"; }
void adjust(int) {}
};
class Percussion : public Instrument {
public:
void play(note) const {
cout << "Percussion::play" << endl;
}
char* what() const { return "Percussion"; }
void adjust(int) {}
};
class Stringed : public Instrument {
public:
void play(note) const {
cout << "Stringed::play" << endl;
}
char* what() const { return "Stringed"; }
void adjust(int) {}
};
class Brass : public Wind {
public:
void play(note) const {
cout << "Brass::play" << endl;
}
char* what() const { return "Brass"; }
};
class Woodwind : public Wind {
public:
void play(note) const {
cout << "Woodwind::play" << endl;
}
char* what() const { return "Woodwind"; }
};
// Identical function from before:
void tune(Instrument&i) {
// ...
i.play(middleC);
}
// New function:
void f(Instrument&i) { i.adjust(1); }
// Upcasting during array initialization:
Instrument* A[] = {
new Wind,
new Percussion,
new Stringed,
new Brass,
};
int main() {
Wind flute;
Percussion drum;
Stringed violin;
Brass flugelhorn;
Woodwind recorder;
tune(flute);
tune(drum);
tune(violin);
tune(flugelhorn);
tune(recorder);
f(flugelhorn);
} ///:∼
您可以看到在Wind
下面添加了另一个继承级别,但是无论有多少个级别,virtual
机制都能正常工作。adjust( )
功能没有被Brass
和Woodwind
覆盖。发生这种情况时,会自动使用继承层次结构中“最接近”的定义;编译器保证对于一个虚函数总是有一些定义,所以你永远不会得到一个没有绑定到函数体的调用。
数组A[ ]
包含指向基类Instrument
的指针,所以向上转换发生在数组初始化的过程中。这个数组和函数f( )
将在后面的讨论中用到。
在对tune( )
的调用中,向上转换在每个不同类型的对象上执行,然而期望的行为总是发生。这可以描述为“向对象发送消息,并让对象担心如何处理它。”virtual
函数是当你试图分析一个项目时使用的透镜:基类应该出现在哪里,以及你可能想要如何扩展程序?然而,即使您在最初创建程序时没有发现正确的基类接口和虚函数,您也会在以后,甚至很久以后,当您开始扩展或维护程序时发现它们。这不是分析或设计错误;它仅仅意味着你没有或者不可能在第一时间知道所有的信息。由于 C++ 中紧密的类模块化,当这种情况发生时,它不是一个大问题,因为你在系统的一部分所做的改变不会像在 C 中那样传播到系统的其他部分。
C++ 如何实现后期绑定
延迟绑定是如何发生的?所有的工作都由编译器在幕后进行,当您要求时,它会安装必要的后期绑定机制(您通过创建虚函数来要求)。因为程序员经常从理解 C++ 中虚函数的机制中受益,所以本节将详细阐述编译器实现这种机制的方式。
关键字virtual
告诉编译器它不应该执行早期绑定。相反,它应该自动安装执行后期绑定所需的所有机制。这意味着如果你通过基类Instrument
的地址调用Brass
对象的play( )
,你将得到正确的函数。
为了实现这一点,典型的编译器为每个包含virtual
函数的类创建一个表(称为 VTABLE)。编译器将该特定类的虚函数地址放在 VTABLE 中。在每个具有虚函数的类中,它秘密地放置一个指针,称为 vpointer(缩写为 VPTR),指向该对象的 VTABLE。当您通过基类指针进行虚函数调用时(即,当您进行多态调用时),编译器会悄悄地插入代码来获取 VPTR,并在 VTABLE 中查找函数地址,从而调用正确的函数并导致后期绑定发生。
所有这些——为每个类设置 VTABLE、初始化 VPTR、插入虚拟函数调用的代码——都是自动发生的,所以您不必担心。使用虚函数,即使编译器不知道对象的具体类型,也能为对象调用正确的函数。下面几节将更详细地介绍这一过程。
存储类型信息
您可以看到,在任何一个类中都没有存储显式的类型信息。但是前面的例子和简单的逻辑告诉你,对象中一定存储了某种类型的信息;否则,该类型无法在运行时建立。这是真的,但是类型信息是隐藏的。参见清单 15-4 检查使用虚函数和不使用虚函数的类的大小。
清单 15-4 。说明了对象大小的比较(有虚函数和没有虚函数)
//: C15:Sizes.cpp
// Object sizes with/without virtual functions
#include <iostream>
using namespace std;
classNoVirtual {
int a;
public:
void x() const {}
int i() const { return 1; }
};
class OneVirtual {
int a;
public:
virtual void x() const {}
int i() const { return 1; }
};
class TwoVirtuals {
int a;
public:
virtual void x() const {}
virtual int i() const { return 1; }
};
int main() {
cout << "int: " << sizeof(int) << endl;
cout << "NoVirtual: "
<< sizeof(NoVirtual) << endl;
cout << "void* : " << sizeof(void*) << endl;
cout << "OneVirtual: "
<< sizeof(OneVirtual) << endl;
cout << "TwoVirtuals: "
<< sizeof(TwoVirtuals) << endl;
} ///:∼
如果没有虚函数,对象的大小正是您所期望的:单个int
的大小。对于OneVirtual
中的单个虚函数,对象的大小是NoVirtual
的大小加上void
指针的大小。结果是,如果你有一个或多个虚函数,编译器会在结构中插入一个指针(VPTR)。OneVirtual
和TwoVirtuals
没有尺寸差异。这是因为 VPTR 指向一个函数地址表。您只需要一个表,因为所有的虚函数地址都包含在这个表中。
此示例需要至少一个数据成员。如果没有数据成员,C++ 编译器会强制对象为非零大小,因为每个对象必须有一个不同的地址。如果你想象索引到一个零大小的对象数组,你就会明白。“虚拟”成员被插入到原本大小为零的对象中。当因为关键字virtual
而插入类型信息时,这将代替虚拟成员。试着注释掉清单 15-4 中所有类的int a
来看看这个。
描绘虚拟功能
为了准确理解使用虚函数时发生了什么,可视化幕后发生的活动是很有帮助的。图 15-1 是Instrument4.cpp
中指针A[ ]
数组的示意图。
图 15-1 。仪器指针数组
Instrument
指针数组没有特定的类型信息;它们各自指向一个类型为Instrument
的对象。Wind
、Percussion
、Stringed
、Brass
都属于这一类,因为它们是从Instrument
派生出来的(因此与Instrument
有相同的接口,可以响应相同的消息),所以它们的地址也可以放入数组中。然而,编译器不知道它们只不过是Instrument
对象,所以留给它自己的设备,它通常会调用所有函数的基类版本。但是在这种情况下,所有这些函数都是用关键字virtual
声明的,所以会发生一些不同的事情。
每次创建包含虚函数的类,或者从包含虚函数的类派生时,编译器都会为该类创建一个唯一的 VTABLE,如图右侧所示。在该表中,它放置了在该类或基类中声明为虚拟的所有函数的地址。如果不重写基类中声明为虚拟的函数,编译器将使用派生类中基类版本的地址。
注你可以在Brass
VTABLE 中的adjust
条目中看到这个。
然后它将 VPTR(在清单 15-4Sizes.cpp
中的中发现)放入类中。像这样使用简单继承时,每个对象只有一个 VPTR。VPTR 必须初始化为指向适当 VTABLE 的起始地址。(这发生在构造器中,稍后您将看到更详细的内容。)
一旦 VPTR 被初始化为合适的 VTABLE,对象实际上“知道”它是什么类型。但是这种自知是没有价值的,除非在调用虚函数的时候使用它。
当您通过基类地址调用虚函数时(这种情况下,编译器没有执行早期绑定所需的所有信息),会发生一些特殊的事情。编译器生成不同的代码来执行函数调用,而不是执行典型的函数调用,函数调用只是针对特定地址的汇编语言CALL
。图 15-2 显示了通过Instrument
指针对Brass
对象的adjust( )
调用的样子。(一个Instrument
参考产生了同样的结果。)
图 15-2 。调用以调整黄铜对象
编译器从指向对象起始地址的Instrument
指针开始。所有的Instrument
对象或者从Instrument
派生的对象都有它们的 VPTR 在同一个地方(通常在对象的开头),所以编译器可以从对象中挑选出 VPTR。VPTR 指向 VTABLE 的起始地址。不管对象的具体类型如何,所有 VTABLE 函数地址都以相同的顺序排列。play( )
第一,what( )
第二,adjust( )
第三。编译器知道,不管具体的对象类型是什么,adjust( )
函数都位于 VPTR+2 位置。因此,它不会说“在绝对位置调用函数Instrument::adjust
”(早期绑定——错误的操作),而是生成代码,实际上是说“在 VPTR+2 调用函数”。因为 VPTR 的获取和实际函数地址的确定发生在运行时,所以您得到了想要的后期绑定。你向对象发送一条消息,对象就知道如何处理它。
引擎盖下
查看由虚函数调用生成的汇编语言代码会很有帮助,因此您可以看到后期绑定确实正在发生。这是调用的一个编译器的输出
i.adjust(1);
在函数f(Instrument &i)
内部:
push 1
pushsi
movbx, word ptr [si]
call word ptr [bx+4]
addsp, 4
C++ 函数调用的参数和 C 函数调用一样,都是从右向左推送到堆栈上的(支持 C 的变量参数列表需要这个顺序),所以参数1
是先推送到堆栈上的。在函数的这一点上,寄存器si
(英特尔 X86 处理器架构的一部分)包含了i
的地址。这也被推到堆栈上,因为它是感兴趣的对象的起始地址。记住起始地址对应于this
的值,并且this
在每个成员函数调用之前作为一个参数被悄悄地推到堆栈上,所以成员函数知道它正在处理哪个特定的对象。因此,在成员函数调用之前,您总是会看到比压入堆栈的参数数量多一个的参数(除了没有this
的static
成员函数)。
现在必须执行实际的虚函数调用。首先,必须产生 VPTR,这样才能找到 VTABLE。对于这个编译器,VPTR 被插入到对象的开头,所以this
的内容对应于 VPTR。这条线
mov bx, word ptr [si]
取si
(也就是this
)指向的字,就是 VPTR。它将 VPTR 放入寄存器bx
。
包含在bx
中的 VPTR 指向 VTABLE 的起始地址,但是要调用的函数指针不在 VTABLE 的位置 0,而是在位置 2(因为它是列表中的第三个函数)。对于这种内存模型,每个函数指针都是两个字节长,所以编译器在 VPTR 上加 4 来计算正确函数的地址。注意这是一个在编译时建立的常量值,所以唯一重要的是位置 2 的函数指针是adjust( )
的指针。幸运的是,编译器会为您处理所有的簿记工作,并确保特定类层次结构的所有 VTABLEs 中的所有函数指针都以相同的顺序出现,而不管您在派生类中重写它们的顺序。
一旦计算出 VTABLE 中适当函数指针的地址,该函数就被称为。因此,在语句中一次提取并调用地址
call word ptr [bx+4]
最后,将堆栈指针向上移回,以清除调用前推送的参数。在 C 和 C++ 汇编代码中,您经常会看到调用者清除参数,但这可能会因处理器和编译器的实现而异。
安装 Vpointer
因为 VPTR 决定了对象的虚函数行为,所以您可以看到 VPTR 总是指向正确的 VTABLE 是多么重要。在 VPTR 被正确初始化之前,你永远不希望能够调用一个虚函数。当然,可以保证初始化的地方是在构造器中,但是Instrument
的例子都没有构造器。
这就是创建默认构造器的必要之处。在Instrument
的例子中,编译器创建了一个默认的构造器,它除了初始化 VPTR 之外什么也不做。当然,在您可以对所有的Instrument
对象做任何事情之前,这个构造器会被自动调用,所以您知道调用虚函数总是安全的。在构造器中自动初始化 VPTR 的含义将在后面的章节中讨论。
对象不同
重要的是要认识到向上转换只处理地址。如果编译器有一个对象,它知道确切的类型,因此(在 C++ 中)不会对任何函数调用使用后期绑定——或者至少,编译器不需要使用来使用后期绑定。为了提高效率,大多数编译器在调用对象的虚函数时会执行早期绑定,因为它们知道对象的确切类型。参见清单 15-5 中的示例。
清单 15-5 。说明早期绑定和虚函数
//: C15:Early.cpp
// Early binding & virtual functions
#include <iostream>
#include <string>
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;
// Late binding for both:
cout << "p1->speak() = " << p1->speak() << endl;
cout << "p2.speak() = " << p2.speak() << endl;
// Early binding (probably):
cout << "p3.speak() = " << p3.speak() << endl;
} ///:∼
在p1–>speak( )
和p2.speak( )
中使用了地址,这意味着信息不完整:p1
和p2
可以代表一个Pet
或从Pet
派生出来的某个东西的地址,所以必须使用虚拟机制。当调用p3.speak( )
时,没有歧义。编译器知道确切的类型,知道它是一个对象,所以它不可能是从Pet
派生的对象——它就是一个Pet
。因此,可能会使用早期绑定。但是,如果编译器不想这么辛苦,它仍然可以使用后期绑定,同样的行为也会发生。
为什么是虚函数?
此时,您可能想知道,“如果这种技术如此重要,如果它能一直进行‘正确’的函数调用,为什么它是一种选择呢?为什么我需要知道这件事?”
这是一个很好的问题,答案是 C++ 基本哲学的一部分:“T0”,因为它没有 T1 那么有效从前面的汇编语言输出中可以看出,建立虚函数调用需要两条(更复杂的)汇编指令,而不是一个简单的绝对地址调用。这需要代码空间和执行时间。
一些面向对象语言已经采取了这样的方法,即后期绑定是面向对象编程所固有的,它应该总是发生,它不应该是一个选项,用户不应该知道它。这是创建语言时的一个设计决策,这个特定的路径适用于许多语言。然而,C++ 来自 C 传统,效率是关键。毕竟,创建 C 语言是为了取代汇编语言来实现操作系统(从而使 Unix 操作系统比它的前辈更具可移植性)。发明 C++ 的主要原因之一是让 C 程序员更有效率。而当 C 程序员遇到 C++ 时问的第一个问题就是,“我会得到什么样的大小和速度影响?”如果答案是,“除了函数调用之外,一切都很好,因为你总是会有一点额外的开销,”许多人会坚持使用 C,而不是改为 C++。此外,内联函数是不可能的,因为虚函数必须有一个地址才能放入 VTABLE。所以虚函数是一个选项,语言默认为非虚函数,这是最快的配置。
因此,virtual
关键字用于效率调整。然而,在设计您的类时,您不应该担心效率调优。如果你打算使用多态,那么就在任何地方使用虚函数。在寻找加速代码的方法时,你只需要寻找可以被非虚拟化的函数(,在其他领域通常会有更大的收获;一个好的剖析器会比你通过猜测更好地找到瓶颈。
轶事证据表明,转向 C++ 的大小和速度影响在 C 的大小和速度的 10%以内,并且通常非常接近。你可能获得更好的大小和速度效率的原因是因为你可以用比使用 C 更小、更快的方式设计 C++ 程序。
抽象基类和纯虚函数
通常在设计中,您希望基类只为其派生类提供一个接口。也就是说,你不希望任何人实际上创建一个基类的对象,只是向上转换到它,以便它的接口可以被使用。这是通过使该类抽象来实现的,如果你给它至少一个纯虚函数,就会发生这种情况。您可以识别一个纯虚函数,因为它使用了virtual
关键字,后面跟有= 0
。如果有人试图创建抽象类的对象,编译器会阻止他们。这是一个允许您实施特定设计的工具。
当一个抽象类被继承时,所有的纯虚函数都必须被实现,否则继承的类也会变成抽象的。创建一个纯虚函数允许你把一个成员函数放到一个接口中,而不必被迫为这个成员函数提供一个可能没有意义的代码体。同时,一个纯虚函数迫使继承的类为它提供一个定义。
在所有的仪器示例中,基类Instrument
中的函数总是哑函数。如果调用了这些函数,那么一定是出了问题。这是因为Instrument
的目的是为从它派生的所有类创建一个公共接口。
建立通用接口的唯一原因是,它可以针对不同的子类型进行不同的表达(见图 15-3 )。它创建了一个基本形式,确定了所有派生类的共同点——除此之外别无其他。所以Instrument
是一个合适的抽象类。当您只想通过一个公共接口操作一组类,但是公共接口不需要有一个实现(或者至少是一个完整的实现)时,您可以创建一个抽象类。
图 15-3 。仪器的通用接口
如果你有一个像Instrument
这样的抽象类,那么这个类的对象几乎总是没有任何意义。也就是说,Instrument
只是用来表达接口,而不是特定的实现,所以创建一个只有Instrument
的对象是没有意义的,而且你可能想阻止用户这样做。这可以通过让Instrument
中的所有虚函数打印错误信息来实现,但是这会将错误信息的出现延迟到运行时,并且需要用户进行可靠的详尽测试。在编译时发现问题要好得多。
下面是用于纯虚拟声明的语法:
virtual void f() = 0;
通过这样做,您告诉编译器为 VTABLE 中的函数保留一个槽,但不要将地址放在那个特定的槽中。即使一个类中只有一个函数被声明为纯虚拟的,VTABLE 也是不完整的。
如果一个类的 VTABLE 不完整,当有人试图创建该类的对象时,编译器应该做什么?它不能安全地创建抽象类的对象,所以编译器会给出一个错误消息。因此,编译器保证了抽象类的纯度。通过使一个类成为抽象的,你可以确保客户程序员不会误用它。
清单 15-6 显示了修改后的Instrument4.cpp
使用纯虚函数。因为这个类除了纯虚函数什么都没有,所以它被称为纯抽象类。
清单 15-6 。说明了一个纯抽象类
//: C15:Instrument5.cpp
// Pure abstract base classes
#include <iostream>
using namespace std;
enum note { middleC, Csharp, Cflat }; // Etc.
class Instrument {
public:
// Pure virtual functions:
virtual void play(note) const = 0;
virtual char* what() const = 0;
// Assume this will modify the object:
virtual void adjust(int) = 0;
};
// Rest of the file is the same ...
class Wind : public Instrument {
public:
void play(note) const {
cout << "Wind::play" << endl;
}
char* what() const { return "Wind"; }
void adjust(int) {}
};
class Percussion : public Instrument {
public:
void play(note) const {
cout << "Percussion::play" << endl;
}
char* what() const { return "Percussion"; }
void adjust(int) {}
};
class Stringed : public Instrument {
public:
void play(note) const {
cout << "Stringed::play" << endl;
}
char* what() const { return "Stringed"; }
void adjust(int) {}
};
class Brass : public Wind {
public:
void play(note) const {
cout << "Brass::play" << endl;
}
char* what() const { return "Brass"; }
};
class Woodwind : public Wind {
public:
void play(note) const {
cout << "Woodwind::play" << endl;
}
char* what() const { return "Woodwind"; }
};
// Identical function from before:
void tune(Instrument&i) {
// ...
i.play(middleC);
}
// New function:
void f(Instrument&i) { i.adjust(1); }
int main() {
Wind flute;
Percussion drum;
Stringed violin;
Brass flugelhorn;
Woodwind recorder;
tune(flute);
tune(drum);
tune(violin);
tune(flugelhorn);
tune(recorder);
f(flugelhorn);
} ///:∼
纯虚函数很有用,因为它们明确了类的抽象性,并告诉用户和编译器如何使用它。
注意,纯虚函数防止抽象类通过值传递给函数。因此,这也是一种防止对象切片的方法(稍后将对此进行描述)。通过将类抽象为,您可以确保在向上转换到该类的过程中始终使用指针或引用。
仅仅因为一个纯虚函数阻止了 VTABLE 的完成,并不意味着你不想要其他函数的函数体。通常你会希望调用一个函数的基类版本,即使它是虚拟的。将公共代码放在尽可能靠近层次结构的根总是一个好主意。这不仅节省了代码空间,还允许轻松传播更改。
纯虚拟定义
在基类中提供一个纯虚函数的定义是可能的。你仍然告诉编译器不要允许抽象基类的对象,纯虚函数仍然必须在派生类中定义,以便创建对象。但是,您可能希望一些或所有派生类定义调用一段公共代码,而不是在每个函数中复制这段代码。清单 15-7 显示了纯虚拟定义的样子。
清单 15-7 。说明纯虚拟定义
//: C15:PureVirtualDefinitions.cpp
// 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 {
cout << "Pet::eat()" << endl;
}
void Pet::speak() const {
cout << "Pet::speak()" << endl;
}
class Dog : public Pet {
public:
// Use the common Pet code:
void speak() const { Pet::speak(); }
void eat() const { Pet::eat(); }
};
int main() {
Dog simba; // Richard's dog
simba.speak();
simba.eat();
} ///:∼
在Pet
VTABLE 中的槽仍然是空的,但是碰巧有一个同名的函数,您可以在派生类中调用它。
这个特性的另一个好处是,它允许您在不干扰现有代码的情况下,从普通虚拟变为纯虚拟。
继承和虚拟表
您可以想象当您执行继承并覆盖一些虚函数时会发生什么。编译器为您的新类创建一个新的 VTABLE,并使用您没有覆盖的任何虚函数的基类函数地址插入您的新函数地址。不管怎样,对于每个可以被创建的对象(也就是说,它的类没有纯虚数),VTABLE 中总是有一个完整的函数地址集,所以你永远无法调用一个不存在的地址(,那将是灾难性的)。
但是当你在派生类中继承并添加新的虚函数时会发生什么呢?参见清单 15-8 。
清单 15-8 。说明在派生类中添加虚函数
//: C15:AddingVirtuals.cpp
// Adding virtuals in derivation
#include <iostream>
#include <string>
using namespace std;
class Pet {
string pname;
public:
Pet(const string &petName) : pname(petName) {}
virtual string name() const { return pname; }
virtual string speak() const { return ""; }
};
class Dog : public Pet {
string name;
public:
Dog(const string &petName) : Pet(petName) {}
// New virtual function in the Dog class:
virtual string sit() const {
return Pet::name() + " sits";
}
string speak() const { // Override
return Pet::name() + " says 'Bark!'";
}
};
int main() {
Pet* p[] = {new Pet("generic"),new Dog("bob")};
cout << "p[0]->speak() = "
<< p[0]->speak() << endl;
cout << "p[1]->speak() = "
<< p[1]->speak() << endl;
//! cout << "p[1]->sit() = "
//! << p[1]->sit() << endl; // Illegal
} ///:∼
类Pet
包含两个虚函数,speak( )
和name( )
。Dog
增加了第三个虚函数sit( )
,也覆盖了speak( )
的含义。图 15-4 将帮助你想象正在发生的事情。它描述了编译器为Pet
和Dog
创建的 VTABLEs。
图 15-4 。宠物和狗的虚拟桌子
注意,编译器将speak( )
地址的位置映射到Dog
虚拟表中与Pet
虚拟表中完全相同的位置。类似地,如果一个类Pug
是从Dog
继承来的,那么它的版本sit( )
将被放在它的 VTABLE 中与它在Dog
中的位置完全相同的位置。这是因为(正如您在汇编语言示例中看到的那样)编译器生成的代码在 VTABLE 中使用一个简单的数字偏移量来选择虚函数。不管对象属于哪个子类型,它的 VTABLE 都以相同的方式布局,所以对虚函数的调用总是以相同的方式进行。
然而,在这种情况下,编译器只使用指向基类对象的指针。基类只有speak( )
和name( )
函数,所以编译器只允许你调用这些函数。如果它只有一个指向基类对象的指针,它怎么可能知道你正在使用一个Dog
对象呢?该指针可能指向其他类型,但没有sit( )
函数。在 VTABLE 中的那个点上,它可能有也可能没有其他的函数地址,但是在任何一种情况下,对那个 VTABLE 地址进行虚拟调用都不是您想要做的。所以编译器通过防止你对只存在于派生类中的函数进行虚拟调用来完成它的工作。
在一些不太常见的情况下,您可能知道指针实际上指向一个特定子类的对象。如果你想调用一个只存在于那个子类中的函数,那么你必须转换指针。您可以删除上一个程序产生的错误信息,如下所示:
((Dog*)p[1])->sit()
在这里,你碰巧知道p[1]
指向一个Dog
对象,但一般情况下你并不知道。如果你的问题是你必须知道所有对象的确切类型,你应该重新考虑一下,因为你可能没有正确使用虚函数。但是,在某些情况下,如果您知道保存在通用容器中的所有对象的确切类型,那么这种设计会发挥最佳效果(或者您别无选择)。这就是运行期类型识别【RTTI】的问题。
`RTTI 就是将基类指针向下转换为派生类指针(“向上”和“向下”是相对于典型的类图而言的,基类在顶部)。向上投射是自动发生的,没有强制,因为它是完全安全的。将强制转换为是不安全的,因为没有关于实际类型的编译时信息,所以你必须确切地知道对象是什么类型。如果你把它转换成错误的类型,你就有麻烦了。(RTTI 将在本章稍后描述,第 20 章也完全致力于这个主题。)
对象切片
使用多态时,传递对象的地址和通过值传递对象有明显的区别。您在这里看到的所有示例,以及实际上您应该看到的所有示例,都传递地址而不是值。这是因为地址都有相同的大小,所以传递派生类型的对象(通常是较大的对象)的地址与传递基类型的对象(通常是较小的对象)的地址是一样的。如前所述,这是使用多态的目标:操作基类型的代码也可以透明地操作派生类型对象。
如果你向上转换到一个对象而不是一个指针或引用,会发生一些让你吃惊的事情:对象被“分割”直到所有剩下的都是对应于你转换的目标类型的子对象。在清单 15-9 中,你可以看到当一个对象被切片时会发生什么。
清单 15-9 。说明对象切片
//: C15:ObjectSlicing.cpp
#include <iostream>
#include <string>
using namespace std;
class Pet {
string pname;
public:
Pet(const string& name) : pname(name) {}
virtual string name() const { return pname; }
virtual string description() const {
return "This is " + pname;
}
};
class Dog : public Pet {
string favoriteActivity;
public:
Dog(const string& name, const string& activity)
: Pet(name), favoriteActivity(activity) {}
string description() const {
return Pet::name() + " likes to " +
favoriteActivity;
}
};
void describe(Pet p) { // Slices the object
cout << p.description() << endl;
}
int main() {
Pet p("Bob");
Dog d("Peter", "sleep");
describe(p);
describe(d);
} ///:∼
函数describe( )
通过值传递一个类型为Pet
的对象。然后它为Pet
对象调用虚拟函数description( )
。在main( )
中,你可能期望第一个调用产生“这是鲍勃”,第二个产生“彼得喜欢睡觉”实际上,这两个调用都使用了description( )
的基类版本。
在这个项目中发生了两件事。首先,因为describe( )
接受一个Pet
对象(而不是指针或引用),任何对describe( )
的调用都会导致一个大小为Pet
的对象被压入堆栈,并在调用后被清除。这意味着如果一个从Pet
继承的类的对象被传递给describe( )
,编译器接受它,但是它只复制对象的Pet
部分。它切掉物体的衍生部分,如图图 15-5 所示。
图 15-5 。显示了从基本内容(宠物)派生的部分(狗)的切片
现在你可能想知道虚函数调用。Dog::description( )
利用了Pet
(仍然存在)和Dog
的一部分,它们已经不存在了,因为它们已经被切掉了!那么当调用虚函数时会发生什么呢?
因为对象是通过值传递的,所以您免于灾难。因此,编译器知道对象的精确类型,因为派生对象已被强制成为基对象。当通过值传递时,使用Pet
对象的复制构造器,它将 VPTR 初始化为Pet
VTABLE,并且只复制对象的Pet
部分。这里没有显式的复制构造器,所以编译器合成了一个。在所有的解释下,对象在切片过程中真正变成了一个Pet
。
对象切片实际上删除了现有对象的一部分,因为它将它复制到新对象中,而不是像使用指针或引用时那样简单地改变地址的含义。因此,向上转换成一个对象并不常见;事实上,这通常是需要注意和预防的事情。注意,在这个例子中,如果description( )
在基类中被做成一个纯虚函数(这并不是不合理的,因为它在基类中并不真正做任何事情),那么编译器会阻止对象切片,因为这不允许你“创建”一个基类的对象(当你通过值向上转换时就会发生这种情况)。这可能是纯虚函数最重要的价值:如果有人试图这样做,通过生成编译时错误消息来防止对象切片。
超载和越权
在第 14 章中,你看到了在基类中重新定义一个重载函数隐藏了该函数的所有其他基类版本。当涉及到虚函数时,行为会有一点不同。清单 15-10 显示了第 14 章的NameHiding.cpp
示例的修改版本。
清单 15-10 。证明虚函数限制重载
//: C15:NameHiding2.cpp
// Virtual functions restrict overloading
#include <iostream>
#include <string>
using namespace std;
class Base {
public:
virtual int f() const {
cout << "Base::f()\n";
return 1;
}
virtual void f(string) const {}
virtual void g() const {}
};
class Derived1 : public Base {
public:
void g() const {}
};
class Derived2 : public Base {
public:
// Overriding a virtual function:
int f() const {
cout << "Derived2::f()\n";
return 2;
}
};
class Derived3 : public Base {
public:
// Cannot change return type:
//! void f() const{ cout<< "Derived3::f()\n";}
};
class Derived4 : public Base {
public:
// Change argument list:
int f(int) const {
cout << "Derived4::f()\n";
return 4;
}
};
int main() {
string s("hello");
Derived1 d1;
int x = d1.f();
d1.f(s);
Derived2 d2;
x = d2.f();
//! d2.f(s); // string version hidden
Derived4 d4;
x = d4.f(1);
//! x = d4.f(); // f() version hidden
//! d4.f(s); // string version hidden
Base &br = d4; // Upcast
//! br.f(1); // Derived version unavailable
br.f(); // Base version available
br.f(s); // Base version available
} ///:∼
首先要注意的是,在Derived3
中,编译器不允许你改变被覆盖函数的返回类型(如果f( )
不是虚拟的,它会允许)。这是一个重要的限制,因为编译器必须保证你可以通过基类多态地调用这个函数,如果基类期望从f( )
返回一个int
,那么f( )
的派生类版本必须保持这个契约,否则事情将会失败。
第 14 章中显示的规则仍然有效:如果你覆盖了基类中的一个重载成员函数,其他重载版本将隐藏在派生类中。在main( )
中,测试Derived4
的代码显示,即使新版本的f( )
实际上没有覆盖现有的虚函数接口,也会发生这种情况——两个基类版本的f( )
都被f(int)
隐藏了。但是,如果将d4
向上转换为Base
,那么只有基类版本可用(因为这是基类契约所承诺的),派生类版本不可用(因为它没有在基类中指定)。
变型返回类型
上面的Derived3
类表明你不能在重写过程中修改虚函数的返回类型。这通常是正确的,但是有一种特殊情况,您可以稍微修改返回类型。如果你返回一个指向基类的指针或者引用,那么这个函数的重写版本可能会返回一个指向基类的指针或者引用。见清单 15-11 中的例子。
清单 15-11 。说明变体返回类型
//: C15:VariantReturn.cpp
// Returning a pointer or reference to a derived
// type during overriding
#include <iostream>
#include <string>
using namespace std;
class PetFood {
public:
virtual string foodType() const = 0;
};
class Pet {
public:
virtual string type() const = 0;
virtual PetFood* eats() = 0;
};
class Bird : public Pet {
public:
string type() const { return "Bird"; }
class BirdFood : public PetFood {
public:
string foodType() const {
return "Bird food";
}
};
// Upcast to base type:
PetFood* eats() { return &bf; }
private:
BirdFood bf;
};
class Cat : public Pet {
public:
string type() const { return "Cat"; }
class CatFood : public PetFood {
public:
string foodType() const { return "Birds"; }
};
// Return exact type instead:
CatFood* eats() { return &cf; }
private:
CatFood cf;
};
int main() {
Bird b;
Cat c;
Pet* p[] = { &b, &c, };
for(int i = 0; i < sizeof p / sizeof *p; i++)
cout << p[i]->type() << " eats "
<< p[i]->eats()->foodType() << endl;
// Can return the exact type:
Cat::CatFood* cf = c.eats();
Bird::BirdFood* bf;
// Cannot return the exact type:
//! bf = b.eats();
// Must downcast:
bf = dynamic_cast<Bird::BirdFood*>(b.eats());
} ///:∼
Pet::eats( )
成员函数返回一个指向PetFood
的指针。在Bird
中,这个成员函数和基类一样被重载,包括返回类型。也就是说,Bird::eats( )
将BirdFood
向上转换为PetFood
。
但是在Cat
中,eats( )
的返回类型是指向CatFood
的指针,是从PetFood
派生出来的类型。返回类型是从基类函数的返回类型继承的,这是编译的唯一原因。这样,合同仍然得到履行;eats( )
总是返回一个PetFood
指针。
如果你多形性地思考,这似乎没有必要。为什么不像Bird::eats( )
那样,将所有的返回类型都向上转换为PetFood*
?这通常是一个很好的解决方案,但是在main( )
的结尾,您会看到不同之处:Cat::eats( )
可以返回PetFood
的精确类型,而Bird::eats( )
的返回值必须向下转换为精确类型。
所以能够返回确切的类型更通用一点,不会因为自动向上转换而丢失特定的类型信息。然而,返回基本类型通常会解决你的问题,所以这是一个相当特殊的特性。
虚拟函数和构造器
当一个包含虚函数的对象被创建时,它的 VPTR 必须被初始化以指向正确的 VTABLE。这必须在有可能调用虚函数之前完成。正如您可能猜到的那样,因为构造器的工作是将一个对象变为现实,所以构造器的工作也是设置 VPTR。编译器秘密地将代码插入初始化 VPTR 的构造器的开头。并且如第 14 章所述,如果你没有为一个类显式创建构造器,编译器会为你合成一个。如果类有虚函数,合成的构造器将包含正确的 VPTR 初始化代码。这有几个含义。
首先是效率问题。使用内联函数的原因是为了减少小函数的调用开销。如果 C++ 没有提供内联函数,预处理器可能会被用来创建这些“宏”然而,预处理器没有访问或类的概念,因此不能用来创建成员函数宏。此外,对于必须由编译器插入隐藏代码的构造器,预处理宏根本不起作用。
在寻找效率漏洞时,你必须意识到编译器正在你的构造器中插入隐藏代码。它不仅必须初始化 VPTR,还必须检查this
的值(以防operator new( )
返回零)并调用基类构造器。综上所述,这段代码可能会影响到您认为很小的内联函数调用。特别是,构造器的大小可能会超过您从减少函数调用开销中获得的节省。如果您进行了大量的内联构造器调用,您的代码大小可能会增加,但在速度上没有任何好处。
当然,你可能不会马上把所有的小构造器都变成非内联的,因为它们更容易写成内联的。但是当您调整代码时,请记住考虑移除内联构造器。
构造器调用 的顺序
构造器和虚函数的第二个有趣的方面涉及到构造器调用的顺序以及在构造器中进行虚函数调用的方式。
基类构造器总是在继承类的构造器中调用。这是有意义的,因为构造器有一项特殊的工作:确保对象构建正确。派生类只能访问自己的成员,而不能访问基类的成员。只有基类构造器可以正确初始化自己的元素。因此,所有的构造器都被调用是很重要的;否则整个对象就不会被正确构造。这就是为什么编译器对派生类的每个部分都强制调用构造器。如果你没有在构造器初始化列表中显式调用基类构造器,它将调用默认构造器。如果没有默认的构造器,编译器会报错。
构造器调用的顺序很重要。当您继承时,您知道基类的所有信息,并且可以访问基类的任何public
和protected
成员。这意味着当你在派生类中时,你必须能够假设基类的所有成员都是有效的。在一个普通的成员函数中,构造已经发生了,所以对象的所有部分的所有成员都已经被建立了。但是,在构造器内部,您必须能够假设您使用的所有成员都已经构建好了。保证这一点的唯一方法是首先调用基类构造器。然后,当你在派生类构造器中时,你可以在基类中访问的所有成员都已经被初始化了。知道所有成员在构造器内部都是有效的,也是你应该尽可能在构造器初始化列表中初始化所有成员对象(即使用组合放置在类中的对象)的原因。如果遵循这种做法,可以假设当前对象的所有基类成员和成员对象都已经初始化。
构造器 内部虚函数的行为
构造器调用的层次结构带来了一个有趣的难题。如果你在一个构造器中调用一个虚函数,会发生什么?在一个普通的成员函数中,你可以想象会发生什么——虚拟调用在运行时被解析,因为对象不知道它是属于成员函数所在的类,还是从它派生的某个类。为了一致性,您可能认为这是构造器内部应该发生的事情。
事实并非如此。如果在构造器中调用虚函数,则只使用该函数的本地版本。也就是说,虚拟机制在构造器中不起作用。
这种行为有两个原因。从概念上讲,构造器的工作是将对象变为现实(这可不是一个普通的壮举)。在任何构造器内部,对象可能只是部分形成的;你只能知道基类对象被初始化了,却无法知道哪些类是从你那里继承来的。然而,虚函数调用“向前”或“向外”到达继承层次。它调用派生类中的函数。如果您可以在构造器中这样做,您将调用一个可能操纵尚未初始化的成员的函数,这肯定会导致灾难。
第二个原因是机械性的。当一个构造器被调用时,它做的第一件事就是初始化它的 VPTR。然而,它只能知道它是“当前”类型——构造器是为这种类型编写的。构造器代码完全不知道对象是否在另一个类的基类中。当编译器为该构造器生成代码时,它为该类的构造器生成代码,而不是基类,也不是从基类派生的类(因为类无法知道谁继承了它)。所以它使用的 VPTR 必须是针对那个类的的 VTABLE。VPTR 在对象的剩余生命周期内保持初始化为那个 VTABLE,除非这不是最后一次构造器调用。如果后来调用了一个派生程度更高的构造器,该构造器会将 VPTR 设置为 VTABLE,依此类推,直到最后一个构造器完成。VPTR 的状态由最后调用的构造器决定。这也是为什么构造器按照从基础到最派生的顺序被调用的另一个原因。
但是当所有这一系列构造器调用发生时,每个构造器都将 VPTR 设置为自己的 VTABLE。如果它对函数调用使用虚拟机制,那么它将只通过自己的 VTABLE 产生一个调用,而不是最具派生性的 VTABLE(在调用了所有构造器之后就会出现这种情况)。此外,许多编译器认识到构造器内部正在进行虚函数调用,并执行早期绑定,因为它们知道后期绑定将只产生对局部函数的调用。在这两种情况下,您都不会从构造器内部的虚函数调用中获得最初预期的结果。
析构函数和虚拟析构函数
不能对构造器使用virtual
关键字,但是析构函数可以而且通常必须是虚拟的。
构造器有一项特殊的工作,首先通过调用基构造器,然后按照继承的顺序调用更多的派生构造器(它还必须沿着 wa y 调用成员-对象构造器),将一个对象一片一片地放在一起。类似地,析构函数有一个特殊的工作:它必须分解一个可能属于一个类层次结构的对象。为此,编译器生成调用所有析构函数的代码,但是按照与构造器调用它们的相反的顺序。也就是说,析构函数从派生程度最高的类开始,一直向下到基类。这是安全和可取的做法,因为当前的析构函数总是知道基类成员是活动的。如果需要调用析构函数内部的基类成员函数,这样做是安全的。因此,析构函数可以执行自己的清理,然后调用下一个析构函数,后者将执行自己的清理,等等。每个析构函数都知道它的类是从派生的*,但不知道从它派生的是什么。*
您应该记住,构造器和析构函数是这种调用层次结构必须发生的唯一地方(因此编译器会自动生成适当的层次结构)。在所有其他函数中,无论是否是虚拟的,只有那个函数会被调用(,而不是基类版本)。在普通函数(虚拟或非)中调用同一函数的基类版本的唯一方法是显式调用该函数。
正常情况下,析构函数的作用已经足够了。但是如果你想通过一个指向它的基类的指针来操作一个对象(也就是说,通过它的泛型接口来操作对象),会发生什么呢?这项活动是面向对象编程的一个主要目标。当您想要为已经用new
在堆上创建的对象使用delete
这种类型的指针时,问题就出现了。如果指针指向基类,编译器只能知道在delete
期间调用基类版本的析构函数。
听起来熟悉吗?
这与创建虚函数来解决一般情况下的问题是一样的。幸运的是,虚函数适用于析构函数,就像它们适用于除了构造器之外的所有其他函数一样;参见清单 15-12 。
清单 15-12 。说明虚拟和非虚拟析构函数的行为
//: C15:VirtualDestructors.cpp
// Behavior of virtual vs. non-virtual destructor
#include <iostream>
using namespace std;
class Base1 {
public:
∼Base1() { cout << "∼Base1()\n"; }
};
class Derived1 : public Base1 {
public:
∼Derived1() { cout << "∼Derived1()\n"; }
};
class Base2 {
public:
virtual ∼Base2() { cout << "∼Base2()\n"; }
};
class Derived2 : public Base2 {
public:
∼Derived2() { cout << "∼Derived2()\n"; }
};
int main() {
Base1* bp = new Derived1; // Upcast
delete bp;
Base2* b2p = new Derived2; // Upcast
delete b2p;
} ///:∼
当你运行程序时,你会看到delete bp
只调用基类析构函数,而delete b2p
调用派生类析构函数,后跟基类析构函数,这是我们想要的行为。忘记创建析构函数virtual
是一个潜在的错误,因为它通常不会直接影响程序的行为,但是它会悄悄地引入内存泄漏。此外,一些破坏正在发生的事实会进一步掩盖问题。
即使析构函数像构造器一样是一个“异常”函数,析构函数也有可能是虚的,因为对象已经知道它是什么类型(而在构造过程中却不知道)。一旦一个对象被构造,它的 VPTR 就被初始化,因此虚函数调用就可以发生了。
纯虚拟析构函数
虽然纯虚析构函数在标准 C++ 中是合法的,但是在使用它们时有一个附加的约束:你必须为纯虚析构函数提供一个函数体。这似乎违反直觉;虚函数需要函数体怎么可能“纯”?但是如果你记住构造器和析构函数是特殊的操作,那就更有意义了,尤其是如果你记住一个类层次结构中的所有析构函数总是被调用的。如果你可以抛开纯虚拟析构函数的定义,那么在析构过程中会调用什么函数体呢?因此,编译器和链接器强制纯虚拟析构函数体的存在是绝对必要的。
如果它是纯的,但它必须有一个函数体,它的价值是什么?您将看到的纯虚析构函数和非纯虚析构函数之间的唯一区别是,纯虚析构函数确实导致基类是抽象的,因此您不能创建基类的对象(尽管如果基类的任何其他成员函数都是纯虚的,这也是正确的)。
然而,当你从一个包含纯虚析构函数的类继承一个类时,事情就有点混乱了。不像其他所有的纯虚函数,你不需要在派生类中提供一个纯虚析构函数的定义。清单 15-13 中的代码编译和链接就是证明。
清单 15-13 。说明纯虚拟析构函数
//: C15:UnAbstract.cpp
// Pure virtual destructors
// seem to behave strangely
classAbstractBase {
public:
virtual ∼AbstractBase() = 0;
};
AbstractBase::∼AbstractBase() {}
class Derived : public AbstractBase {};
// No overriding of destructor necessary?
int main() { Derived d; } ///:∼
通常,基类中的纯虚函数会导致派生类是抽象的,除非给它(以及所有其他纯虚函数)一个定义。但在这里,情况似乎并非如此。但是,请记住,如果您没有创建析构函数,编译器会自动为每个类创建一个析构函数定义。这就是这里发生的事情——基类析构函数被悄悄覆盖,因此定义由编译器提供,Derived
实际上不是抽象的。
这就带来了一个有趣的问题:纯虚拟析构函数的意义是什么?不像普通的纯虚函数,你必须给它一个函数体。在派生类中,你不需要提供定义,因为编译器会为你合成析构函数。那么常规的虚析构函数和纯虚析构函数有什么区别呢?
唯一的区别发生在只有一个纯虚函数的类中:析构函数。在这种情况下,析构函数的纯度的唯一作用是防止基类的实例化。如果有其他的纯虚函数,它们会阻止基类的实例化,但是如果没有其他的,那么纯虚析构函数会阻止基类的实例化。因此,虽然添加一个虚析构函数是必要的,但它是否是纯析构函数并不重要。
当你运行清单 15-14 中的代码时,你可以看到纯虚函数体是在派生类版本之后被调用的,就像其他任何析构函数一样。
清单 15-14 。说明纯虚析构函数需要一个函数体(也说明了虚函数体是在派生类版本之后调用的)
//: C15:PureVirtualDestructors.cpp
// Pure virtual destructors
// require a function body
#include <iostream>
using namespace std;
class Pet {
public:
virtual ∼Pet() = 0;
};
Pet::∼Pet() {
cout << "∼Pet()" << endl;
}
class Dog : public Pet {
public:
∼Dog() {
cout << "∼Dog()" << endl;
}
};
int main() {
Pet* p = new Dog; // Upcast
delete p; // Virtual destructor call
} ///:∼
作为一个指导原则,任何时候你在一个类中有一个虚函数,你应该立即添加一个虚析构函数(,即使它什么也不做)。这样,你可以确保以后不会有任何意外。
析构函数中的虚数
在毁灭过程中会发生一些你可能不会立即想到的事情。如果你在一个普通的成员函数中调用了一个虚函数,那么这个函数是使用后期绑定机制调用的。对于析构函数,不管是不是虚的,都不是这样。在析构函数内部,只调用成员函数的“本地”版本;虚拟机制被忽略了,正如你在清单 15-15 中看到的。
清单 15-15 。阐释析构函数内部的虚拟调用
//: C15:VirtualsInDestructors.cpp
// Virtual calls inside destructors
#include <iostream>
using namespace std;
class Base {
public:
virtual ∼Base() {
cout << "Base1()\n";
f();
}
virtual void f() { cout << "Base::f()\n"; }
};
class Derived : public Base {
public:
∼Derived() { cout << "∼Derived()\n"; }
void f() { cout << "Derived::f()\n"; }
};
int main() {
Base* bp = new Derived; // Upcast
delete bp;
} ///:∼
在析构函数调用期间,Derived::f( )
是被而不是调用,即使f( )
是虚拟的。
这是为什么呢?假设虚拟机制被用在析构函数内部。那么虚拟调用就有可能解析到一个比当前析构函数在继承层次上“更远的*(更衍生的)的函数。但是析构函数是从“外部的*”调用的(从最派生的析构函数一直到基本析构函数),所以实际调用的函数依赖于对象中已经被销毁的部分!相反,编译器在编译时解析调用,并且只调用函数的“本地”版本。注意,构造器也是如此(如前所述),但在构造器的情况下,类型信息不可用,而在析构函数中,信息(即 VPTR)是存在的,但它不可靠。
*创建基于对象的层次
在展示容器类Stack
和Stash
的过程中,贯穿本书的一个问题是“所有权问题”“所有者”指的是负责为已经动态创建(使用new
)的对象调用delete
的人或事。使用容器的问题是它们需要足够灵活来容纳不同类型的对象。为此,容器保存了void
指针,所以它们不知道自己保存的对象的类型。删除一个void
指针不会调用析构函数,所以容器不能负责清理它的对象。
在示例C14:InheritStack.cpp
( 清单 14-9 )中给出了一个解决方案,其中Stack
被继承到一个新类中,该类只接受和产生string
指针。因为它知道它只能保存指向string
对象的指针,所以它可以正确地删除它们。这是一个很好的解决方案,但是它要求您为您想要保存在容器中的每个类型继承一个新的容器类。
注意虽然现在这看起来很乏味,但实际上在第 16 章中,当引入模板时,它会工作得很好。
问题是你想让容器保存不止一种类型,但是你不想使用void
指针。另一种解决方案是通过强制容器中的所有对象从同一个基类继承来使用多态。也就是说,容器保存基类的对象,然后你可以调用虚函数——特别是,你可以调用虚析构函数来解决所有权问题。
这个解决方案使用所谓的单根层次或基于对象的层次(因为层次的根类通常被命名为“object”)。事实证明,使用单根层次结构还有许多其他好处;事实上,除了 C++ 之外,其他所有面向对象语言都强制使用这种层次结构。当你创建一个类时,你自动地直接或间接地从一个公共基类继承,这个基类是由语言的创建者建立的。在 C++ 中,人们认为强制使用这个公共基类会导致太多的开销,所以它被忽略了。但是,您可以选择在自己的项目中使用公共基类。
为了解决所有权问题,您可以为基类创建一个极其简单的Object
,它只包含一个虚拟析构函数。然后,Stack
可以保存从Object
继承的类。参见清单 15-16 。
清单 15-16 。说明了单根层次结构(也称为基于对象的层次结构)
//: C15:OStack.h
// Using a singly-rooted hierarchy
#ifndef OSTACK_H
#define OSTACK_H
class Object {
public:
virtual ∼Object() = 0;
};
// Required definition:
inline Object::∼Object() {}
class Stack {
struct Link {
Object* data;
Link* next;
Link(Object* dat, Link* nxt) :
data(dat), next(nxt) {}
}* head;
public:
Stack() : head(0) {}
∼Stack(){
while(head)
delete pop();
}
void push(Object* dat) {
head = new Link(dat, head);
}
Object* peek() const {
return head ? head->data : 0;
}
Object* pop() {
if(head == 0) return 0;
Object* result = head->data;
Link* oldHead = head;
head = head->next;
delete oldHead;
return result;
}
};
#endif // OSTACK_H ///:∼
为了通过将所有内容保存在头文件中来简化事情,纯虚拟析构函数的(必需)定义被内联到头文件中,并且pop( )
(可能被认为对于内联来说太大了)也被内联。
Link
对象现在持有指向Object
的指针,而不是void
指针,并且Stack
将只接受和返回Object
指针。现在Stack
更加灵活,因为它可以容纳许多不同类型的物品,但也会破坏掉留在Stack
上的任何物品。新的限制(当模板被应用于第 16 章中的问题时将最终被移除)是放置在Stack
上的任何东西必须从Object
继承。如果您是从零开始创建您的类,这很好,但是如果您已经有了一个像string
这样的类,并且希望能够放到Stack
中,那该怎么办呢?在这种情况下,新类必须同时是一个string
和一个Object
,这意味着它必须从两个类中继承。这被称为多重继承,这是本书后面整整一章的主题。在第 21 章中,你会看到多重继承充满了复杂性,这是一个你应该少用的特性。然而,在清单 15-17 中,一切都很简单,我们不会遇到任何多重继承的陷阱。
清单 15-17 。测试清单 15-16 中的 OStack
//: C15:OStackTest.cpp
//{T} OStackTest.cpp
#include "OStack.h" // To be INCLUDED from above
#include "../require.h" // To be INCLUDED from *[Chapter 9](09.html)*
#include <fstream>
#include <iostream>
#include <string>
using namespace std;
// Use multiple inheritance. We want
// both a string and an Object:
class MyString: public string, public Object {
public:
∼MyString() {
cout << "deleting string: " << *this << endl;
}
MyString(string s) : string(s) {}
};
int main(int argc, char* argv[]) {
requireArgs(argc, 1); // File name is argument
ifstream in(argv[1]);
assure(in, argv[1]);
Stack textlines;
string line;
// Read file and store lines in the stack:
while(getline(in, line))
textlines.push(new MyString(line));
// Pop some lines from the stack:
MyString* s;
for(int i = 0; i < 10; i++) {
if((s=(MyString*)textlines.pop())==0) break;
cout << *s << endl;
delete s;
}
cout << "Letting the destructor do the rest:" << endl;
} ///:∼
虽然这与先前版本的Stack
测试程序相似,但是您会注意到只有 10 个元素从堆栈中弹出,这意味着可能还有一些对象。因为Stack
知道它持有Object
,析构函数可以适当地清理东西,你会在程序的输出中看到这一点,因为MyString
对象在被销毁时打印消息。
创建容纳Object
s 的容器并不是一种不合理的方法——如果你有一个单根层次结构(由语言或每个类从Object
继承的要求强制执行)。在这种情况下,所有东西都保证是一个Object
,所以使用容器并不复杂。然而,在 C++ 中,你不能期望每个类都这样,所以如果你采用这种方法,你一定会被多重继承绊倒。你会在第 16 章中看到,模板以一种更简单、更优雅的方式解决了这个问题。
运算符重载
你可以像其他成员函数一样使用操作符virtual
。然而,实现virtual
操作符经常变得令人困惑,因为您可能在两个对象上操作,这两个对象都具有未知的类型。数学组件通常就是这种情况(为此,您经常重载运算符)。例如,考虑一个处理矩阵、向量和标量值的系统,这三者都是从类Math
派生的,如清单 15-18 所示。
清单 15-18 。用重载运算符 说明多态
//: C15:OperatorPolymorphism.cpp
// Polymorphism with overloaded operators
#include <iostream>
using namespace std;
class Matrix;
class Scalar;
class Vector;
class Math {
public:
virtual Math& operator*(Math& rv) = 0;
virtual Math& multiply(Matrix*) = 0;
virtual Math& multiply(Scalar*) = 0;
virtual Math& multiply(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:
Math& operator*(Math& rv) {
return rv.multiply(this); // 2nd dispatch
}
Math& multiply(Matrix*) {
cout << "Matrix * Scalar" << endl;
return *this;
}
Math& multiply(Scalar*) {
cout << "Scalar * Scalar" << endl;
return *this;
}
Math& multiply(Vector*) {
cout << "Vector * Scalar" << endl;
return *this;
}
};
class Vector : public Math {
public:
Math& operator*(Math& rv) {
return rv.multiply(this); // 2nd dispatch
}
Math& multiply(Matrix*) {
cout << "Matrix * Vector" << endl;
return *this;
}
Math& multiply(Scalar*) {
cout << "Scalar * Vector" << endl;
return *this;
}
Math& multiply(Vector*) {
cout << "Vector * Vector" << endl;
return *this;
}
};
int main() {
Matrix 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;
}
} ///:∼
为了简单起见,只有operator*
被重载。目标是能够将任意两个Math
对象相乘并产生期望的结果——注意,将一个矩阵乘以一个向量与将一个向量乘以一个矩阵是非常不同的操作。
问题是,在main( )
中,表达式m1 * m2
包含两个向上转换的Math
引用,因此包含两个未知类型的对象。虚函数只能进行一次分派,即确定一个未知对象的类型。为了确定这两种类型,在这个例子中使用了一种称为多重分派 的技术,由此看起来是单个虚拟函数调用的结果是第二个虚拟调用。进行第二次调用时,您已经确定了这两种类型的对象,并且可以执行适当的活动了。一开始它并不透明,但是如果你盯着这个例子看一会儿,它应该开始变得有意义了。
向下投射
正如您可能猜到的,既然有向上转换(在继承层次中向上移动)这样的事情,那么也应该有向下转换来向下移动层次。但是向上转换很容易,因为当你沿着继承层次向上移动时,这些类总是会收敛到更一般的类。也就是说,当你向上转换时,你总是明确地从一个祖先类中派生出来(通常只有一个,除了多重继承的情况),但是当你向下转换时,通常有几种可能性可以转换。更具体地说,Circle
是Shape
的一种类型(这是向上转换),但是如果你试图向下转换Shape
,它可能是Circle
、Square
、Triangle
等等。所以进退两难的问题是找到一种安全的方法。
注意但是一个更重要的问题是首先问问自己为什么向下转换,而不是仅仅使用多态来自动找出正确的类型。
C++ 提供了一个特殊的显式强制转换(在第 3 章中介绍)称为dynamic_cast
,这是一个类型安全的向下转换操作。当您使用dynamic_cast
尝试向下强制转换为特定类型时,只有在强制转换正确且成功的情况下,返回值才会是指向所需类型的指针;否则,它将返回零,表明这不是正确的类型。清单 15-19 包含了一个最小的例子。
清单 15-19 。举例说明一个动态 _ 强制转换
//: C15:DynamicCast.cpp
#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
// Try to cast it to Dog*:
Dog* d1 = dynamic_cast<Dog*>(b);
// Try to cast it to Cat*:
Cat* d2 = dynamic_cast<Cat*>(b);
cout << "d1 = " << (long)d1 << endl;
cout << "d2 = " << (long)d2 << endl;
} ///:∼
当您使用dynamic_cast
时,您必须使用真正的多态层次结构(具有虚函数的层次结构),因为dynamic_cast
使用存储在 VTABLE 中的信息来确定实际类型。这里,基类包含一个虚析构函数,这就足够了。在main( )
中,将Cat
指针向上转换为Pet
,然后尝试向下转换为Dog
指针和Cat
指针。两个指针都被打印出来,当你运行程序时,你会看到不正确的向下转换产生了一个零结果。当然,每当你向下转换时,你要负责检查以确保转换的结果是非零的。此外,你不应该假设指针会完全相同,因为有时指针调整会在向上转换和向下转换时发生(特别是在多重继承的情况下)。
运行一个dynamic_cast
需要一点额外的开销;不多,但是如果你做了大量的dynamic_cast
ing(在这种情况下,你应该认真地质疑你的程序设计)这可能会成为一个性能问题。在某些情况下,你可能在向下转换过程中知道一些特殊的东西,允许你确定你正在处理什么类型,在这种情况下,dynamic_cast
的额外开销变得不必要,你可以使用static_cast
来代替。清单 15-20 显示了它是如何工作的。
清单 15-20 。用 static_cast 演示类层次结构的导航
//: C15:StaticHierarchyNavigation.cpp
// Navigating class hierarchies with static_cast
#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 upcasting 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<Square*>(s);
if(cp != 0)
cout << "It's a circle!" << endl;
if(sp != 0)
cout << "It's 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
} ///:∼
在这个程序中,使用了 C++ 的运行时类型信息(RTTI)机制(一个在第 20 章中详细描述的新特性)。RTTI 允许您发现向上转换时丢失的类型信息。dynamic_cast
实际上是 RTTI 的一种形式。这里,typeid
关键字(在头文件<typeinfo>
中声明)用于检测指针的类型。您可以看到,向上转换的Shape
指针的类型被依次与Circle
指针和Square
指针进行比较,以查看是否匹配。RTTI 不仅仅是typeid
,你也可以想象使用一个虚拟函数来实现你自己的类型信息系统是相当容易的。
创建一个Circle
对象,并将地址向上转换为一个Shape
指针;表达式的第二个版本展示了如何使用static_cast
来更明确地表达向上转换。然而,由于向上强制转换总是安全的,而且这是一件很常见的事情,所以为向上强制转换进行显式强制转换只会造成混乱,而且没有必要。
RTTI 用于确定类型,然后static_cast
用于执行向下转换。但是请注意,在这个设计中,这个过程实际上与使用dynamic_cast
是一样的,客户端程序员必须做一些测试来发现实际上成功的转换。在使用static_cast
而不是dynamic_cast
之前,你通常会想要一个比清单 15-20 中的更确定的情况(同样,在使用dynamic_cast
之前,你要仔细检查你的设计)。
如果一个类层次结构没有virtual
函数(,这是一个有问题的设计)或者如果你有其他允许你安全向下转换的信息,静态向下转换比使用dynamic_cast
稍微快一点。另外,static_cast
不会像传统的施法者那样让你脱离等级,所以更安全。然而,静态导航类层次总是有风险的,除非有特殊情况,否则应该使用dynamic_cast
。
审查会议
- 多态—用 C++ 实现,带有虚函数—意为不同形式在面向对象编程中,你有相同的界面(基类 s 中的公共接口)和使用该界面的不同形式:虚函数的不同版本。
- 在这一章中你已经看到,如果不使用数据抽象和继承,就不可能理解,甚至不可能创建一个多态的例子。多态是一个不能被孤立看待的特性(例如,像 const 或 switch 语句),而是作为类关系的“大图的一部分,只能协同工作。
- 人们经常被 C++ 的其他非面向对象的特性所迷惑,比如重载和默认参数,它们有时被描述为面向对象。不要上当;如果不是后期绑定,就不是多态。
- 为了在你的程序中有效地使用多态——以及面向对象技术——你必须扩展你的编程视角,不仅包括单个类的成员和消息,还包括类之间的共性以及它们彼此之间的关系。
- 尽管这需要巨大的努力,但这是一场值得努力的斗争,因为结果是更快的程序开发、更好的代码组织、可扩展的程序和更容易的代码维护。
- 多态完善了语言的面向对象特性,但是 C++ 还有两个主要特性:模板(在第十六章第一节中介绍)和异常处理(在第十七章第三节中介绍)。这两个特性为您提供了与每个面向对象特性一样多的编程能力:抽象数据类型、继承和多态。*`