深度探索C++对象模型 学习笔记 第七章 站在对象模型的尖端(1)

在本章中,我将讨论三种对 C++ 对象模型影响深远的语言扩展:模板(templates)、异常处理(exception handling, EH)和运行时类型识别(runtime type identification, RTTI)。EH(以及 RTTI,它在某种意义上可视为 EH 的副产品)与我在此讨论的其他特性有所不同——因为我从未有机会真正实现它。因此,我的讨论将主要依据 [CHASE94]、[LAJOIE94a]、[LAJOIE94b]、[LENKOV92] 和 [SUN94a]。

7.1 Template(模板)

自 1991 年 cfront Release 3.0 引入模板以来,C++ 的编程风格和惯用法已被深刻改变。模板最初被视为对 Lists 和 Arrays 等容器类的支持,如今已成为泛型编程(Generic Programming)的基础——标准模板库(STL)便是明证。模板还被用作属性混入(attribute mix-in)的惯用法,例如将内存分配策略([BOOCH93])用于线程同步的互斥机制([SCHMIDT94])参数化(这里的参数化指的是,模板参数的类型只要含有某个名字的方法,如名为alloc的分配方法,就可通过传入不同的模板参数类型,来控制不同的alloc行为)。此外,模板还用于模板元编程(template metaprogramming)技术,其中类表达式模板在编译期而非运行期求值,从而带来显著的性能提升([VELD95])。

话虽如此,但模板编程目前也是 C++ 编程中最令人沮丧的方面之一。错误信息常常出现在远离实际问题源头的地方(模板是在编译期实例化的,编译器会展开层层模板推导,错误往往发生在模板内部,如STL容器的深层实现,而非你显式写的那一行);编译时间往往呈指数级增长(模板不是一次编译,而是对每个不同类型组合都重新生成一份机器码。N个类型、M个函数模板,最坏情况下组合爆炸,编译开销几何级上升);一旦修改了含有多文件依赖的头文件,尤其是调试进行到一半时,就会让人心生恐惧。程序体积意外膨胀的情况也屡见不鲜(每个模板实例化都生成一份独立的目标代码)。更糟糕的是,所有这些行为通常超出了普通程序员的理解范围——他们只想完成手头的工作,如果这些问题持续存在,他们可能会觉得语言更像障碍而非助手。在一个项目团队中,常常能见到一位指定的“模板专家”,负责排查问题并试图优化模板的生成过程。

本节将聚焦于模板支持的语义,即编译系统中对模板进行处理的“何时、为何、如何”的问题。模板支持主要包含三个方面:
1.模板声明的处理——基本上就是当你声明一个模板类、模板类成员函数等时,会发生什么。

2.类对象以及内联非成员和成员模板函数的实例化——这些是每个编译单元所需的实例。

3.非成员和成员模板函数以及静态模板类成员的实例化——这些是可执行程序中仅需一份的实例,而这正是模板问题通常滋生的地方。

我用实例化(instantiation)一词来表示将实际的类型和表达式与模板对应的形式参数绑定的过程。例如,对于下面的模板函数:

以及它的使用:

实例化过程会把 Type 绑定为 double,并生成一个 min() 的程序文本实例(其名称会经过适当的名称重整,以便在可执行文件中获得唯一标识),其中 t1 和 t2 的类型都是 double。

Template的“具现”行为(Template Instantiation,模板实例化)

考虑下面这个模板 Point 类:

首先,当编译器看到这个模板类声明时,会发生什么?就实际程序而言,什么也不会发生。也就是说,静态数据成员尚未可用,嵌套的枚举及其枚举值也是如此。

尽管枚举 Status 的实际类型及其枚举值在所有 Point 实例化中都保持不变,但每个枚举都只能通过 Point 模板类的特定实例来访问。因此,我们可以写:

但不能写:

尽管这两种写法在抽象意义上是一样的(理想情况下,我们只希望生成该枚举的单一实例,如果做不到,则可以把该枚举提取到一个非模板基类中,以避免产生多份拷贝)。

类似地,静态数据成员 freeList 和 chunkSize 目前对程序来说也还不可用。我们不能写:

而必须指定与 freeList 成员相关联的显式 Point 模板类实例化:

这种对静态成员的使用,会导致在程序中生成一个与 float 实例化 Point 类相关联的实例。如果我们再写:

则会生成第二个 freeList 实例,这一次它关联的是 double 实例化的 Point 类。

如果我们定义一个指向特定实例的指针,比如:

同样,程序里什么事也不会发生。为什么呢?因为指向类对象的指针本身并不是一个类对象;编译器不需要知道该类的布局或成员信息。因此,实例化一个 float 版本的 Point 是没有必要的。然而,在 C++ 标准之前,声明一个指向特定模板类的指针会产生什么效果并没有明确规定;编译系统可能会也可能不会实例化该模板(例如,cfront 就会进行实例化,这曾让不少程序员感到头疼)。在标准 C++ 中,明确禁止编译系统这样做。

不过,声明一个指向模板特定实例的引用时,情况就不同了。例如:

这确实会导致 Point 的 float 实例被实例化。该定义的实际语义展开如下:

为什么呢?因为引用不能作为“无对象”的别名。这里的 0 被视为一个整数值,必须转换为Point<float>类型的对象。如果无法进行转换,则该定义就是错误的,并会在编译时被标记出来。

因此,无论是编译器隐式地(如生成临时对象时),还是程序员显式地(如下面这样),定义类对象都会导致模板类的实例化:

这意味着 float 实例化的实际对象布局会被生成。回顾前面的模板声明,我们可以看到 Point 有三个非静态成员,每个成员的类型都是 Type。当 Type 被绑定为 float 类型时,origin 就会被分配足以容纳三个 float 成员的空间。

然而,成员函数——至少是那些未被使用的成员函数——不应该被实例化。标准 C++ 要求成员函数只有在被使用时才进行实例化(当前实现并未严格遵循这一要求)。这条“按需实例化”规则主要有两个原因:
1.空间和时间效率。如果一个类关联了 100 个成员函数,但你的程序针对某一类型只使用了其中 2 个,针对另一类型只使用了 5 个,那么实例化其余 193 个函数会耗费大量的时间和空间。

2.未实现的功能。并非所有用于实例化模板的类型都支持全部成员函数所要求的操作(例如 I/O 操作和关系运算符)。通过仅实例化实际使用的成员函数,模板就能支持那些否则会导致编译错误的类型。

origin 的定义,例如,要求调用默认的 Point 构造函数和析构函数。只有这两个函数必须被实例化。类似地,当程序员写下:

只有 Point 模板本身的 float 实例、类实例的 operator new 以及默认构造函数需要被实例化(有趣的是,虽然 operator new 隐式是类的静态成员,即使不加static,它也是static的,因为它是用来分配内存的,调用它时对象还没有创建,因此该函数不能直接访问任何非静态成员,但它仍然依赖于实际的模板参数类型,因为它的第一个参数 size_t 会传入类的大小)。

这些函数实例化发生在什么时候?目前有两种策略:
1.编译期策略:在这种情况下,函数会在定义 origin 和 p 的文件中被实例化。

2.链接期策略:在这种情况下,编译器会由某个辅助工具重新调用。模板函数实例可能被放在该文件中、其他文件中,或者一个独立的代码仓库(repository)中。

函数实例化将在本章后面的一节中详细讨论。

[CARGILL95] 提出了一个有趣的问题:在那些 int 和 long(或 double 和 long double)大小相同的体系结构上,下面这两个类型实例化:

应该生成一个还是两个实例?目前,我所知道的所有实现都会生成两个实例(可能还附带两整套成员函数实例)。标准对此并未做出规定。

Template的错误报告(Error Reporting within a Template)

考虑下面这个模板声明:

Mumble 模板类的声明中包含了一系列明显错误和潜在错误:
1.第 4 行:使用 $ 字符是不正确的。这个错误有两层:(1) $ 不是标识符中的有效字符;(2) 在类声明的函数体内只允许 public、protected 和 private 访问标号($ 的出现使得它不再被识别为关键字标号 public)。(1) 属于词法错误,而 (2) 属于语法/解析错误。

2.第 5 行:将 t 初始化为整型常量 1024,根据 T 的实际类型,可能合法也可能不合法。通常,这种错误只能在模板的每次实例化时才能诊断出来。

3.第 6 行:_t 不是成员名,成员名是 tt。这类错误通常在类型检查阶段被发现,此时每个名字要么被绑定到一个定义,要么生成一条错误信息。

4.第 8 行:不等运算符(!=)是否已定义,取决于 T 的实际类型。与第 2 点一样,这种错误也只能在模板的每次实例化时才能诊断出来。

5.第 9 行:我们不小心把 ex 敲了两遍。这是一个在编译的语法分析阶段发现的错误(语言的合法“句子”不允许一个标识符紧跟在另一个标识符之后)。

6.第 13 行:类声明末尾漏掉了分号。这同样是在编译的语法分析阶段发现的错误。

在非模板类的声明中,这六个明显错误和潜在错误会在编译器看到该声明时就被解决。然而,模板的情况并非如此。一方面,所有涉及模板参数的类型依赖检查都必须推迟到实际的实例化发生时才能进行。也就是说,示例中第 5 行和第 8 行(上述第 2 点和第 4 点)的潜在错误,会在每次实例化时被检查和报告,并且是逐个类型地解决的。因此,对于:

这两行都是正确的。而对于:

第 8 行是正确的,但第 5 行是类型错误——你不能把整型常量(除 0 外)赋值给指针。

再考虑下面的声明:

其中并未定义不等运算符。那么对于实例:

第 8 行会生成错误,而第 5 行是正确的。当然:

又一次反转了局面:第 8 行再次正确,但第 5 行再次出错。

那么,在处理模板声明时,哪些错误会被标记出来呢?这在一定程度上取决于模板的处理策略。在最初的 cfront 实现中,模板会被完整解析,但不会进行类型检查。类型检查只应用到每一次具体的实例化上。因此,采用解析策略时,所有词法错误和语法解析错误会在处理模板声明时就被标记出来。

在这种处理策略下,词法分析器会捕获第 4 行中的非法字符。语法分析器本身很可能将:

标记为类声明中的非法标号。但语法分析器不会标记对未命名成员的引用:

它会捕获第 9 行 throw 表达式中连续出现两次 ex 的错误,以及第 13 行缺少分号的错误。

在另一种流行的替代实现策略中(例如参见 [BALL92a]),模板声明被收集为一系列词法标记(token)。解析被推迟到实际的实例化发生时。当遇到实际的实例化时,这组标记被送入语法分析器进行处理,然后调用类型检查,依此类推。这种“词法标记化”处理模板声明的方式会标记出哪些错误呢?实际上很少,只有第 4 行中使用的非法字符会被捕获。模板声明的其余部分则会分解成一个合法的标记序列。

在当前的实现中,模板声明在实际参数集实例化之前,只进行有限的错误检查。用户可能认为某些非语法相关的错误“明显显而易见”,但模板很可能在编译时不会报错,只有在定义了特定实例之后才会被标记为错误。这更多地反映了当前实践的状况,而非模板机制本身的特性。模板函数的处理尤其能戏剧性地说明这一点。

非成员和成员模板函数同样要等到实例化时才会进行完整的类型检查。在当前的实现中,这导致了一些相当明显不正确的模板声明也能毫无错误地通过编译。例如,给定下面 Foo 的模板声明:

cfront、Sun 编译器和 Borland 都能编译通过下面这个定义而不报任何错误:

Template中的名称决议方式(Name Resolution within a Template)

在模板定义处的程序位置(标准中称为“模板定义的作用域”)与模板实际实例化处的程序位置(标准中称为“模板实例化的作用域”)之间,存在一个区别。

第一个作用域的例子如下:

第二个作用域的例子如下:

在 ScopeRules 模板类中,有两次对 foo() 的调用。在模板定义的作用域内,只有一个 foo() 声明可见。然而在模板实例化的作用域内,有两个声明可见。如果我们有一个这样的调用:

问题在于,对于这个调用:

会调用哪个 foo() 实例?在程序的这个位置,作用域内有两个实例:

并且 _val 的类型是 int。那么你认为会选择哪一个(提示:除了猜测之外,唯一能正确回答的方法就是知道答案)?显然,选择的是那个反直觉的实例:

模板中非成员名称的决议位置,取决于该名称的使用是否依赖于实例化模板时所使用的参数类型。如果该使用不依赖于模板参数,则名称的决议在模板声明的作用域中确定。如果该使用依赖于模板参数,则名称的决议在模板实例化的作用域中确定。

在前一个例子中,foo() 的决议不依赖于用来实例化 ScopeRules 的类型参数:

这是因为 _val 是模板类中一个非依赖(invariant)的成员,其类型为 int——也就是说,用来实例化模板的实际类型对 _val 的类型没有影响。此外,函数重载决议仅由函数的签名(signature)决定,与返回类型无关。因此,_member 的类型不会影响选择哪个 foo(),而 foo() 的调用也与模板实参无关。该调用必须从模板声明的作用域中决议。在该作用域内,只有唯一的候选 foo() 实例可供选择(请注意,这种行为无法通过简单的宏展开来重现,例如使用预处理器的 #define 宏)。

我们再来看一个类型依赖的使用场景:

它的调用会决议到哪个 foo()?

这个实例显然依赖于模板实参,因为实参决定了 _member 的实际类型。因此,这个 foo() 实例必须从模板实例化的作用域中决议,在本例中该作用域包含了两个 foo() 声明。由于此时 _member 的类型是 int,所以调用的是 foo() 的整型版本。如果用 double 类型来实例化 ScopeRules,则会调用双精度版本;如果用 unsigned int 或 long 来实例化,则调用会引发二义性;如果用一个类类型来实例化,而该类类型对这两个版本都不存在转换,则该调用会被标记为错误。无论调用结果如何,候选声明都反映的是模板实例化处的作用域,而非模板声明处的作用域。

这意味着实现必须维护两个作用域上下文:
1.模板声明的作用域——与泛型模板类的表示绑定在一起。

2.模板实例化的作用域——与特定实例的表示绑定在一起。

编译器的决议算法必须确定,在查找该名称时究竟应该在哪个作用域中搜索。

Member Function的具现行为(Member Function Instantiation)

模板支持中最困难的方面是模板函数的实例化。当前实现提供了两种实例化策略:一种是编译期策略,要求模板源代码必须在程序文本文件中可用;另一种是链接期策略,通过某种元编译工具来指导编译器进行实例化。

实现必须回答三个主要问题:
1.实现如何找到函数的定义?

一种解决方案是要求模板程序文本文件像头文件一样被包含进来。例如,Borland 编译器就采用这种策略。另一种做法是要求一个文件命名约定——例如,可以要求模板程序的文本(其声明位于 Point.h 文件中)必须放在 Point.C 或 Point.cpp 等文件中。cfront 遵循这种策略。Edison Design Group 的编译器则两种方式都支持。

2.实现如何只实例化应用程序实际使用到的那些成员函数?

一种方案是直接忽略这个要求,而是生成实例化类的所有成员函数。例如,Borland 编译器就采用这种策略,同时通过 #pragma 指令来抑制或强制实例化特定的实例。另一种替代方案是模拟应用程序的链接过程,以确定实际需要哪些实例,然后只生成这些实例。cfront 采用这种策略。Edison Design Group 的编译器两者都支持。

3.实现如何防止成员定义在多个 .o 文件中被重复实例化?

一种解决方案是生成多份副本,然后借助链接器的支持,使其忽略除一份之外的所有副本。另一种方案是按需驱动的实例化策略——通过模拟链接阶段来确定哪些实例是真正需要的。

当前编译期和链接期两种实例化策略的共同弱点是:在生成模板实例的过程中,编译时间有时会显著增加。显然,模板函数的首次实例化是不可避免的。但问题在于,当这些函数被不必要地重复实例化,或者用于判断是否需要重新实例化的开销过大时,实现的效率就会大打折扣。

C++ 模板支持的最初设想是一种按需驱动的自动实例化机制——既不需要用户干预,也不会对同一份文件进行多次实例化。然而,事实证明,这比当时任何人想象的要困难得多(参见 [STROUP94])。ptlink——随 Release 3.0 发布的原始实例化工具——确实提供了一种按需驱动的自动实例化机制,但即使对有经验的用户来说也过于复杂,而且对于大型应用程序而言,其速度慢得令人无法接受。

Edison Design Group 开发了一种“第二代”按需实例化机制,在(我所知的)各实现中最接近模板机制的原始设想。简要地说,它的工作方式如下:
1.对程序源文件的初次编译不会产生任何模板实例化。不过,那些本可以被实例化的实例信息会生成在目标文件中。

2.当目标文件被链接到一起时,会执行一个预链接器(prelinker)程序。它检查这些目标文件,查找对模板实例的引用以及相应的定义。

3.对于每个没有定义却有引用的模板实例,预链接器会确定一个可以实例化它的文件(利用第 1 步中生成的信息)。这样,它就把必要的程序实例分配给特定的文件。这些分配会被注册到预链接器生成的 .ii 文件中,并存放在一个 ii_file 目录下。

4.预链接器重新执行编译器,对那些 .ii 文件发生过变化的文件进行重新编译。这一过程会反复进行,直到所有必要实例化达到传递闭包(即所有需要的实例都已被生成)为止。

5.目标文件最终被链接在一起,形成可执行文件。

这种按需实例化方案的主要开销在于:程序首次编译时,建立 .ii 文件的初始设置时间。次要开销在于:之后每次编译后都需要运行预链接器,以确保所有被引用的模板都有定义。在初始设置和首次链接成功之后,重新编译的流程如下:
1.对于每个被重新编译的程序文本文件,编译器会检查关联的 .ii 文件。

2.如果关联的 .ii 文件中列出了一组需要实例化的模板,那么在编译该程序文本文件的过程中,这些模板(且仅这些模板)会被实例化。

3.必须运行预链接器,以确保所有被引用的模板都已定义。

在我看来,某种形式的自动模板机制是构建对程序员友好的 C++ 编译系统所必需的组成部分——尽管坦率地说,目前没有任何一个系统是完美无缺的。但作为一名开发者,我不会使用或推荐没有这种机制的系统。

不幸的是,没有任何机制是不带 Bug 的。Edison Design Group 的编译器采用了一种在 cfront Release 2.0 [KOENIG90a] 中引入的算法,该算法(在大多数情况下)能够自动为程序中的每个类生成单个虚表实例。例如,给定下面这个类声明:

在 15 个或 45 个程序文本文件中都包含该类声明的情况下,编译器如何确保只生成一个虚表实例(生成 15 个或 45 个倒是容易)?

Andy Koenig 提出了如下观察:每个虚函数的地址都会被放入其生效所在类的虚表中(标准已放宽了这一要求)。通过取函数的地址,该虚函数的定义必须存在于程序的某个地方;否则程序将无法链接。此外,该函数只能有一个实例,否则程序同样无法链接。因此,可将虚表放在定义了该类第一个非内联、非纯虚函数的文件中。在我们这个例子中,编译器会在存放虚析构函数的文件中生成虚表。

不幸的是,这种“单一实例”的观察在模板情况下不一定成立。不仅因为在“模块中编译所有内容”的模板支持模型下可能生成多个定义,而且链接编辑器现在也允许存在多个定义——只需选择其中一个而忽略其余即可。

好了,这很有趣,但这一切与 Edison Design Group 的自动实例化机制有什么关系呢?考虑下面这个库函数:

该虚函数调用会被转换为类似下面的形式:

因此,这将导致 Point 类的 float 实例以及 virtual_func() 的实例化。由于每个虚函数的地址都放在虚表中,一旦虚表被生成,每个虚函数也必须被实例化。这正是标准在第 14.3.2 节中给出下面这条(否则令人费解的)条款的原因:

如果某个虚函数被实例化,其实例化点紧跟在它所属类的实例化点之后。

然而,如果编译器遵循 cfront 的虚表实现方案,那么虚表要等到 Point 的 float 实例的虚析构函数被实例化时才会生成。但问题在于,此时并没有对虚析构函数的显式使用来保证其实例化。

Edison Design Group 的自动模板机制未能意识到其自身的编译器对第一个非内联、非纯虚函数的隐式使用,因此不会在 .ii 文件中将其标记为需要实例化。结果导致链接器报错,找不到 __vtbl__Point< float > 符号,从而拒绝生成可执行文件。哎呀,真麻烦!在这种情况下,自动实例化机制失效了,程序员必须显式强制实例化该析构函数。目前,在该编译系统中,可通过使用 #pragma 指令来实现这一点。不过,标准已扩展了模板支持,允许程序员在文件中显式请求对整个类模板进行实例化:
并对模板类的单个成员函数进行显式实例化:

以及对单个模板函数进行显式实例化:

在实践中,模板实例化似乎难以完全自动化。即使一切运行正常,如果应用程序规模足够大,生成的目标文件集也可能导致常规重新编译的成本过高。在单独的目标模块中手工预实例化虽然繁琐,但通常也是唯一有效的解决方案。

7.2 异常处理(Exception Handling)

支持异常处理(EH)的首要实现任务是:找到适当的 catch 子句来处理被抛出的异常。这就要求实现以某种方式跟踪程序栈上每个函数的活跃区域(包括跟踪函数中该点的局部类对象是否活跃)。此外,实现还必须提供某种方法来查询异常对象的实际类型(这直接引出了某种形式的运行时类型识别,即 RTTI)。最后,还需要一种机制来管理被抛出的对象——包括其创建、存储、可能的销毁(如果它有关联的析构函数)、清理以及一般访问(同一时刻也可能有多个对象处于活跃状态)。总的来说,EH 机制要求在编译器生成的数据结构与运行时异常库之间进行紧密配合的实现握手。实现的权衡在于程序大小与未发生异常时的程序运行速度之间的取舍:
1.为了保持执行速度,编译器可以在编译期间构建好支持数据结构。这会增加程序的大小,但意味着在抛出异常之前,编译器基本可以忽略这些结构。

2.为了保持程序大小,编译器可以在每个函数执行时“按需”构建支持数据结构。这会影响程序运行速度,但意味着编译器只需在需要时构建(然后可以丢弃)这些数据结构。

据 [CHASE94] 所述,Modula-3 报告实际上已经“制度化”了一种偏好:以程序大小为代价来维持执行速度,建议“在异常情况下可以花费 10000 条指令,只为在正常情况下节省一条指令”。不过,这种权衡并非普遍适用。在最近于特拉维夫举行的一次会议上,我与 Shay Bushinsky 交谈过,他是“Junior”国际象棋程序的开发者之一。在 1994 年冬季世界计算机国际象棋锦标赛中,该程序与 IBM 的“Deep Blue”并列第三名。令人惊讶的是,该程序运行在一台基于 Pentium 的个人计算机上(而 Deep Blue 使用了 256 个处理器)。他告诉我,当他们使用集成了 EH 的 Borland 编译器版本重新编译时,即使程序本身没有任何改动,程序也不再能容纳于可用内存之中。结果,他们不得不退回到较早版本的编译器。对于“Junior”来说,一个体积更大但不会侵入运行时的程序是不可接受的(另一方面,运行时按需构建数据结构的性能影响也可能难以接受,无论是否抛出异常,EH 的支持都会带来额外的开销)。

另外值得一提的是,异常处理(EH)实际上断送了 cfront 的生命。在没有代码生成器(和链接器)支持的情况下,根本不可能提供足够健壮的 EH 机制。UNIX 软件实验室(USL)曾将惠普提交的基于 C 代码生成的 EH 实现搁置了长达一年多(其可移植实现及其性能的讨论参见 [LENKOV92])。USL 最终放弃了,取消了 Release 4.0 的计划,也停止了 cfront 的任何后续开发。

Exception Handling快速检阅(A Quick Review of Exception Handling,异常处理快速回顾)

C++ 的异常处理由以下三个主要语法组件构成:
1.throw 子句:在程序中的某个点抛出一个异常。被抛出的异常可以是内置类型,也可以是用户定义的类型。

2.一个或多个 catch 子句:每个 catch 子句都是一个异常处理器。它指明了自己准备处理的异常类型,并给出用花括号括起来的实际处理代码。

3.try 块:try 块将一组语句包围起来,与之关联的一组 catch 子句在此期间处于活跃状态。

当异常被抛出时,控制流会沿着函数调用链向上回溯,直到找到匹配的 catch 子句;如果一直回溯到 main() 都未能找到处理器,则会调用默认处理器 terminate()。在控制流向上回溯调用链的过程中,每个函数会依次从程序栈中弹出(这个过程称为栈展开,stack unwinding)。在弹出每个函数之前,该函数中局部类对象的析构函数会被调用。

异常处理中略显反直觉的一点,在于它会对那些看似与异常毫无关系的函数产生影响。例如,考虑下面的代码:

如果异常是在第一次调用 foo()(第 5 行)时抛出的,那么该函数可以直接从程序栈中弹出。这条语句不在 try 块内,因此无需尝试匹配 catch 子句;也没有任何局部类对象需要析构。然而,如果异常是在第二次调用 foo()(第 11 行)时抛出的,那么 EH 机制在将函数从程序栈中展开之前,必须先调用 p 的析构函数。

也就是说,在 EH 下,第 4–8 行和第 9–16 行被视为该函数中语义不同的两个区域,当抛出异常时它们具有不同的运行时语义。此外,EH 支持还需要额外的簿记工作。实现可以选择将这两个区域关联到各自的局部对象销毁列表(这些列表在编译时设定),也可以共享一个在运行时动态增减的列表。

在程序员层面,EH 还会改变那些管理资源的函数的语义。例如,下面这个函数包含了对共享内存区域的加锁和解锁操作,尽管它看似与异常无关,但在 EH 下其正确性已不再有保证:

在这种情况下,EH 机制将整个函数视为一个单一区域,除了从程序栈中展开该函数之外,不需要做任何额外处理。然而从语义上讲,我们需要在函数弹出之前同时解锁共享内存并删除 p。让该函数“防异常”的最直接(即便不是最有效)的方法是插入一个默认的 catch 子句,如下所示:

现在该函数被分成了两个区域:
1.try 块之外的区域——对于该区域,EH 机制除了弹出程序栈之外无需做任何事。

2.try 块(及其关联的默认 catch 子句)内部的区域。

请注意,operator new 的调用并不在 try 块内。这是我犯的错误吗?如果 operator new 或内存分配后调用的 Point 构造函数抛出了异常,那么 catch 子句之后的解锁内存和删除 p 的操作都不会被执行。这是正确的语义吗?

是的,这是正确的。如果 operator new 抛出了异常,说明堆内存尚未分配成功,Point 构造函数也不会被调用,因此没有理由调用 operator delete。然而,如果异常是在堆内存分配成功之后、Point 构造函数内部抛出的,那么 Point 中已构造的复合对象或子对象(即成员类对象或基类对象)会被自动析构,然后堆内存会被释放。无论哪种情况,都不需要调用 operator delete(我将在本节末尾再回到这个话题)。

类似地,如果在 operator new 的处理过程中抛出异常,arena 所指向的共享内存段尚未被锁定,因此也无需解锁。

处理这类资源管理的推荐惯用法是:将资源的获取封装在一个类对象中,并由该类的析构函数释放资源(不过,当资源需要多次获取、释放、再获取、再释放时,这种方式就会变得笨拙):

现在,从 EH 的角度看,该函数被划分为三个区域:
1.定义标准 auto_ptr 的区域。

2.定义 SMLock 的区域。

3.紧随两个定义之后并覆盖整个函数的区域。

如果在 auto_ptr 构造函数中抛出异常,则没有活跃的局部对象需要 EH 机制去销毁。然而,如果在 SMLock 构造函数中抛出异常,则在程序栈展开之前必须先销毁 auto_ptr 对象。在第三个区域中,当然两个局部对象都必须被销毁。

EH 支持增加了含有成员类对象和基类子对象(它们各自带有构造函数)的类的构造函数的复杂性。一个部分构造完成的类,只应对那些已经构造完成的子对象和/或成员对象调用析构函数。例如,如果类 X 含有成员对象 A、B 和 C,每个都有构造函数/析构函数对,那么:
1.如果 A 的构造函数抛出异常,则 A、B、C 的析构函数都不需要被调用。

2.如果 B 的构造函数抛出异常,则需要调用 A 的析构函数,但不需要调用 C 的析构函数。

处理所有这些意外情况是编译器的职责。

类似地,如果程序员写下:

实际发生的过程如下:
1.从堆上为 512 个 Point3d 对象分配内存。

2.如果第 1 步成功,则依次对每个元素先调用 Point2d 的构造函数,再调用 Point3d 的构造函数。

那么,如果第 27 个元素的 Point3d 构造函数抛出了异常呢?对于第 27 个元素,只需调用 Point2d 的析构函数。对于前 26 个已构造完成的元素,则需要同时调用 Point3d 和 Point2d 的析构函数。然后,已分配的内存必须被释放。

对Exception Handling的支持

当异常被抛出时,编译系统必须执行以下操作:
1.检查发生 throw 的函数。

2.判断该 throw 是否发生在 try 块内。

3.如果是,则编译系统必须将异常的类型与每个 catch 子句的类型进行匹配。

4.如果类型匹配,控制权将转移到 catch 子句的代码体中。

5.如果 throw 不在 try 块内,或者没有任何 catch 子句匹配,则系统必须:(a) 销毁所有活跃的局部对象;(b) 将当前函数从栈上展开;© 转到栈上的下一个活跃函数,并重复步骤 2–5。

判断 throw 是否发生在 try 块内

回忆一下,一个函数可以被看作由一组区域构成:
1.try 块之外且没有活跃局部对象的区域。

2.try 块之外但有一个或多个活跃局部对象需要析构的区域。

3.活跃的 try 块内部的区域。

编译器需要标记出这些区域,并将这些标记提供给运行时 EH 系统。一种主要的实现策略是构建程序计数器范围表(program counter-range tables)。

回忆一下,程序计数器(program counter)保存着下一条将要执行的程序指令的地址。为了标记出函数内某个处于活跃 try 块中的区域,可以将该区域的起始和结束程序计数器值(或者起始程序计数器值及其范围值)存储在表中。

当异常被抛出时,当前的程序计数器值会与关联的范围表进行匹配,以判断当前活跃的区域是否位于 try 块内。如果是,则需要检查关联的 catch 子句(我们将在下一小节中讨论)。如果异常未被处理(或者被重新抛出),则当前函数从程序栈中弹出,程序计数器的值被恢复为调用点的值,然后整个过程再次开始。

将异常类型与每个 catch 子句的类型进行比较

对于每个 throw 表达式,编译器必须创建一个类型描述符(type descriptor),用于编码该异常的类型。如果该类型是派生类类型,则描述符还必须包含其所有基类类型的信息(仅仅编码公有基类类型是不够的,因为异常可能在成员函数中被捕获,在成员函数的作用域内,派生类到非公有基类之间的转换是允许的)。

类型描述符之所以必要,是因为异常的实际处理发生在运行时,而此时异常对象本身并不携带任何类型信息。RTTI 正是 EH 支持所带来的一个必要副产品(我将在 7.3 节进一步讨论 RTTI)。

编译器还必须为每个 catch 子句生成一个类型描述符。运行时异常处理器会将所抛出对象的类型描述符与每个 catch 子句的类型描述符逐一比较,直到找到匹配项,或者直到栈被完全展开并调用了 terminate() 为止。

编译器会为每个函数生成一张异常表(exception table)。该表描述了与函数相关的各个区域、任何必要的清理代码的位置(即局部类对象析构函数的调用),以及如果某个区域位于活跃的 try 块内时,关联的 catch 子句的位置。

程序执行期间抛出实际对象时会发生什么?

当异常被抛出时,异常对象会被创建,通常放在某种形式的异常数据栈上。从 throw 点传播到每个 catch 子句的信息包括:异常对象的地址、类型描述符(或一个返回与该异常类型关联的类型描述符对象的函数地址),以及如果定义了析构函数,还可能包括异常对象析构函数的地址。

考虑下面这种形式的 catch 子句:

以及一个类型为 exVertex(派生自 exPoint)的异常对象。两个类型匹配,catch 子句块进入活动状态。那么 p 会发生什么呢:
1.p 是用异常对象按值初始化的,就好像它是函数的一个形参一样。这意味着,如果拷贝构造函数和析构函数被定义或由编译器合成,它们会被应用到局部副本 p 上。

2.由于 p 是一个对象而非引用,当值被拷贝时,异常对象中非 exPoint 的部分会被切掉(即对象切片,slicing)。此外,如果异常继承体系中提供了虚函数,p 的 vptr 会被设置为 exPoint 的虚表;异常对象的 vptr 不会被复制过来。

当异常被重新抛出时,传播的是 p 还是最初在 throw 点生成的原始异常对象?p 是一个局部对象,在 catch 子句结束时被销毁。如果抛出 p,则需要生成另一个临时对象,同时也会丢失原始异常中 exVertex 的部分。实际上,原始异常对象被重新抛出;对 p 所做的任何修改都会被丢弃。

对于下面这种形式的 catch 子句:

rp 引用的是实际的异常对象。任何虚函数调用都会解析到 exVertex(即异常对象的实际类型)所对应的实例。对对象所做的任何更改都会传播到下一个 catch 子句。

最后,这里有一个有趣的问题。假设我们有下面这个 throw 表达式:

传播的是实际的异常对象 errVer,还是在异常栈上构造了 errVer 的一份拷贝并传播该拷贝?答案是构造了一份拷贝;全局的 errVer 并不会被传播。这意味着在 catch 子句内对异常对象所做的任何修改,都只作用于该拷贝,而不会反映到 errVer 上。实际的异常对象只有在求值完一个不重新抛出异常的 catch 子句之后才会被销毁。

在对 PC 上 C++ 编译器的评测中(参见 [HORST95]),Cay Horstmann 测量了 EH 引入的性能和大小开销。在其中一个测试中,Cay 编译并运行了一个测试用例,该用例创建并销毁了大量带有构造和析构函数的局部对象。实际执行过程中并未发生任何异常,而两个程序之间的唯一区别在于 main() 中是否包含一个 catch(…) 子句。以下是他在 Microsoft、Borland 和 Symantec 编译器上的测量结果摘要,首先,因 catch 子句存在而导致的程序大小差异:

其次,因 catch 子句存在而导致的执行速度差异:

在不同 C++ 编译器中,异常处理(EH)的实现差异是最大的。这在一定程度上是由于 EH 的运行时特性、对底层硬件的依赖,以及 UNIX 和 PC 平台在执行速度和程序大小方面有着不同的优先级。