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

深度探索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 被视为一个整数值必须转换为Pointfloat类型的对象。如果无法进行转换则该定义就是错误的并会在编译时被标记出来。因此无论是编译器隐式地如生成临时对象时还是程序员显式地如下面这样定义类对象都会导致模板类的实例化这意味着 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 平台在执行速度和程序大小方面有着不同的优先级。