C 到 C++ 迁移手册(七)
十五、多态和虚函数
多态 ( 用虚函数在 C++ 中实现)是继数据抽象和继承之后,面向对象编程语言的第三个基本特征。
它提供了接口与实现分离的另一个维度,将什么与如何解耦。 What 表示接口细节, how 表示实现细节。你已经在第五章的中学习了隐藏实现。这里的想法类似于首先模拟一个系统(什么方面),而不用担心让一个工作系统自己就位(如何到位方面)。
多态允许改进代码组织和可读性,以及创建可扩展的程序,这些程序不仅可以在最初创建项目时“成长”,还可以在需要新功能时“成长”。
封装通过组合特征和行为来创建新的数据类型。访问控制通过制作细节private将接口与实现分开。这种机械的组织对于具有过程化编程背景的人来说很有意义。但是虚函数 在类型方面处理解耦。在第十四章的中,你看到了继承是如何允许把一个对象当作它自己的类型或它的基础类型。这种能力至关重要,因为它允许许多类型(派生自相同的基类型)被视为一种类型,并且一段代码可以平等地处理所有这些不同的类型。虚函数允许一种类型表达它与另一种相似类型的区别,只要它们都是从同一个基类型派生的。这种区别通过可以通过基类调用的函数的行为差异来表达。
在这一章中,你将学习虚函数,从简单的例子开始,除了函数的虚拟部分外,所有的东西都被去掉了。
C++ 程序员的进化
C 程序员似乎分三步习得 C++。首先,简单地说是“更好的 C”,因为 C++ 强迫你在使用它们之前声明所有的函数,并且对如何使用变量更加挑剔。用 C++ 编译器编译一个 C 程序,你就能发现其中的错误。
第二步是“基于对象”的 C++。这意味着您很容易看到将数据结构与作用于它的函数、构造器和析构函数的值以及一些简单的继承组合在一起的代码组织的好处。大多数已经使用 C 语言一段时间的程序员很快就看到了它的用处,因为无论何时他们创建一个库,这正是他们想要做的。有了 C++,你有了编译器的帮助。
你可能会在基于对象的层次上停滞不前,因为你可以很快到达那里,并且不需要太多的脑力劳动就可以获得很多好处。也很容易让人觉得你在创建数据类型——你创建类和对象,你向这些对象发送消息,一切都很好很整洁。
但是不要被骗了。如果您就此打住,您就错过了这门语言最伟大的部分,那就是向真正的面向对象编程的飞跃。您只能通过虚函数来实现这一点。
虚函数增强了类型的概念,而不仅仅是将代码封装在结构内部和墙的后面,因此它们无疑是新 C++ 程序员最难理解的概念。然而,它们也是理解面向对象编程的转折点。如果你没有使用虚函数,你还不了解 OOP。
因为虚函数与类型的概念密切相关,而类型是面向对象编程的核心,所以在传统的过程语言中没有虚函数的类似物。作为一个过程化的程序员,你在考虑虚函数时没有参照物,就像你在考虑语言中的其他特性一样。过程语言中的特性可以在算法层面上理解,但是虚函数只能从设计的角度来理解。
向上抛
在第十四章中,你看到了一个对象如何被用作它自己的类型或者它的基本类型。此外,还可以通过基本类型的地址对其进行操作。取一个对象的地址(或者是一个指针或者是一个引用)并把它当作基类的地址被称为向上转换,因为继承树是以基类在顶部的方式绘制的。
你也看到了一个问题的出现,它体现在清单 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++ 基本哲学的一部分:“??”,因为它没有 ?? 那么有效从前面的汇编语言输出中可以看出,建立虚函数调用需要两条(更复杂的)汇编指令,而不是一个简单的绝对地址调用。这需要代码空间和执行时间。
一些面向对象语言已经采取了这样的方法,即后期绑定是面向对象编程所固有的,它应该总是发生,它不应该是一个选项,用户不应该知道它。这是创建语言时的一个设计决策,这个特定的路径适用于许多语言。然而,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 将在本章稍后描述,第二十章也完全致力于这个主题。)
对象切片
使用多态时,传递对象的地址和通过值传递对象有明显的区别。您在这里看到的所有示例,以及实际上您应该看到的所有示例,都传递地址而不是值。这是因为地址都有相同的大小,所以传递派生类型的对象(通常是较大的对象)的地址与传递基类型的对象(通常是较小的对象)的地址是一样的。如前所述,这是使用多态的目标:操作基类型的代码也可以透明地操作派生类型对象。
如果你向上转换到一个对象而不是一个指针或引用,会发生一些让你吃惊的事情:对象被“分割”直到所有剩下的都是对应于你转换的目标类型的子对象。在清单 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( )在基类中被做成一个纯虚函数(这并不是不合理的,因为它在基类中并不真正做任何事情),那么编译器会阻止对象切片,因为这不允许你“创建”一个基类的对象(当你通过值向上转换时就会发生这种情况)。这可能是纯虚函数最重要的价值:如果有人试图这样做,通过生成编译时错误消息来防止对象切片。
超载和越权
在第十四章中,你看到了在基类中重新定义一个重载函数隐藏了该函数的所有其他基类版本。当涉及到虚函数时,行为会有一点不同。清单 15-10 显示了第十四章的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( )的派生类版本必须保持这个契约,否则事情将会失败。
第十四章中显示的规则仍然有效:如果你覆盖了基类中的一个重载成员函数,其他重载版本将隐藏在派生类中。在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 的构造器的开头。并且如第十四章所述,如果你没有为一个类显式创建构造器,编译器会为你合成一个。如果类有虚函数,合成的构造器将包含正确的 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对象的指针,所以它可以正确地删除它们。这是一个很好的解决方案,但是它要求您为您想要保存在容器中的每个类型继承一个新的容器类。
注意虽然现在这看起来很乏味,但实际上在第十六章中,当引入模板时,它会工作得很好。
问题是你想让容器保存不止一种类型,但是你不想使用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上的任何物品。新的限制(当模板被应用于第十六章中的问题时将最终被移除)是放置在Stack上的任何东西必须从Object继承。如果您是从零开始创建您的类,这很好,但是如果您已经有了一个像string这样的类,并且希望能够放到Stack中,那该怎么办呢?在这种情况下,新类必须同时是一个string和一个Object,这意味着它必须从两个类中继承。这被称为多重继承,这是本书后面整整一章的主题。在第二十一章中,你会看到多重继承充满了复杂性,这是一个你应该少用的特性。然而,在清单 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*
#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++ 中,你不能期望每个类都这样,所以如果你采用这种方法,你一定会被多重继承绊倒。你会在第十六章中看到,模板以一种更简单、更优雅的方式解决了这个问题。
运算符重载
你可以像其他成员函数一样使用操作符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++ 提供了一个特殊的显式强制转换(在第三章中介绍)称为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)机制(一个在第二十章中详细描述的新特性)。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++ 还有两个主要特性:模板(在第十六章第一节中介绍)和异常处理(在第十七章第三节中介绍)。这两个特性为您提供了与每个面向对象特性一样多的编程能力:抽象数据类型、继承和多态。*`
十六、模板介绍
继承和组合提供了一种重用目标代码的方法。C++ 中的模板特性提供了一种重用源代码的方法。
尽管 C++ 模板是一种通用的编程工具,但当它们被引入语言时,它们似乎不鼓励使用基于对象的容器类层次结构(在第十五章末尾演示)。
这一章不仅演示了模板的基础知识,也是对容器的介绍,容器是面向对象编程的基本组件,几乎完全是通过标准 C++ 库中的容器实现的。你会发现这本书通篇都在使用容器的例子——Stash和Stack——正是为了让你对容器感到舒服;在这一章中,还将添加迭代器的概念。虽然容器是使用模板的理想例子,但是模板还有许多其他的用途。
容器
假设你想创建一个栈,就像我们在整本书中所做的那样。清单 16-1 中的堆栈类将保存int s,以保持简单。
清单 16-1 。说明了一个简单的整数堆栈
//: C16:IntStack.cpp
// Simple integer stack
//{L} fibonacci
#include "fibonacci.h" // SEE ahead in this Section
#include "../require.h" // To be INCLUDED from *Chapter 9*
#include <iostream>
using namespace std;
class IntStack {
enum { ssize = 100 };
int stack[ssize];
int top;
public:
IntStack() : top(0) {}
void push(int i) {
require(top < ssize, "Too many push()es");
stack[top++] = i;
}
int pop() {
require(top > 0, "Too many pop()s");
return stack[--top];
}
};
int main() {
IntStack is;
// Add some Fibonacci numbers, for interest:
for(int i = 0; i < 20; i++)
is.push(fibonacci(i));
// Pop & print them:
for(int k = 0; k < 20; k++)
cout << is.pop() << endl;
} ///:∼
类IntStack是下推堆栈的一个简单例子。为了简单起见,这里创建了一个固定大小的类,但是您也可以修改它,通过从堆中分配内存来自动扩展,就像在本书中讨论的Stack类一样。
向堆栈中添加一些整数,并再次弹出它们。为了让这个例子更有趣,整数是用fibonacci()函数创建的,它生成传统的兔子繁殖数。清单 16-2 是声明该函数的头文件。
清单 16-2 。斐波那契数列生成器的头文件
//: C16:fibonacci.h
// Fibonacci number generator
int fibonacci(int n); ///:∼
清单 16-3 是实现。
清单 16-3 。斐波那契数生成器的实现
//: C16:fibonacci.cpp {O}
#include "../require.h"
int fibonacci(int n) {
const int sz = 100;
require(n < sz);
static int f[sz]; // Initialized to zero
f[0] = f[1] = 1;
// Scan for unfilled array elements:
int i;
for(i = 0; i < sz; i++)
if(f[i] == 0) break;
while(i <= n) {
f[i] = f[i-1] + f[i-2];
i++;
}
return f[n];
} ///:∼
这是一个相当有效的实现,因为它只生成一次数字。它使用了一个int的static数组,并且依赖于编译器将一个static数组初始化为零的事实。第一个for循环将索引i移动到第一个数组元素为零的位置,然后while循环将斐波那契数添加到数组中,直到到达所需的元素。但是请注意,如果通过元素n的斐波那契数已经初始化,它将完全跳过while循环。
对容器的需求
显然,整数栈不是一个重要的工具。当您开始使用new在堆上创建对象并用delete销毁它们时,就真正需要容器了。在一般的编程问题中,当你写程序时,你不知道你需要多少对象。例如,在一个空中交通管制系统中,你不希望限制系统可以处理的飞机数量。你不希望程序仅仅因为超出了某个数字而中止。在计算机辅助设计系统中,你要处理许多形状,但是只有用户决定(在运行时)你到底需要多少形状。一旦你注意到这种趋势,你会在你自己的编程环境中发现很多例子。
C 依靠虚拟内存来处理他们的“内存管理”的程序员经常发现new, delete的想法,以及容器类令人不安。显然,C 中的一种做法是创建一个巨大的全局数组,比程序需要的任何东西都要大。这可能不需要太多的思考(或者对malloc()和free()的了解),但是它确实产生了移植性不好并且隐藏了微妙错误的程序。
此外,如果在 C++ 中创建一个巨大的全局对象数组,构造器和析构函数的开销会大大降低速度。C++ 方法的工作要好得多:当你需要一个对象时,用new创建它,并把它的指针放在一个容器中。稍后,把它捞出来,做点什么。这样,您只创建绝对需要的对象。通常在程序启动时,你并不具备所有的初始化条件。new允许您等到环境中发生某些事情,然后才能真正创建对象。
因此,在最常见的情况下,您将创建一个容器来保存指向一些感兴趣的对象的指针。您将使用new创建这些对象,并将结果指针放入容器中(潜在地在这个过程中向上抛掷它),稍后当您想要对该对象做一些事情时将它取出。这种技术产生了最灵活、最通用的程序。
模板概述
现在出现了一个问题。你有一个保存整数的IntStack。但是你想要一堆形状、飞机、植物或其他东西。对于一种鼓吹可重用性的语言来说,每次都重新编写源代码似乎不是一个非常明智的方法。一定有更好的办法。
在这种情况下,有三种重用源代码的技术:C 方式,这里为了对比而介绍;Smalltalk 方法,它极大地影响了 c++;和模板的 C++ 方法。
C 解决方案
当然,你正试图摆脱 C 语言的方法,因为它混乱不堪,容易出错,而且完全不优雅。在这种方法中,您复制了一个Stack的源代码,并手工进行了修改,在这个过程中引入了新的错误。这当然不是一个非常有效的技术。
闲聊解决方案
Smalltalk(以及 Java,以其为例)采用了一种简单明了的方法:您想要重用代码,那么就使用继承。为了实现这一点,每个容器类保存通用基类Object的项目(类似于第十五章结尾的例子)。但是因为 Smalltalk 中的库是如此的重要,你不能从头开始创建一个类。相反,您必须始终从现有的类中继承它。你找到一个尽可能接近你想要的类,从它继承,并做一些改变。显然,这是一个好处,因为它最小化了您的工作(并且解释了为什么您在成为一个有效的 Smalltalk 程序员之前花费大量时间学习类库)。
但这也意味着 Smalltalk 中的所有类最终都是单个继承树的一部分。创建新类时,必须从该树的一个分支继承。树的大部分已经在那里了(它是 Smalltalk 类库),在树的根部是一个名为Object的类——每个 Smalltalk 容器持有的同一个类。
这是一个巧妙的技巧,因为这意味着 Smalltalk(和 Java)类层次结构中的每个类都是从Object派生的,所以每个类都可以保存在每个容器中(包括容器本身)。这种基于基本泛型类型(通常被命名为Object,在 Java 中也是如此)的单树层次被称为基于对象的层次。你可能听说过这个术语,并认为它是 OOP 中的一些新的基本概念,比如多态。它只是指一个以Object(或者类似的名字)为根的类层次结构和包含Object的容器类。
因为 Smalltalk 类库比 C++ 有更长的历史和经验,并且因为最初的 C++ 编译器没有容器类库,所以在 C++ 中复制 Smalltalk 类库似乎是个好主意。这是作为早期 C++ 实现的一个实验来完成的,因为它代表了大量的代码,所以许多人开始使用它。在尝试使用容器类的过程中,他们发现了一个问题。
问题是在 Smalltalk(和大多数其他 OOP 语言)中,所有的类都是自动从一个层次结构中派生出来的,但是在 C++ 中却不是这样。您可能有一个不错的基于对象的层次结构及其容器类,但是您可能会从另一个没有使用该层次结构的供应商那里购买一组形状类或飞机类。(首先,使用这种层次结构会增加开销,这是 C 程序员避免的。)在基于对象的层次结构中,如何将单独的类树插入到容器类中?图 16-1 显示了问题的样子。
图 16-1 。如何在基于对象的层次结构中将单独的类树(形状)插入到容器类中?
因为 C++ 支持多个独立的层次结构,所以 Smalltalk 的基于对象的层次结构不太好用。解决方案似乎显而易见。如果您可以有许多继承层次,那么您应该能够从多个类继承。多重继承会解决问题。解决方法见图 16-2 (类似的例子在第十五章末尾给出)。
图 16-2 。通过多重继承解决问题
现在OShape有了Shape的特征和行为,但是因为它也是从Object派生出来的,所以可以放在Container里。额外继承成OCircle, OSquare等。是必要的,这样那些类就可以向上转换到OShape中,从而保持正确的行为。你可以看到事情正在迅速变得混乱。
编译器供应商发明并包含了他们自己的基于对象的容器类层次结构,其中大部分已经被模板版本所取代。你可以争论多重继承对于解决一般的编程问题是必要的,但是你会在第二十一章中看到,除非在特殊情况下,否则最好避免它的复杂性。
模板解决方案
尽管具有多重继承的基于对象的层次结构在概念上很简单,但使用起来却很痛苦。在他的原著中,Stroustrup 展示了他认为的基于对象的层次结构的更好的替代方案。容器类是作为大型预处理器宏创建的,其参数可以替换为您想要的类型。当您想要创建一个容器来保存一个特定的类型时,您需要进行几次宏调用。
不幸的是,这种方法被所有现有的 Smalltalk 文献和编程经验所混淆,而且有点笨拙。基本上没人懂。
与此同时,Stroustrup 和贝尔实验室的 C++ 团队修改了他最初的宏方法,将其简化并从预处理器领域转移到编译器领域。这种新的代码替换设备被称为template,它代表了一种完全不同的重用代码的方式。模板重用源代码,而不是像继承和组合那样重用目标代码。容器不再保存一个名为Object的通用基类,而是保存一个未指定的参数。当你使用一个模板时,参数被编译器替换*,很像旧的宏方法,但是更干净和更容易使用。*
现在,当你想使用一个容器类时,不用担心继承或组合,你可以使用容器的模板版本,并为你的特定问题去掉一个特定的版本,如图 16-3 所示。
图 16-3 。通过源模板重用代码
编译器会为您完成这项工作,您最终会得到完成工作所需的容器,而不是笨拙的继承层次结构。在 C++ 中,模板实现了参数化类型的概念。模板方法的另一个好处是,可能不熟悉或不习惯继承的程序员新手仍然可以马上使用固定的容器类(就像我们在整本书中对vector所做的那样)。
模板语法
template关键字告诉编译器,后面的类定义将操作一个或多个未指定的类型。从模板生成实际的类代码时,必须指定这些类型,以便编译器可以替换它们。为了演示语法,请看清单 16-4 中一个产生边界检查数组的小例子。
清单 16-4 。说明模板语法
//: C16:Array.cpp
#include "../require.h"
#include <iostream>
using namespace std;
template<class T>
class Array {
enum { size = 100 };
T A[size];
public:
T& operator[](int index) {
require(index >= 0 && index < size,
"Index out of range");
return A[index];
}
};
int main() {
Array<int> ia;
Array<float> fa;
for(int i = 0; i < 20; i++) {
ia[i] = i * i;
fa[i] = float(i) * 1.414;
}
for(int j = 0; j < 20; j++)
cout << j << ": " << ia[j]
<< ", " << fa[j] << endl;
} ///:∼
您可以看到,除了行之外,它看起来像一个普通的类
template<class T>
它说T是替换参数,它代表一个类型名。此外,你会看到T在类中的任何地方都被使用,在那里你通常会看到容器持有的特定类型。
在Array中,用相同的函数插入和提取元素:重载的operator []。它返回一个引用,所以它可以用在等号的两边(即,既作为左值又作为右值)。请注意,如果索引超出界限,则使用require()函数打印一条消息。由于operator[]是一个inline,您可以使用这种方法来保证不发生数组边界冲突,然后删除发货代码的require()。
在main()中,您可以看到创建保存不同类型对象的Array是多么容易。当你说
Array<int> ia;
Array<float> fa;
编译器两次扩展Array模板(这被称为实例化,以创建两个新生成的类,你可以把它们想象成Array_int和Array_float。
注不同的编译器可能会以不同的方式修饰名字。
这些类就像您手工执行替换时生成的类一样,只是编译器会在您定义对象ia和fa时为您创建它们。还要注意,重复的类定义要么被编译器避免,要么被链接器合并。
非内联函数定义
当然,有时候你会想要非内联成员函数定义。在这种情况下,编译器需要在成员函数定义之前看到template声明。清单 16-5 显示了修改后的代码形式清单 16-4 显示了非内联成员定义。
清单 16-5 。说明非内联模板/函数定义
//: C16:Array2.cpp
// Non-inline template definition
#include "../require.h"
template<class T>
class Array {
enum { size = 100 };
T A[size];
public:
T& operator[](int index);
};
template<class T>
T& Array<T>::operator[](int index) {
require(index >= 0 && index < size,
"Index out of range");
return A[index];
}
int main() {
Array<float> fa;
fa[0] = 1.414;
} ///:∼
任何对模板类名的引用都必须附有模板参数列表,如Array<T>::operator[]所示。您可以想象,在内部,类名是用模板参数列表中的参数修饰的,以便为每个模板实例化产生一个惟一的类名标识符。
头文件
即使你创建了非内联函数定义,你通常也会想把一个模板的所有声明和定义放到一个头文件中。这似乎违反了正常的头文件规则“不要放入任何分配存储空间的东西”(防止链接时出现多个定义错误),但是模板定义是特殊的。任何以template<...>开头的东西都意味着编译器不会在那时为它分配存储,而是等待直到它被告知(通过模板实例化),并且在编译器和链接器中的某个地方有一个机制来删除同一个模板的多个定义。因此,为了方便使用,您几乎总是将整个模板声明和定义放在头文件中。
有时,您可能需要将模板定义放在单独的cpp文件中,以满足特殊需求(例如,强制模板实例化只存在于单个 Windows dll文件中)。大多数编译器都有某种机制允许这样做;要使用它,您必须研究特定编译器的文档。
有些人认为将实现的所有源代码放在一个头文件中会使人们有可能窃取和修改您的代码,如果他们从您这里购买一个库的话。这可能是一个问题,但这可能取决于你如何看待这个问题。他们购买的是产品还是服务?如果它是一个产品,那么你就要尽你所能去保护它,而且很可能你不想给源代码,只给编译好的代码。但是许多人将软件视为一种服务,甚至更多的是一种订阅服务。客户需要你的专业知识;他们希望你继续维护这段可重用的代码,这样他们就不必这么做了,这样他们就可以专注于完成他们的工作。我个人认为,大多数客户会把你视为一种有价值的资源,不希望危及他们与你的关系。至于那几个想偷而不是买或者做原创的,他们大概无论如何都跟不上你。
IntStack 作为模板
清单 16-6 显示了来自IntStack.cpp的容器和迭代器,使用模板实现为通用容器类。
清单 16-6 。说明了一个简单的整数堆栈模板
//: C16:StackTemplate.h
// Simple stack template
#ifndef STACKTEMPLATE_H
#define STACKTEMPLATE_H
#include "../require.h"
template<class T>
class StackTemplate {
enum { ssize = 100 };
T stack[ssize];
int top;
public:
StackTemplate() : top(0) {}
void push(const T& i) {
require(top < ssize, "Too many push()es");
stack[top++] = i;
}
T pop() {
require(top > 0, "Too many pop()s");
return stack[--top];
}
int size() { return top; }
};
#endif // STACKTEMPLATE_H ///:∼
请注意,模板对它所保存的对象做了某些假设。例如,StackTemplate假设在push()函数中有某种针对T的赋值操作。你可以说一个模板对于它能够容纳的类型“暗示了一个接口”。
换句话说,模板为 C++ 提供了一种弱类型机制,c++ 通常是一种强类型语言。弱类型并不要求一个对象必须是某种精确的类型才能被接受,它只要求它要调用的成员函数对于一个特定的对象来说是可用的。因此,弱类型代码可以应用于任何可以接受这些成员函数调用的对象,因此更加灵活。
清单 16-7 包含了测试模板的修改后的例子。
清单 16-7 。测试清单 16-6 中的整数堆栈模板
//: C16:StackTemplateTest.cpp
// Test simple stack template
//{L} fibonacci
#include "fibonacci.h"
#include "StackTemplate.h" // To be INCLUDED from above
#include <iostream>
#include <fstream>
#include <string>
using namespace std;
int main() {
StackTemplate<int> is;
for(int i = 0; i < 20; i++)
is.push(fibonacci(i));
for(int k = 0; k < 20; k++)
cout << is.pop() << endl;
ifstream in("StackTemplateTest.cpp");
assure(in, "StackTemplateTest.cpp");
string line;
StackTemplate<string> strings;
while(getline(in, line))
strings.push(line);
while(strings.size() > 0)
cout << strings.pop() << endl;
} ///:∼
唯一不同的是is的打造。在模板参数列表中,你可以指定栈和迭代器应该持有的对象类型。为了演示模板的通用性,还创建了一个StackTemplate来保存string。这是通过从源代码文件中读入行来测试的。
模板中的常量
模板参数不限于类类型;也可以使用内置类型。这些参数的值随后成为模板特定实例化的编译时常量。您甚至可以为这些参数使用默认值。清单 16-8 允许你在实例化过程中设置Array类的大小,但也提供了一个默认值。
清单 16-8 。说明如何使用内置类型作为模板参数
//: C16:Array3.cpp
// Built-in types as template arguments
#include "../require.h"
#include <iostream>
using namespace std;
template<class T, int size = 100>
class Array {
T array[size];
public:
T& operator[](int index) {
require(index >= 0 && index < size,
"Index out of range");
return array[index];
}
int length() const { return size; }
};
class Number {
float f;
public:
Number(float ff = 0.0f) : f(ff) {}
Number& operator=(const Number& n) {
f = n.f;
return *this;
}
operator float() const { return f; }
friend ostream&
operator<<(ostream& os, const Number& x) {
return os << x.f;
}
};
template<class T, int size = 20>
class Holder {
Array<T, size>* np;
public:
Holder() : np(0) {}
T& operator[](int i) {
require(0 <= i && i < size);
if(!np) np = new Array<T, size>;
return np->operator[](i);
}
int length() const { return size; }
∼Holder() { delete np; }
};
int main() {
Holder<Number> h;
for(int i = 0; i < 20; i++)
h[i] = i;
for(int j = 0; j < 20; j++)
cout << h[j] << endl;
} ///:∼
和以前一样,Array是一个检查过的对象数组,它可以防止索引越界。类Holder很像Array,除了它有一个指向Array的指针,而不是一个Array类型的嵌入对象。此指针未在构造器中初始化;初始化被延迟到第一次访问。这叫做惰性初始化;如果您正在创建许多对象,但不能访问所有对象,并且希望节省存储空间,那么您可能会使用这样的技术。
您会注意到,两个模板中的size值从来没有存储在类内部,但是它被用作成员函数中的数据成员。
作为模板堆叠和隐藏
在本书中反复出现的Stash和Stack容器类的“所有权”问题来自于这样一个事实,即这些容器不能确切地知道它们持有什么类型。离他们最近的是在OStackTest.cpp ( 清单 15-17 )的第十五章结尾看到的Stack``Object集装箱。
如果客户端程序员没有显式删除容器中保存的所有对象指针,那么容器应该能够正确删除这些指针。也就是说,容器"拥有任何没有被移除的对象,因此负责清理它们。问题是清理需要知道对象的类型,而创建一个通用容器类需要而不是知道对象的类型。然而,使用模板,您可以编写不知道对象类型的代码,并轻松地为您想要包含的每种类型实例化该容器的新版本。单个实例化的容器确实知道它们持有的对象的类型,因此可以调用正确的析构函数(假设,在涉及多态的典型情况下,已经提供了一个虚拟析构函数)。
对于Stack,这变得非常简单,因为所有的成员函数都可以合理地内联;见清单 16-9 。
清单 16-9 。示出了作为模板的堆栈的创建
//: C16:TStack.h
// The Stack as a template
#ifndef TSTACK_H
#define TSTACK_H
template<class T>
class Stack {
struct Link {
T* data;
Link* next;
Link(T* dat, Link* nxt):
data(dat), next(nxt) {}
}* head;
public:
Stack() : head(0) {}
∼Stack(){
while(head)
delete pop();
}
void push(T* dat) {
head = new Link(dat, head);
}
T* peek() const {
return head ? head->data : 0;
}
T* pop(){
if(head == 0) return 0;
T* result = head->data;
Link* oldHead = head;
head = head->next;
delete oldHead;
return result;
}
};
#endif // TSTACK_H ///:∼
如果你将此与第十五章的结尾的OStack.h例子进行比较,你会发现Stack实际上是相同的,除了Object被替换为T。测试程序也几乎相同,除了消除了从string和Object多重继承的必要性(甚至消除了对Object 本身的需求)。现在没有MyString类来宣布它的销毁,所以在清单 16-10 中添加了一个小的新类来显示一个Stack容器清理它的对象。
清单 16-10 。测试清单 16-9 中的模板堆栈
//: C16:TStackTest.cpp
//{T} TStackTest.cpp
#include "TStack.h" // To be INCLUDED from above
#include "../require.h"
#include <fstream>
#include <iostream>
#include <string>
using namespace std;
class X {
public:
virtual ∼X() { cout << "∼X " << endl; }
};
int main(int argc, char* argv[]) {
requireArgs(argc, 1); // File name is argument
ifstream in(argv[1]);
assure(in, argv[1]);
Stack<string> textlines;
string line;
// Read file and store lines in the Stack:
while(getline(in, line))
textlines.push(new string(line));
// Pop some lines from the stack:
string* s;
for(int i = 0; i < 10; i++) {
if((s = (string*)textlines.pop())==0) break;
cout << *s << endl;
delete s;
} // The destructor deletes the other strings.
// Show that correct destruction happens:
Stack<X> xx;
for(int j = 0; j < 10; j++)
xx.push(new X);
} ///:∼
X的析构函数是虚拟的,不是因为它在这里是必需的,而是因为xx以后可以用来保存从X派生的对象。
注意为string和X创建不同种类的Stack是多么容易。因为有了模板,您可以两全其美:Stack类的易用性和适当的清理。
模板化指针存储
将PStash代码重新组织成一个模板并不那么简单,因为有许多成员函数不应该被内联。然而,作为一个模板,那些函数定义仍然属于头文件(编译器和链接器处理任何多重定义问题)。清单 16-11 中的代码看起来与普通的PStash非常相似,除了你会注意到增量的大小(由inflate()使用)已经被模板化为一个带有默认值的非类参数,因此增量大小可以在实例化时修改(注意这意味着增量大小是固定的;您可能还会认为增量大小应该在对象的整个生命周期中是可变的)。
清单 16-11 。说明了模板化的指针存储
//: C16:TPStash.h
#ifndef TPSTASH_H
#define TPSTASH_H
template<class T, int incr = 10>
class PStash {
int quantity; // Number of storage spaces
int next; // Next empty space
T** storage;
void inflate(int increase = incr);
public:
PStash() : quantity(0), next(0), storage(0) {}
∼PStash();
int add(T* element);
T* operator[](int index) const; // Fetch
// Remove the reference from this PStash:
T* remove(int index);
// Number of elements in Stash:
int count() const { return next; }
};
template<class T, int incr>
int PStash<T, incr>::add(T* element) {
if(next >= quantity)
inflate(incr);
storage[next++] = element;
return(next - 1); // Index number
}
// Ownership of remaining pointers:
template<class T, int incr>
PStash<T, incr>::∼PStash() {
for(int i = 0; i < next; i++) {
delete storage[i]; // Null pointers OK
storage[i] = 0; // Just to be safe
}
delete []storage;
}
template<class T, int incr>
T* PStash<T, incr>::operator[](int index) const {
require(index >= 0,
"PStash::operator[] index negative");
if(index >= next)
return 0; // To indicate the end
require(storage[index] != 0,
"PStash::operator[] returned null pointer");
// Produce pointer to desired element:
return storage[index];
}
template<class T, int incr>
T* PStash<T, incr>::remove(int index) {
// operator[] performs validity checks:
T* v = operator[](index);
// "Remove" the pointer:
if(v != 0) storage[index] = 0;
return v;
}
template<class T, int incr>
void PStash<T, incr>::inflate(int increase) {
const int psz = sizeof(T*);
T** st = new T*[quantity + increase];
memset(st, 0, (quantity + increase) * psz);
memcpy(st, storage, quantity * psz);
quantity += increase;
delete []storage; // Old storage
storage = st; // Point to new memory
}
#endif // TPSTASH_H ///:∼
这里使用的默认增量很小,以保证调用inflate()发生。这样你可以确保它正常工作。
为了测试模板化PStash的所有权控制,清单 16-12 中的类将报告它自己的创建和销毁,并保证所有已创建的对象也被销毁。AutoCounter将只允许在堆栈上创建其类型的对象。
清单 16-12 。测试(模板化指针存储的)所有权控制
//: C16:AutoCounter.h
#ifndef AUTOCOUNTER_H
#define AUTOCOUNTER_H
#include "../require.h"
#include <iostream>
#include <set> // Standard C++ Library container
#include <string>
class AutoCounter {
static int count;
int id;
class CleanupCheck {
std::set<AutoCounter*> trace;
public:
void add(AutoCounter* ap) {
trace.insert(ap);
}
void remove(AutoCounter* ap) {
require(trace.erase(ap) == 1,
"Attempt to delete AutoCounter twice");
}
∼CleanupCheck() {
std::cout << "∼CleanupCheck()"<< std::endl;
require(trace.size() == 0,
"All AutoCounter objects not cleaned up");
}
};
static CleanupCheck verifier;
AutoCounter() : id(count++) {
verifier.add(this); // Register itself
std::cout << "created[" << id << "]"
<< std::endl;
}
// Prevent assignment and copy-construction:
AutoCounter(const AutoCounter&);
void operator=(const AutoCounter&);
public:
// You can only create objects with this:
static AutoCounter* create() {
return new AutoCounter();
}
∼AutoCounter() {
std::cout << "destroying[" << id
<< "]" << std::endl;
verifier.remove(this);
}
// Print both objects and pointers:
friend std::ostream& operator<<(
std::ostream& os, const AutoCounter& ac){
return os << "AutoCounter " << ac.id;
}
friend std::ostream& operator<<(
std::ostream& os, const AutoCounter* ac){
return os << "AutoCounter " << ac->id;
}
};
#endif // AUTOCOUNTER_H ///:∼
AutoCounter类做两件事。首先,它对AutoCounter的每个实例进行顺序编号:该编号的值保存在id中,该编号是使用static数据成员count生成的。
其次,也是更复杂的,嵌套类CleanupCheck的一个static实例(称为verifier)跟踪所有被创建和销毁的AutoCounter对象,并在您没有清理它们时向您报告(例如,是否有内存泄漏)。这个行为是使用标准 C++ 库中的set类完成的,这是一个很好的例子,说明了设计良好的模板如何使生活变得简单。
set类在它持有的类型上被模板化;在这里,它被实例化以保存AutoCounter指针。一个set将只允许添加每个不同对象的一个实例;在add()中,你可以看到这是通过set::insert()函数实现的。如果你试图添加已经添加的东西,实际上会通知你它的返回值;然而,由于添加了对象地址,你可以依靠 C++ 保证所有对象都有唯一的地址。
在remove()中,set::erase()用于从set中移除一个AutoCounter指针。返回值告诉您移除了元素的多少个实例;在我们的例子中,我们只期望零或一。然而,如果该值为零,则意味着该对象已经从set中删除,并且您正试图第二次删除它,这是一个编程错误,将通过require()报告。
CleanupCheck的析构函数通过确保set的大小为零来做最后的检查——这意味着所有的对象都被适当地清理了。如果它不为零,那么您有一个内存泄漏,这是通过require()报告的。
AutoCounter的构造器和析构函数向verifier对象注册和取消注册。注意,构造器、复制构造器和赋值操作符都是private,所以创建对象的唯一方法是使用static create()成员函数。这是一个简单的工厂的例子,它保证所有的对象都是在堆上创建的,所以verifier不会对赋值和复制构造感到困惑。
因为所有的成员函数都被内联了,所以实现文件的唯一原因是包含静态数据成员定义;参见清单 16-13 。
清单 16-13 。在清单 16-12 中实现自动计算器
//: C16:AutoCounter.cpp {O}
// Definition of static class members
#include "AutoCounter.h" // To be INCLUDED from above
AutoCounter::CleanupCheck AutoCounter::verifier;
int AutoCounter::count = 0;
///:∼
有了AutoCounter,你现在可以测试PStash的设备了。清单 16-14 不仅显示了PStash析构函数清理了它当前拥有的所有对象,还展示了AutoCounter类如何检测还没有被清理的对象。
清单 16-14 。使用自动计数器测试模板化的指针存储
//: C16:TPStashTest.cpp
//{L} AutoCounter
#include "AutoCounter.h"
#include "TPStash.h" // To be INCLUDED from above
#include <iostream>
#include <fstream>
using namespace std;
int main() {
PStash<AutoCounter> acStash;
for(int i = 0; i < 10; i++)
acStash.add(AutoCounter::create());
cout << "Removing 5 manually:" << endl;
for(int j = 0; j < 5; j++)
delete acStash.remove(j);
cout << "Remove two without deleting them:"
<< endl;
// ... to generate the cleanup error message.
cout << acStash.remove(5) << endl;
cout << acStash.remove(6) << endl;
cout << "The destructor cleans up the rest:"
<< endl;
// Repeat the test from earlier chapters:
ifstream in("TPStashTest.cpp");
assure(in, "TPStashTest.cpp");
PStash<string> stringStash;
string line;
while(getline(in, line))
stringStash.add(new string(line));
// Print out the strings:
for(int u = 0; stringStash[u]; u++)
cout << "stringStash[" << u << "] = "
<< *stringStash[u] << endl;
} ///:∼
当AutoCounter元素 5 和 6 从PStash中移除时,它们变成了调用者的责任,但是由于调用者从不清理它们,它们导致了内存泄漏,然后在运行时被AutoCounter检测到。
当您运行该程序时,您会看到错误消息并不像预期的那样具体。如果您使用AutoCounter中介绍的方案来发现您自己系统中的内存泄漏,您可能希望让它打印出关于尚未清理的对象的更详细的信息。有更复杂的方法可以做到这一点,在本书后面你会看到。
打开和关闭所有权
让我们回到所有权问题。通过值保存对象的容器通常不担心所有权,因为它们清楚地拥有它们所包含的对象。但是如果你的容器保存了指针(这在 C++、中更常见,尤其是在多态中),那么这些指针很可能还会在程序中的其他地方使用,你不一定要删除这个对象,因为这样程序中的其他指针就会引用一个被销毁的对象。为了防止这种情况发生,您必须在设计和使用容器时考虑所有权。
很多程序比这个简单很多,不会遇到所有权问题;一个容器保存只由该容器使用的对象的指针。在这种情况下,所有权非常简单:容器拥有它的对象。
处理所有权问题的最好方法是给客户程序员一个选择。这通常是通过构造器参数来实现的,默认为指明所有权(最简单的情况)。此外,可能有*、* get”和“set”功能来查看和修改容器的所有权。如果容器具有移除对象的功能,所有权状态通常会影响移除,因此您也可以在移除功能中找到控制销毁的选项。您可以为容器中的每个元素添加所有权数据,这样每个位置都会知道它是否需要销毁;这是引用计数的一种变体,只不过是容器而不是对象知道指向一个对象的引用的数量(见清单 16-15 )。
清单 16-15 。展示具有运行时可控所有权的堆栈
//: C16:OwnerStack.h
// Stack with runtime controllable ownership
#ifndef OWNERSTACK_H
#define OWNERSTACK_H
template<class T> class Stack {
struct Link {
T* data;
Link* next;
Link(T* dat, Link* nxt)
: data(dat), next(nxt) {}
}* head;
bool own;
public:
Stack(bool own = true) : head(0), own(own) {}
∼Stack();
void push(T* dat) {
head = new Link(dat,head);
}
T* peek() const {
return head ? head->data : 0;
}
T* pop();
bool owns() const { return own; }
void owns(bool newownership) {
own = newownership;
}
// Auto-type conversion: true if not empty:
operator bool() const { return head != 0; }
};
template<class T> T* Stack<T>::pop() {
if(head == 0) return 0;
T* result = head->data;
Link* oldHead = head;
head = head->next;
delete oldHead;
return result;
}
template<class T> Stack<T>::∼Stack() {
if(!own) return;
while(head)
delete pop();
}
#endif // OWNERSTACK_H ///:∼
默认行为是容器销毁它的对象,但是你可以通过修改构造器参数或者使用owns()读/写成员函数来改变这一点。
与您可能看到的大多数模板一样,整个实现包含在头文件中。清单 16-16 是一个练习所有权能力的小测试。
清单 16-16 。在清单 16-15 中测试堆栈的所有权
//: C16:OwnerStackTest.cpp
//{L} AutoCounter
#include "AutoCounter.h"
#include "OwnerStack.h" // To be INCLUDED from above
#include "../require.h"
#include <iostream>
#include <fstream>
#include <string>
using namespace std;
int main() {
Stack<AutoCounter> ac; // Ownership on
Stack<AutoCounter> ac2(false); // Turn it off
AutoCounter* ap;
for(int i = 0; i < 10; i++) {
ap = AutoCounter::create();
ac.push(ap);
if(i % 2 == 0)
ac2.push(ap);
}
while(ac2)
cout << ac2.pop() << endl;
// No destruction necessary since
// ac "owns" all the objects
} ///:∼
ac2对象不拥有你放入其中的对象,因此ac是负责所有权的“主”容器。如果在容器生命周期的中途,您想改变容器是否拥有它的对象,您可以使用owns()来实现。
也有可能改变所有权的粒度,使其基于一个对象接一个对象,但是这可能会使所有权问题的解决方案比问题更复杂。
按值保存对象
实际上,如果没有模板,在通用容器中创建对象的副本是一个复杂的问题。有了模板,事情就相对简单了;你只是说你持有的是对象而不是指针,如清单 16-17 所示。
清单 16-17 。使用模板说明按值保存对象
//: C16:ValueStack.h
// Holding objects by value in a Stack
#ifndef VALUESTACK_H
#define VALUESTACK_H
#include "../require.h"
template<class T, int ssize = 100>
class Stack {
// Default constructor performs object
// initialization for each element in array:
T stack[ssize];
int top;
public:
Stack() : top(0) {}
// Copy-constructor copies object into array:
void push(const T& x) {
require(top < ssize, "Too many push()es");
stack[top++] = x;
}
T peek() const { return stack[top]; }
// Object still exists when you pop it;
// it just isn't available anymore:
T pop() {
require(top > 0, "Too many pop()s");
return stack[--top];
}
};
#endif // VALUESTACK_H ///:∼
所包含对象的复制构造器通过按值传递和返回对象来完成大部分工作。在push()内部,对象到Stack数组的存储是通过T::operator=完成的。为了保证它的工作,一个名为SelfCounter的类跟踪对象的创建和复制构造;参见清单 16-18 。
清单 16-18 。使用 SelfCounter 使清单 16-17 中的 ValueStack 工作
//: C16:SelfCounter.h
#ifndef SELFCOUNTER_H
#define SELFCOUNTER_H
#include "ValueStack.h" // To be INCLUDED from above
#include <iostream>
class SelfCounter {
static int counter;
int id;
public:
SelfCounter() : id(counter++) {
std::cout << "Created: " << id << std::endl;
}
SelfCounter(const SelfCounter& rv) : id(rv.id){
std::cout << "Copied: " << id << std::endl;
}
SelfCounter operator=(const SelfCounter& rv) {
std::cout << "Assigned " << rv.id << " to "
<< id << std::endl;
return *this;
}
∼SelfCounter() {
std::cout << "Destroyed: "<< id << std::endl;
}
friend std::ostream& operator<<(
std::ostream& os, const SelfCounter& sc){
return os << "SelfCounter: " << sc.id;
}
};
#endif // SELFCOUNTER_H ///:∼
//: C16:SelfCounter.cpp {O}
#include "SelfCounter.h" // To be INCLUDED from above
int SelfCounter::counter = 0; ///:∼
//: C16:ValueStackTest.cpp
//{L} SelfCounter
#include "ValueStack.h"
#include "SelfCounter.h"
#include <iostream>
using namespace std;
int main() {
Stack<SelfCounter> sc;
for(int i = 0; i < 10; i++)
sc.push(SelfCounter());
// OK to peek(), result is a temporary:
cout << sc.peek() << endl;
for(int k = 0; k < 10; k++)
cout << sc.pop() << endl;
} ///:∼
当创建一个Stack容器时,为数组中的每个对象调用所包含对象的默认构造器。您最初会看到 100 个SelfCounter对象被莫名其妙地创建,但这只是数组初始化。这可能有点贵,但是在这样一个简单的设计中没有办法解决这个问题。如果您通过允许大小动态增长来使Stack更通用,则会出现更复杂的情况,因为在清单 16-18 所示的实现中,这将涉及创建一个新的(更大的)数组,将旧数组复制到新数组,并销毁旧数组(事实上,这是标准 C++ 库vector类所做的)。
迭代器简介
一个迭代器是一个对象,它遍历一个包含其他对象的容器,一次选择一个对象,但不提供对该容器实现的直接访问。迭代器提供了访问元素的标准方式,不管容器是否提供了直接访问元素的方式。您将看到迭代器最常与容器类结合使用;迭代器是标准 C++ 容器设计和使用中的一个基本概念。
在许多方面,迭代器是一个智能指针,事实上你会注意到迭代器通常模仿大多数指针操作。然而,与指针不同,迭代器被设计为安全的,所以你不太可能做等同于离开数组末尾的事情(*或者如果你这样做了,*你会更容易发现它)。
考虑本章的第一个例子。清单 16-19 增加了一个简单的迭代器。
清单 16-19 。用迭代器演示了一个简单的整数堆栈
//: C16:IterIntStack.cpp
// Simple integer stack with iterators
//{L} fibonacci
#include "fibonacci.h"
#include "../require.h"
#include <iostream>
using namespace std;
class IntStack {
enum { ssize = 100 };
int stack[ssize];
int top;
public:
IntStack() : top(0) {}
void push(int i) {
require(top < ssize, "Too many push()es");
stack[top++] = i;
}
int pop() {
require(top > 0, "Too many pop()s");
return stack[--top];
}
friend class IntStackIter;
};
// An iterator is like a "smart" pointer:
class IntStackIter {
IntStack& s;
int index;
public:
IntStackIter(IntStack& is) : s(is), index(0) {}
int operator++() { // Prefix
require(index < s.top,
"iterator moved out of range");
return s.stack[++index];
}
int operator++(int) { // Postfix
require(index < s.top,
"iterator moved out of range");
return s.stack[index++];
}
};
int main() {
IntStack is;
for(int i = 0; i < 20; i++)
is.push(fibonacci(i));
// Traverse with an iterator:
IntStackIter it(is);
for(int j = 0; j < 20; j++)
cout << it++ << endl;
} ///:∼
IntStackIter被创建为仅与IntStack一起工作。注意,IntStackIter是IntStack的friend,这使得它可以访问IntStack的所有private元素。
像指针一样,IntStackIter的工作是遍历IntStack并检索值。在这个简单的例子中,IntStackIter只能向前移动(使用operator++的前缀和后缀形式)。然而,定义迭代器的方式没有边界,除了它所使用的容器的约束。迭代器在关联容器内以任何方式移动并导致所包含的值被修改是完全可以接受的(在底层容器的限制内)。
习惯上,迭代器是用一个构造器创建的,这个构造器将迭代器附加到一个容器对象上,迭代器在它的生命周期中不会附加到不同的容器上。
注意迭代器通常又小又便宜,所以你很容易再做一个。
使用迭代器,您可以遍历堆栈中的元素而不弹出它们,就像指针可以在数组的元素中移动一样。然而,迭代器知道栈的底层结构以及如何遍历元素,所以即使你通过假装“增加一个指针”来遍历它们,底层发生的事情更复杂。这是迭代器的关键:它将从一个容器元素移动到下一个容器元素的复杂过程抽象成看起来像指针的东西。目标是让程序中的每个迭代器都有相同的接口,这样任何使用迭代器的代码都不会关心它指向什么;它只知道可以用同样的方式重新定位所有迭代器,所以迭代器指向的容器并不重要。通过这种方式,您可以编写更通用的代码。标准 C++ 库中的所有容器和算法都是基于迭代器的原理。
为了有助于使事情更加通用,最好能够说“每个容器都有一个名为iterator的相关类”,但是这通常会导致命名问题。解决方案是向每个容器添加一个嵌套的iterator类(注意,在本例中,“iterator”以小写字母开头,以便符合标准 C++ 库的样式)。清单 16-20 显示了带有嵌套iterator的IterIntStack.cpp。
清单 16-20 。展示了容器中迭代器的嵌套
//: C16:NestedIterator.cpp
// Nesting an iterator inside the container
//{L} fibonacci
#include "fibonacci.h"
#include "../require.h"
#include <iostream>
#include <string>
using namespace std;
class IntStack {
enum { ssize = 100 };
int stack[ssize];
int top;
public:
IntStack() : top(0) {}
void push(int i) {
require(top < ssize, "Too many push()es");
stack[top++] = i;
}
int pop() {
require(top > 0, "Too many pop()s");
return stack[--top];
}
class iterator;
friend class iterator;
class iterator {
IntStack& s;
int index;
public:
iterator(IntStack& is) : s(is), index(0) {}
// To create the "end sentinel" iterator:
iterator(IntStack& is, bool)
: s(is), index(s.top) {}
int current() const { return s.stack[index]; }
int operator++() { // Prefix
require(index < s.top,
"iterator moved out of range");
return s.stack[++index];
}
int operator++(int) { // Postfix
require(index < s.top,
"iterator moved out of range");
return s.stack[index++];
}
// Jump an iterator forward
iterator& operator+=(int amount) {
require(index + amount < s.top,
"IntStack::iterator::operator+=() "
"tried to move out of bounds");
index += amount;
return *this;
}
// To see if you're at the end:
bool operator==(const iterator& rv) const {
return index == rv.index;
}
bool operator!=(const iterator& rv) const {
return index != rv.index;
}
friend ostream&
operator <<(ostream& os, const iterator& it) {
return os << it.current();
}
};
iterator begin() { return iterator(*this); }
// Create the "end sentinel":
iterator end() { return iterator(*this, true);}
};
int main() {
IntStack is;
for(int i = 0; i < 20; i++)
is.push(fibonacci(i));
cout << "Traverse the whole IntStack\n";
IntStack::iterator it = is.begin();
while(it != is.end())
cout << it++ << endl;
cout << "Traverse a portion of the IntStack\n";
IntStack::iterator
start = is.begin(), end = is.begin();
start += 5, end += 15;
cout << "start = " << start << endl;
cout << "end = " << end << endl;
while(start != end)
cout << start++ << endl;
} ///:∼
在制作嵌套的friend类时,必须经历先声明类名,再声明为friend,再定义类的过程。否则,编译器会感到困惑。
迭代器中增加了一些新的变化。成员函数产生迭代器当前选择的容器中的元素。您可以使用operator+=将迭代器向前“跳转”任意数量的元素。此外,您将看到两个重载操作符,==和!=,它们将一个迭代器与另一个迭代器进行比较。这些可以比较任意两个IntStack::iterator,但是它们主要是用来测试迭代器是否像“真正的”标准 C++ 库迭代器一样位于序列的末尾。这个想法是两个迭代器定义一个范围,包括第一个迭代器指向的第一个元素,直到第二个迭代器指向的最后一个元素。所以如果你想在两个迭代器定义的范围内移动,你可以说类似于
while(start != end)
cout << start++ << endl;
其中start和end是范围内的两个迭代器。注意,end迭代器,我们通常称之为结束标记,并没有被解引用,它只是告诉你已经到了序列的末尾。因此,它代表“一个过去的结束。”
大多数情况下,您希望遍历容器中的整个序列,因此容器需要某种方式来产生迭代器,以指示序列的开始和结束标记。在这里,和在标准 C++ 库中一样,这些迭代器由容器成员函数begin()和end()产生。begin()使用第一个iterator构造器,默认指向容器的开头(这是压入堆栈的第一个元素)。然而,end()使用的第二个构造器是创建结束标记iterator所必需的。“在末尾”意味着指向堆栈的顶部,因为top总是指示堆栈上下一个可用但未使用的空间。这个iterator构造器接受第二个bool类型的参数,这是一个伪参数,用于区分两个构造器。
斐波那契数列再次用于填充main()中的IntStack,而iterator s 用于移动整个IntStack,并且也在序列的缩小范围内。
当然,下一步是通过将它的类型模板化来使代码通用化,这样你就可以保存任何类型,而不是被迫只保存ints;参见清单 16-21 。
清单 16-21 。用嵌套迭代器演示了一个简单的堆栈模板
//: C16:IterStackTemplate.h
// Simple stack template with nested iterator
#ifndef ITERSTACKTEMPLATE_H
#define ITERSTACKTEMPLATE_H
#include "../require.h"
#include <iostream>
template<class T, int ssize = 100>
class StackTemplate {
T stack[ssize];
int top;
public:
StackTemplate() : top(0) {}
void push(const T& i) {
require(top < ssize, "Too many push()es");
stack[top++] = i;
}
T pop() {
require(top > 0, "Too many pop()s");
return stack[--top];
}
class iterator; // Declaration required
friend class iterator; // Make it a friend
class iterator { // Now define it
StackTemplate& s;
int index;
public:
iterator(StackTemplate& st): s(st),index(0){}
// To create the "end sentinel" iterator:
iterator(StackTemplate& st, bool)
: s(st), index(s.top) {}
T operator*() const { return s.stack[index];}
T operator++() { // Prefix form
require(index < s.top,
"iterator moved out of range");
return s.stack[++index];
}
T operator++(int) { // Postfix form
require(index < s.top,
"iterator moved out of range");
return s.stack[index++];
}
// Jump an iterator forward
iterator& operator+=(int amount) {
require(index + amount < s.top,
" StackTemplate::iterator::operator+=() "
"tried to move out of bounds");
index += amount;
return *this;
}
// To see if you're at the end:
bool operator==(const iterator& rv) const {
return index == rv.index;
}
bool operator!=(const iterator& rv) const {
return index != rv.index;
}
friend std::ostream& operator<<(
std::ostream& os, const iterator& it) {
return os << *it;
}
};
iterator begin() { return iterator(*this); }
// Create the "end sentinel":
iterator end() { return iterator(*this, true);}
};
#endif // ITERSTACKTEMPLATE_H ///:∼
您可以看到从常规类到template的转换是相当透明的。这种先创建并调试一个普通类,然后将其制作成模板的方法通常被认为比从头开始创建模板更容易。
请注意,不只是说
friend iterator; // Make it a friend
这个代码说
friend class iterator; // Make it a friend
这很重要,因为名字*“迭代器”*已经在一个包含文件的作用域中。
代替current()成员函数,iterator有一个operator*来选择当前元素,这使得iterator看起来更像一个指针,这是一种常见的做法。
清单 16-22 显示了测试模板的修改后的例子。
清单 16-22 。测试清单 16-21 中的堆栈模板
//: C16:IterStackTemplateTest.cpp
//{L} fibonacci
#include "fibonacci.h"
#include "IterStackTemplate.h" // To be INCLUDED from above
#include <iostream>
#include <fstream>
#include <string>
using namespace std;
int main() {
StackTemplate<int> is;
for(int i = 0; i < 20; i++)
is.push(fibonacci(i));
// Traverse with an iterator:
cout << "Traverse the whole StackTemplate\n";
StackTemplate<int>::iterator it = is.begin();
while(it != is.end())
cout << it++ << endl;
cout << "Traverse a portion\n";
StackTemplate<int>::iterator
start = is.begin(), end = is.begin();
start += 5, end += 15;
cout << "start = " << start << endl;
cout << "end = " << end << endl;
while(start != end)
cout << start++ << endl;
ifstream in("IterStackTemplateTest.cpp");
assure(in, "IterStackTemplateTest.cpp");
string line;
StackTemplate<string> strings;
while(getline(in, line))
strings.push(line);
StackTemplate<string>::iterator
sb = strings.begin(), se = strings.end();
while(sb != se)
cout << sb++ << endl;
} ///:∼
迭代器的第一次使用只是从头到尾行进一次(并显示 end sentinel 工作正常)。在第二种用法中,您可以看到迭代器如何让您轻松地指定元素的范围(标准 C++ 库中的容器和迭代器几乎在任何地方都使用范围的概念)。重载的operator+=将start和end迭代器移动到is中元素范围的中间位置,这些元素被打印出来。请注意,在输出中,end sentinel 是包含在范围内的而不是,因此它可以是一个超出范围的结束标记,让您知道您已经通过了结束标记——但是您不能取消对 end sentinel 的引用,否则您可能会取消对空指针的引用。最后,为了验证StackTemplate与类对象一起工作,为string实例化了一个,并用源代码文件中的行填充,然后打印出来。
带迭代器的堆栈
您可以使用动态调整大小的Stack类重复这个过程,该类在整本书中都被用作示例。清单 16-23 显示了Stack类,其中嵌套了一个迭代器。
清单 16-23 。说明了带有嵌套迭代器的模板化堆栈
//: C16:TStack2.h
// Templatized Stack with nested iterator
#ifndef TSTACK2_H
#define TSTACK2_H
template<class T> class Stack {
struct Link {
T* data;
Link* next;
Link(T* dat, Link* nxt)
: data(dat), next(nxt) {}
}* head;
public:
Stack() : head(0) {}
∼Stack();
void push(T* dat) {
head = new Link(dat, head);
}
T* peek() const {
return head ? head->data : 0;
}
T* pop();
// Nested iterator class:
class iterator; // Declaration required
friend class iterator; // Make it a friend
class iterator { // Now define it
Stack::Link* p;
public:
iterator(const Stack<T>& tl) : p(tl.head) {}
// Copy-constructor:
iterator(const iterator& tl) : p(tl.p) {}
// The end sentinel iterator:
iterator() : p(0) {}
// operator++ returns boolean indicating end:
bool operator++() {
if(p->next)
p = p->next;
else p = 0; // Indicates end of list
return bool(p);
}
bool operator++(int) { return operator++(); }
T* current() const {
if(!p) return 0;
return p->data;
}
// Pointer dereference operator:
T* operator->() const {
require(p != 0,
"PStack::iterator::operator->returns 0");
return current();
}
T* operator*() const { return current(); }
// bool conversion for conditional test:
operator bool() const { return bool(p); }
// Comparison to test for end:
bool operator==(const iterator&) const {
return p == 0;
}
bool operator!=(const iterator&) const {
return p != 0;
}
};
iterator begin() const {
return iterator(*this);
}
iterator end() const { return iterator(); }
};
template<class T> Stack<T>::∼Stack() {
while(head)
delete pop();
}
template<class T> T* Stack<T>::pop() {
if(head == 0) return 0;
T* result = head->data;
Link* oldHead = head;
head = head->next;
delete oldHead;
return result;
}
#endif // TSTACK2_H ///:∼
你还会注意到这个类已经被修改为支持所有权,这现在可以工作了,因为这个类知道确切的类型(或者至少知道基本类型,,假设使用了虚拟析构函数,它将会工作)。默认情况下容器销毁它的对象,但是你要对你使用的任何指针负责。
迭代器很简单,物理上非常小——只有一个指针的大小。当你创建一个iterator时,它被初始化到链表的头部,你只能在链表中向前递增。如果想从头开始,可以创建一个新的迭代器,如果想记住列表中的某个点,可以从指向该点的现有迭代器创建一个新的迭代器(使用迭代器的复制构造器)。
要为迭代器引用的对象调用函数,可以使用current()函数、operator*或指针解引用operator->(迭代器中常见的情况)。后者有一个实现,看起来和current()一样,因为它返回一个指向当前对象的指针,但是不同的是,指针解引用操作符执行额外级别的解引用(参考第十二章)。
iterator类遵循你在清单 16-21 中看到的形式。class iterator嵌套在容器类中,它包含构造器来创建指向容器中元素的迭代器和“end sentinel”迭代器,容器类有begin()和end()方法来产生这些迭代器。整个实现包含在头文件中,所以没有单独的cpp文件。
清单 16-24 包含了一个测试迭代器的小测试。
清单 16-24 。测试清单 16-23 中的模板化堆栈
//: C16:TStack2Test.cpp
#include "TStack2.h" // To be INCLUDED from above
#include "../require.h"
#include <iostream>
#include <fstream>
#include <string>
using namespace std;
int main() {
ifstream file("TStack2Test.cpp");
assure(file, "TStack2Test.cpp");
Stack<string> textlines;
// Read file and store lines in the Stack:
string line;
while(getline(file, line))
textlines.push(new string(line));
int i = 0;
// Use iterator to print lines from the list:
Stack<string>::iterator it = textlines.begin();
Stack<string>::iterator* it2 = 0;
while(it != textlines.end()) {
cout << it->c_str() << endl;
it++;
if(++i == 10) // Remember 10th line
it2 = new Stack<string>::iterator(it);
}
cout << (*it2)->c_str() << endl;
delete it2;
} ///:∼
一个Stack被实例化以保存string对象,并用文件中的行填充。然后创建一个迭代器,用于遍历序列。通过从第一个迭代器复制构造第二个迭代器来记住第十行;稍后,这一行被打印出来,动态创建的迭代器被销毁。这里,动态对象创建用于控制对象的生存期。
带迭代器的 PStash
对于大多数容器类来说,拥有一个迭代器是有意义的。清单 16-25 展示了一个添加到PStash类中的迭代器。
清单 16-25 。说明了带有嵌套迭代器的模板化 PStash
//: C16:TPStash2.h
// Templatized PStash with nested iterator
#ifndef TPSTASH2_H
#define TPSTASH2_H
#include "../require.h"
#include <cstdlib>
template<class T, int incr = 20>
class PStash {
int quantity;
int next;
T** storage;
void inflate(int increase = incr);
public:
PStash() : quantity(0), storage(0), next(0) {}
∼PStash();
int add(T* element);
T* operator[](int index) const;
T* remove(int index);
int count() const { return next; }
// Nested iterator class:
class iterator; // Declaration required
friend class iterator; // Make it a friend
class iterator { // Now define it
PStash& ps;
int index;
public:
iterator(PStash& pStash)
: ps(pStash), index(0) {}
// To create the end sentinel:
iterator(PStash& pStash, bool)
: ps(pStash), index(ps.next) {}
// Copy-constructor:
iterator(const iterator& rv)
: ps(rv.ps), index(rv.index) {}
iterator& operator=(const iterator& rv) {
ps = rv.ps;
index = rv.index;
return *this;
}
iterator& operator++() {
require(++index <= ps.next,
"PStash::iterator::operator++ "
"moves index out of bounds");
return *this;
}
iterator& operator++(int) {
return operator++();
}
iterator& operator--() {
require(--index >= 0,
"PStash::iterator::operator-- "
"moves index out of bounds");
return *this;
}
iterator& operator--(int) {
return operator--();
}
// Jump interator forward or backward:
iterator& operator+=(int amount) {
require(index + amount < ps.next &&
index + amount >= 0,
"PStash::iterator::operator+= "
"attempt to index out of bounds");
index += amount;
return *this;
}
iterator& operator-=(int amount) {
require(index - amount < ps.next &&
index - amount >= 0,
"PStash::iterator::operator-= "
"attempt to index out of bounds");
index -= amount;
return *this;
}
// Create a new iterator that's moved forward
iterator operator+(int amount) const {
iterator ret(*this);
ret += amount; // op+= does bounds check
return ret;
}
T* current() const {
return ps.storage[index];
}
T* operator*() const { return current(); }
T* operator->() const {
require(ps.storage[index] != 0,
"PStash::iterator::operator->returns 0");
return current();
}
// Remove the current element:
T* remove(){
return ps.remove(index);
}
// Comparison tests for end:
bool operator==(const iterator& rv) const {
return index == rv.index;
}
bool operator!=(const iterator& rv) const {
return index != rv.index;
}
};
iterator begin() { return iterator(*this); }
iterator end() { return iterator(*this, true);}
};
// Destruction of contained objects:
template<class T, int incr>
PStash<T, incr>::∼PStash() {
for(int i = 0; i < next; i++) {
delete storage[i]; // Null pointers OK
storage[i] = 0; // Just to be safe
}
delete []storage;
}
template<class T, int incr>
int PStash<T, incr>::add(T* element) {
if(next >= quantity)
inflate();
storage[next++] = element;
return(next - 1); // Index number
}
template<class T, int incr> inline
T* PStash<T, incr>::operator[](int index) const {
require(index >= 0,
"PStash::operator[] index negative");
if(index >= next)
return 0; // To indicate the end
require(storage[index] != 0,
"PStash::operator[] returned null pointer");
return storage[index];
}
template<class T, int incr>
T* PStash<T, incr>::remove(int index) {
// operator[] performs validity checks:
T* v = operator[](index);
// "Remove" the pointer:
storage[index] = 0;
return v;
}
template<class T, int incr>
void PStash<T, incr>::inflate(int increase) {
const int tsz = sizeof(T*);
T** st = new T*[quantity + increase];
memset(st, 0, (quantity + increase) * tsz);
memcpy(st, storage, quantity * tsz);
quantity += increase;
delete []storage; // Old storage
storage = st; // Point to new memory
}
#endif // TPSTASH2_H ///:∼
这个文件的大部分内容是将前面的PStash和嵌套的iterator直接翻译成一个模板。然而,这一次,操作符返回对当前迭代器的引用,这是更典型、更灵活的方法。
析构函数为所有包含的指针调用delete,因为类型是由模板捕获的,所以会发生适当的析构。您应该知道,如果容器保存指向基类类型的指针,该类型应该有一个virtual析构函数,以确保在将地址被向上转换的派生对象放入容器时,对它们进行适当的清理。
PStash::iterator在其生命周期中遵循绑定到单个容器对象的迭代器模型。此外,copy-constructor 允许您创建一个新的迭代器,指向与创建它的现有迭代器相同的位置,有效地在容器中创建一个书签。operator+=和operator-=成员函数允许你移动迭代器很多点,同时尊重容器的边界。重载的递增和递减操作符将迭代器移动一个位置。operator+产生一个新的迭代器,它向前移动了加数的数量。如清单 16-11 中的所示,指针解引用操作符用于对迭代器所引用的元素进行操作,remove()通过调用容器的remove()销毁当前对象。
与清单 16-11 中的相同类型的代码(就像标准 C++ 库容器一样)被用于创建结束标记:第二个构造器,容器的end()成员函数,以及用于比较的operator==和operator!=。
清单 16-26 创建并测试了两种不同的Stash对象,一种用于一个名为Int的新类,它声明了它的构造和销毁,另一种用于保存标准库string类的对象。
清单 16-26 。创建和测试两个不同的 Stash 对象
//: C16:TPStash2Test.cpp
#include "TPStash2.h" // To be INCLUDED from above
#include "../require.h"
#include <iostream>
#include <vector>
#include <string>
using namespace std;
class Int {
int i;
public:
Int(int ii = 0) : i(ii) {
cout << ">" << i << ' ';
}
∼Int() { cout << "∼" << i << ' '; }
operator int() const { return i; }
friend ostream&
operator <<(ostream& os, const Int& x) {
return os << "Int: " << x.i;
}
friend ostream&
operator <<(ostream& os, const Int* x) {
return os << "Int: " << x->i;
}
};
int main() {
{ // To force destructor call
PStash<Int> ints;
for(int i = 0; i < 30; i++)
ints.add(new Int(i));
cout << endl;
PStash<Int>::iterator it = ints.begin();
it += 5;
PStash<Int>::iterator it2 = it + 10;
for(; it != it2; it++)
delete it.remove(); // Default removal
cout << endl;
for(it = ints.begin();it != ints.end();it++)
if(*it) // Remove() causes "holes"
cout << *it << endl;
} // "ints" destructor called here
cout << "\n-------------------\n";
ifstream in("TPStash2Test.cpp");
assure(in, "TPStash2Test.cpp");
// Instantiate for String:
PStash<string> strings;
string line;
while(getline(in, line))
strings.add(new string(line));
PStash<string>::iterator sit = strings.begin();
for(; sit != strings.end(); sit++)
cout << **sit << endl;
sit = strings.begin();
int n = 26;
sit += n;
for(; sit != strings.end(); sit++)
cout << n++ << ": " << **sit << endl;
} ///:∼
为了方便起见,Int对于Int&和Int*都有一个关联的ostream operator<<。
main()中的第一个代码块用大括号括起来,以强制销毁PStash<Int>,从而由析构函数自动清除。手工移除和删除一系列元素,表明PStash清理了剩余的元素。
对于PStash的两个实例,一个迭代器被创建并用于遍历容器。注意使用这些结构产生的优雅;您不会被使用数组的实现细节所困扰。你告诉容器和迭代器对象做什么,而不是怎么做。这使得解决方案更容易概念化、构建和修改。
为什么是迭代器?
到目前为止,您已经看到了迭代器的机制,但是理解它们为什么如此重要需要一个更复杂的例子。
在真正的面向对象程序中,多态、动态对象创建和容器一起使用是很常见的。容器和动态对象创建解决了不知道需要多少或什么类型的对象的问题。如果容器被配置为保存指向基类对象的指针,则每次将派生类指针放入容器时都会发生向上转换(具有相关的代码组织和可扩展性好处)。作为本章的最后一段代码,清单 16-27 也将汇集到目前为止你所学的所有内容的各个方面。如果你能遵循这个例子,那么你就为接下来的章节做好了准备。
清单 16-27 。把这一切放在一起
//: C16:Shape.h
#ifndef SHAPE_H
#define SHAPE_H
#include <iostream>
#include <string>
class Shape {
public:
virtual void draw() = 0;
virtual void erase() = 0;
virtual ∼Shape() {}
};
class Circle : public Shape {
public:
Circle() {}
∼Circle() { std::cout << "Circle::∼Circle\n"; }
void draw() { std::cout << "Circle::draw\n";}
void erase() { std::cout << "Circle::erase\n";}
};
class Square : public Shape {
public:
Square() {}
∼Square() { std::cout << "Square::∼Square\n"; }
void draw() { std::cout << "Square::draw\n";}
void erase() { std::cout << "Square::erase\n";}
};
class Line : public Shape {
public:
Line() {}
∼Line() { std::cout << "Line::∼Line\n"; }
void draw() { std::cout << "Line::draw\n";}
void erase() { std::cout << "Line::erase\n";}
};
#endif // SHAPE_H ///:∼
假设您正在创建一个程序,允许用户编辑和生成不同种类的绘图。每个绘图都是一个对象,包含一组Shape对象;见清单 16-27 。
这使用了基类中虚函数的经典结构,这些虚函数在派生类中被重写。注意,Shape类包含了一个virtual析构函数,你应该自动添加到任何带有virtual函数的类中。如果一个容器保存了指向Shape对象的指针或引用,那么当这些对象的virtual析构函数被调用时,一切都将被正确地清理。
清单 16-28 中的每种不同类型的绘图都使用了不同种类的模板化容器类:本章已经定义的PStash和Stack,以及标准 C++ 库中的vector类。容器的“使用”非常简单;一般来说,继承可能不是最好的方法(组合可能更有意义),但在这种情况下,继承是一种简单的方法,它不会偏离示例中的观点。
清单 16-28 。使用清单 16-27 中的头文件
//: C16:Drawing.cpp
#include <vector> // Uses Standard vector too!
#include "TPStash2.h"
#include "TStack2.h"
#include "Shape.h" // To be INCLUDED from above
using namespace std;
// A Drawing is primarily a container of Shapes:
class Drawing : public PStash<Shape> {
public:
∼Drawing() { cout << "∼Drawing" << endl; }
};
// A Plan is a different container of Shapes:
class Plan : public Stack<Shape> {
public:
∼Plan() { cout << "∼Plan" << endl; }
};
// A Schematic is a different container of Shapes:
class Schematic : public vector<Shape*> {
public:
∼Schematic() { cout << "∼Schematic" << endl; }
};
// A function template:
template<class Iter>
void drawAll(Iter start, Iter end) {
while(start != end) {
(*start)->draw();
start++;
}
}
int main() {
// Each type of container has
// a different interface:
Drawing d;
d.add(new Circle);
d.add(new Square);
d.add(new Line);
Plan p;
p.push(new Line);
p.push(new Square);
p.push(new Circle);
Schematic s;
s.push_back(new Square);
s.push_back(new Circle);
s.push_back(new Line);
Shape* sarray[] = {
new Circle, new Square, new Line
};
// The iterators and the template function
// allow them to be treated generically:
cout << "Drawing d:" << endl;
drawAll(d.begin(), d.end());
cout << "Plan p:" << endl;
drawAll(p.begin(), p.end());
cout << "Schematic s:" << endl;
drawAll(s.begin(), s.end());
cout << "Array sarray:" << endl;
// Even works with array pointers:
drawAll(sarray,
sarray + sizeof(sarray)/sizeof(*sarray));
cout << "End of main" << endl;
} ///:∼
不同类型的容器都包含指向Shape的指针和指向从Shape派生的类的向上转换对象的指针。然而,由于多态,当调用虚函数时,正确的行为仍然发生。
注意,Shape*的数组sarray,也可以认为是一个容器。
功能模板
在drawAll()你会看到一些新的东西。到目前为止,本章中我们只使用了类模板,它基于一个或多个类型参数实例化新类。然而,您可以轻松地创建函数模板,它基于类型参数创建新函数。
创建函数模板的原因与创建类模板的原因是一样的:您试图创建泛型代码,并且通过延迟一个或多个类型的规范来实现这一点。你只是想说这些类型参数支持某些操作,而不是确切的说它们是什么类型。
函数模板drawAll()可以被认为是一个算法(而这也是标准 C++ 库中大多数函数模板的叫法)。它只是说如何做一些给定的迭代器描述一系列元素,只要这些迭代器可以被解引用,增加,和比较。这些正是我们在本章中开发的迭代器,也是——并非巧合——由标准 C++ 库中的容器产生的迭代器,在本例中使用vector就是证明。
你还想让drawAll()成为一个通用算法,这样容器就可以是任何类型,你不必为每个不同类型的容器编写新版本的算法。这就是函数模板必不可少的地方,因为它们会自动为每种不同类型的容器生成特定的代码。
但是如果没有迭代器提供的额外间接性,这种泛型(程序的泛型属性或泛型属性)是不可能的。这就是迭代器重要的原因;它们允许您编写涉及容器的通用代码,而无需了解容器的底层结构。
注意在 C++ 中,迭代器和泛型算法需要函数模板才能工作。
你可以在main()中看到这一点的证明,因为drawAll()对于每一种不同类型的容器都是不变的。更有趣的是,drawAll()还可以处理指向数组开头和结尾的指针sarray。这种将数组视为容器的能力是标准 C++ 库设计的一部分,其算法看起来很像drawAll()。
因为容器类模板很少受到你在“普通”类中看到的继承和向上转换的影响,所以你几乎不会在容器类中看到virtual函数。容器类重用是用模板实现的,而不是用继承。
审查会议
- 容器类是面向对象编程的一个基本部分。它们是简化和隐藏程序细节的另一种方式,也是加速程序开发过程的另一种方式。
- 此外,通过取代 c 语言中原始的数组和相对粗糙的数据结构技术,它们提供了大量的安全性和灵活性
- 因为客户端程序员需要容器,所以容器必须易于使用。这就是模板的用武之地。
- 有了模板,源代码重用(与继承和组合提供的目标代码重用相反)的语法对新手用户来说变得足够简单。事实上,用模板复用代码明显比继承和组合容易。
- 与容器类设计相关的问题在本章中已经有所涉及,但是你可能已经知道它们可以更进一步。
- 事实上,复杂的容器类库可能涵盖所有种类的附加问题,包括多线程、持久性和垃圾收集。