)
请看下面这个抽象基类的声明你能看出什么问题吗该类被设计成抽象基类纯虚函数的存在禁止创建 Abstract_base 的独立实例但它仍然需要一个显式的构造函数来初始化其唯一的数据成员 _mumble。如果没有这个初始化从 Abstract_base 派生的类其局部对象中的 _mumble 成员就处于未初始化状态。例如有人可能会说Abstract_base 的设计者本意是让每个派生类自己为 _mumble 提供初值。如果真是这样要想强制派生类这么做唯一的方法是提供一个带参数的 protected 构造函数一般而言类的数据成员应该只在该类的构造函数和其他成员函数内进行初始化和赋值。否则就会破坏封装让后续的维护和修改变得更加困难。另一种观点则认为设计错误并不在于缺少显式构造函数而在于抽象基类里声明了数据成员。这个论点更有说服力它涉及接口与实现的分离但并非放之四海皆准。把多个派生类共享的数据成员提升到基类中有时也是合理的设计选择。纯虚拟函数的存在Presence of a Pure Virtual Function刚接触 C 的程序员常常会惊讶地发现只要采用静态调用方式而非通过虚机制调用纯虚函数也可以被定义和调用。例如下面这段代码是合法的是否这么做通常由类设计者自行决定——但有一个例外。这个例外就是纯虚析构函数它必须由类设计者提供定义而不能将其定义为纯虚函数。为什么因为编译器会在每个派生类的析构函数内部隐式地加入对其所有虚基类和直接基类析构函数的静态调用。只要任何一个基类析构函数缺少定义就会导致链接错误。有人可能会问编译器在扩充派生类的析构函数的时候难道不应该主动抑制对纯虚析构函数的调用吗答案是不应该。因为类设计者可能确实为这个纯虚析构函数提供了定义就像前面的例子中定义了 Abstract_base::interface() 一样。整个设计本身就依赖于 C 语言的一个保证类对象继承体系中的每个析构函数都会被调用。所以编译器不能擅自省略这一调用。又有人会问如果类设计者忘记或不知道需要定义纯虚析构函数编译器难道不能自动合成它的定义吗答案依然是不能。由于可执行程序采用分离编译模型编译器无法自行判断。某些开发环境或许可以在链接时发现定义缺失并重新调用编译器带上合成定义的指令但据我所知目前没有任何实现能做到这一点。更好的设计选择是不要把虚析构函数声明为纯虚函数。虚拟规格的存在Presence of a Virtual Specification更应翻译为虚函数声明的问题Abstract_base::mumble() 不太适合作为虚函数因为它的算法与类型无关派生类几乎不可能改写它。更糟糕的是它的非虚实现是内联函数。如果频繁调用这个虚函数版本的实现性能损失会相当明显。不过有人可能会问编译器难道不能通过分析发现整个继承体系里只有一个实例存在吗如果确实如此它难道不能把虚调用转成静态调用进而允许内联展开吗问题是——如果后来给这个继承体系增加了新类而新类又引入了该函数的新实例呢这个新类会让之前的优化失效。此时该函数必须重新编译或者编译器生成第二个多态实例再通过流分析来决定实际该调用哪个实例。但问题在于该函数可能已经以二进制形式存在于某个库中。想要解开这种依赖关系很可能需要某种持久化的程序数据库或库管理器。总的来说把所有函数都声明成虚函数再指望编译器优化掉不必要的虚调用——这依然是一种糟糕的设计选择。虚拟规格中const的存在Presence of const within a Virtual Specification虚函数声明中的 const 限定判断一个虚函数是否应该为 const看起来好像很简单但实际在抽象基类里并不容易。这样做意味着要去预测数量可能无限的派生类实现会怎么使用这个函数。如果声明函数时不加 const那么这个函数就无法通过 const 引用或 const 指针来调用——至少不借助 const_cast 去掉常量性是做不到的。更麻烦的情况是先把函数声明成 const结果发现某个派生类的实例确实需要修改数据成员。我不知道业界有没有公认的解决办法但我可以证实这个问题的确存在。在我自己的代码里我倾向于不加 const。重新审视后的类声明前面的讨论表明下面这个 Abstract_base 的重声明似乎是更合适的设计5.1 “无继承”情况下的对象构造请看下面这个程序片段第 1、5、6 行代表了三种对象创建方式全局对象、局部对象和堆内存分配。第 7 行是一个类对象对另一个类对象的赋值操作第 10 行是用局部 Point 对象来初始化返回值。第 9 行显式删除了堆上分配的对象。对象的生命周期是运行时的一个属性。局部对象的生命周期从第 5 行定义开始到第 10 行结束。全局对象的生命周期贯穿整个程序运行。堆上分配的对象的生命周期则从 operator new 分配开始一直到调用 operator delete 为止。下面是用类似 C 的风格写出的第一版 Point 声明。标准称这种 Point 声明为 Plain Ol’ Data普通旧数据简称 POD在 C 下编译这个声明时会发生什么从概念上说编译器会为 Point 内部声明一个平凡的默认构造函数、平凡的析构函数、平凡的拷贝构造函数以及平凡的拷贝赋值运算符。不过在实际实现中编译器只是分析一下这个声明然后把它标记为 Plain Ol’ Data普通旧数据即 POD。当编译器遇到下面的定义时从概念上说Point 的平凡构造函数和析构函数都会被生成并调用构造函数在程序启动时调用析构函数通常在 main() 完成后由系统生成的 exit() 调用中执行。但在实际实现中这些平凡成员既不会被定义也不会被调用程序的行为和 C 语言下完全一样。嗯只有一个微小的区别。在 C 语言里global 被视为一个暂定性定义tentative definition因为它没有显式初始化。暂定性定义在程序里可以出现多次。链接编辑器会把这些多次出现的实例合并成一个然后把这一个实例放到程序数据段中专门存放未初始化全局对象的区域里由于历史原因这块区域叫 BSS是“Block Started by Symbol”的缩写源自 IBM 704 汇编器的一条伪指令。在 C 中暂定性定义不被支持因为类构造函数会隐式地应用进来诚然语言本可以对类对象和 Plain Ol’ Data 做出区分但这样做似乎引入了不必要的复杂化。因此在 C 中 global 被视为一个完整的定义这样就排除了第二个或后续定义的出现。所以 C 和 C 的一个区别在于BSS 数据段在 C 中的重要性相对降低——C 里的所有全局对象都被当作已初始化来处理。foobar() 函数第 5 行的局部 Point 对象同样既不会被构造也不会被析构。当然如果第一次使用比如第 7 行依赖于该局部对象有确定的值那么让它保持未初始化状态就可能成为一个程序缺陷。第 6 行的堆分配会被转化成一个对库函数 operator new 的调用同样operator new 返回的 Point 对象也不会应用默认构造函数。如果上一行的 local 已经被正确初始化那么再下一行的赋值可以解决这个问题但这个赋值语句很可能触发一条类似下面这样的编译器警告从概念上说这个赋值操作会触发平凡拷贝赋值运算符的定义并调用该运算符来执行赋值。不过在实际实现中因为对象是 Plain Ol’ Data所以赋值仍然只是逐位拷贝和在 C 语言中一样。第 9 行的 delete heap会被转换成对库函数 operator delete 的调用同样从概念上讲这会触发为 Point 生成平凡析构函数。但正如我们所见实际实现中这个析构函数既不会被生成也不会被调用。最后按值返回 local 从概念上会触发平凡拷贝构造函数的定义然后调用它依此类推。但在实际实现中这个返回操作仍然只是对 Plain Ol’ Data 的简单逐位拷贝。以上过程中global中的三个成员都会被零初始化因为静态存储期的对象C会在进入 main 之前先执行零初始化而local中的三个成员值会是随机值因为默认构造函数中没有为三个成员设置初始值即使是我们显式提供的构造函数如果没有为成员设置初始值成员的初始值也是随机的。抽象数据类型Abstract Data TypePoint 的第二版声明通过公共接口把私有数据完全封装了起来不过没有提供任何虚函数接口这样一个封装后的 Point 类对象大小没有变化仍然是三个连续排列的 float 坐标成员。私有关键字、公共关键字乃至成员函数的声明都不会在对象里占用任何空间。我们没有为 Point 类定义拷贝构造函数或拷贝运算符因为默认的逐位拷贝语义已经够用了。也没有提供析构函数——默认的程序内存管理方式足以胜任。定义一个全局实例现在默认构造函数会被应用到 global 上。由于 global 定义在全局作用域它的初始化需要推迟到程序启动时详见 6.1 节。有一种特殊情况把一个类初始化为全常量值。此时使用显式初始化列表比等价的构造函数内联展开要略微高效一些——即使是在局部作用域也是如此虽然在局部作用域这一点看起来有点反直觉。我们会在 5.4 节给出相关数据。例如看下面这段代码local1 的初始化比 local2 的初始化略微高效一些。这是因为初始化列表里的值可以在函数的活动记录activation record被压入程序堆栈时就直接放到 local1 的内存中。显式初始化列表有三个缺点1.只有当类的所有成员都是公有时才能使用为什么上例中三个坐标成员是私有也可以我自己测试也是可以的这是因为我自己测试时发现初始化列表实际调用的也是构造函数如果把构造函数去掉那么就只能在所有成员都是公有时才能使用显示初始化列表了。2.只能指定常量表达式那些能在编译期求值的表达式。3.由于编译器不会自动应用它对象未被初始化的风险大大增加。那么使用显式初始化列表带来的性能提升是否足以弥补它在软件工程上的这些缺陷呢通常来说不值得。但在某些特定场景下它确实能带来差别。例如你可能在手工构建某个大型数据结构比如调色板或者把大量常量值直接写到程序文本里比如某个复杂几何模型的控制顶点和节点值这类模型通常来自 Alias 或 SoftImage 等软件包。在这些情况下显式初始化列表的表现优于内联构造函数对于全局对象尤其明显。在编译器层面可以做这样一项优化识别出那些仅仅用常量表达式对成员逐一赋值的内联构造函数。编译器可以把这些常量值提取出来像对待显式初始化列表中的值一样去处理而不是把构造函数展开成一系列赋值语句。现在来看局部 Point 对象的定义此后默认的 Point 构造函数会被内联展开相当于第 6 行在堆上分配 Point 对象现在这里会包含一个对默认 Point 构造函数的有条件调用该构造函数随后会被内联展开。把局部对象赋值给 heap 所指向的对象这个赋值仍然只是简单的逐位拷贝。按值返回局部对象也是如此删除 heap 所指向的对象不会触发析构函数调用因为我们没有显式提供析构函数实例。从概念上说我们的 Point 类有一个相关联的默认拷贝构造函数、拷贝运算符和析构函数。不过它们都是平凡的编译器在实际中并不会生成它们。为继承做准备Point 的第三个版本开始为继承以及某些操作的动态决议做准备——这里仅限于访问坐标成员 z我们仍然没有定义拷贝构造函数、拷贝运算符或析构函数。所有成员都是按值存储的因此在默认语义下程序层面不会有问题有人可能会说一旦引入了虚函数就应该同时声明一个虚析构函数不过在这个例子里这样做毫无意义。引入虚函数后每个 Point 对象内部会多出一个虚表指针vptr。这给了我们虚接口的灵活性代价是每个对象多占用一个字word的存储空间。这个代价大不大显然取决于应用场景和领域。以 3D 建模为例如果你要表示一个复杂的几何形状其中有 60 个 NURBS 曲面每个曲面有 512 个控制顶点那么每个 Point 对象多出的 4 字节开销累积起来就接近 200,000 字节。这个开销是否值得需要与设计中多态带来的实际好处进行权衡。你所要避免的是等实现完成之后才意识到这个问题。除了在每个类对象中增加 vptr 之外引入虚函数还会导致编译器对 Point 类做如下扩充1.我们已经定义的构造函数会被插入初始化虚表指针的代码。这段代码必须在任何基类构造函数调用之后、用户提供的任何代码执行之前加入。例如下面是我们 Point 构造函数的一种可能的展开形式2.拷贝构造函数和拷贝运算符都需要被合成因为它们的操作不再是平凡的隐式析构函数仍然是平凡的因此不会被合成。如果用一个派生类对象来初始化或赋值给 Point 对象逐位操作可能会导致虚表指针被错误设置。作为优化编译器可能会把一个对象的连续内存块直接拷贝到另一个对象而不是严格实现逐成员赋值。C 标准要求实现编译器延迟这些非平凡成员的合成直到确实遇到它们的实际使用。为了方便起见我把 foobar() 的代码再次贴在这里第 1 行 global 的初始化、第 6 行 heap 的初始化以及第 9 行 heap 的删除与之前 Point 的第一版表示完全相同。不过第 7 行的逐成员赋值很可能会触发拷贝赋值运算符的实际合成并对该调用进行内联展开——用 heap 替换 this 指针用 local 替换右值参数 rhs。对我们的程序影响最大的是第 10 行按值返回 local 的操作。一旦有了拷贝构造函数foobar() 很可能被改写成下面这样2.3 节会详细讨论如果编译器支持具名返回值优化Named Return ValueNRV那么该函数会进一步改写如下一般来说如果你的设计中有大量函数需要按值定义并返回一个局部类对象例如下面这种形式的算术运算那么即使默认的逐成员语义已经够用提供一个显式的拷贝构造函数仍然非常合理。因为它的存在会触发 NRV 优化存疑可能是旧版编译器的特性新版可能已经优化。此外正如前一个例子所展示的这种优化一旦生效就不再需要真正调用拷贝构造函数——因为计算结果被直接放入了将要返回的那个对象中。5.2 继承体系下的对象构造当我们定义一个对象时比如究竟会发生什么如果 T 有关联的构造函数无论是用户提供的还是编译器合成的它就会被调用。这一点显而易见。但有时候不那么显而易见的是调用一个构造函数实际包含哪些操作构造函数可能隐含大量编译器自动插入的代码因为编译器会根据 T 的类继承体系的复杂程度对每个构造函数进行或多或少的扩充。编译器扩充的一般顺序如下1.成员初始化列表中的数据成员必须按照它们在类中声明的顺序放入构造函数的函数体里。2.如果某个成员类对象没有出现在成员初始化列表中但它有相关的默认构造函数那么该默认构造函数必须被调用。3.如果类对象中包含虚表指针可能不止一个则必须用相应虚表的地址来初始化这些指针。4.所有直接基类的构造函数必须按照基类声明的顺序被调用成员初始化列表中的顺序无关紧要。1如果基类出现在成员初始化列表中则必须传递其中给出的显式实参如果有。2如果基类没有出现在成员初始化列表中且该基类有默认构造函数或默认的逐成员拷贝构造函数则必须调用它。3如果该基类是第二个或更靠后的基类则必须调整 this 指针。5.所有虚基类的构造函数必须按照派生类继承体系所定义的、从左到右、深度优先的顺序被调用。1如果虚基类出现在成员初始化列表中则必须传递其中给出的显式实参如果有。否则如果该类有关联的默认构造函数则必须调用它。2此外每个虚基类子对象在类对象中的偏移量必须在运行时能以某种方式访问到。3不过这些构造函数仅当该类对象代表“最派生类”时才会被调用。为此必须引入某种机制来支持这一判断。在本节中我将探讨为了支持 C 语言所保证的类语义编译器需要在构造函数中做哪些扩充。我仍然用 Point 类来辅助说明为了展示后续的容器类和派生类在这些函数存在时的行为我特意增加了一个拷贝构造函数、一个拷贝赋值运算符和一个虚析构函数在深入探讨以 Point 为基类的继承体系之前我们先快速看一下 Line 类的声明及其扩充。Line 由一个起点和一个终点 Point 对象组合而成每个显式构造函数都会被扩充以调用其两个成员类对象的构造函数。例如下面这个用户定义的构造函数在内部会被扩充并改写成由于 Point 类声明了拷贝构造函数、拷贝赋值运算符和析构函数本例中为虚析构函数因此 Line 的隐式拷贝构造函数、拷贝赋值运算符和析构函数都变成了非平凡的。当程序员写下编译器会合成隐式的 Line 析构函数如果 Line 是从 Point 派生的那么合成的析构函数会是虚函数不过因为 Line 只是包含 Point 对象而非派生自 Point所以合成的 Line 析构函数是非虚的。在这个合成的析构函数中两个成员类对象的析构函数会按照与构造相反的顺序被调用当然如果 Point 的析构函数是内联的那么每次调用都会在调用点展开。请注意尽管 Point 的析构函数是虚函数但在容器类Line的析构函数中对其调用是以静态方式决议的。类似地当程序员写下编译器会合成隐式的 Line 拷贝构造函数作为一个内联公有成员。最近在查阅 cfront 的代码时我注意到它在生成拷贝赋值运算符时并不会加入下面这种自赋值保护的判断因此对于像下面这样的表达式它会执行冗余的拷贝操作。我发现这个问题并非 cfront 独有Borland 同样不加自赋值保护我推测大多数编译器都是如此。对于编译器合成的拷贝赋值运算符来说这种冗余拷贝是安全的——因为它不涉及资源的释放。不过在用户自己提供的拷贝赋值运算符中忘记检查自赋值是一个新手常犯的错误。例如说到“有心无力”的事情——我曾多次想在 cfront 中加入一条警告当拷贝赋值运算符里缺少自赋值保护同时又对某个成员执行了 delete 操作时就发出警告。我仍然认为这条警告对程序员会很有用。虚拟继承Virtual Inheritance请看下面从 Point 类进行的虚派生由于虚基类具有共享特性传统的构造函数扩充方式在这里行不通。例如下面这种扩充就是无效的你能看出上面这种对 Point3d 构造函数的扩充有什么问题吗考虑下面三个类的派生关系Vertex 的构造函数也必须调用 Point 类的构造函数。然而当 Point3d 和 Vertex 作为 Vertex3d 的子对象时它们对 Point 构造函数的调用不能发生相反由 Vertex3d 作为最派生类来负责初始化 Point。在后续的 PVertex 派生中则由 PVertex而不是 Vertex3d负责初始化那个共享的 Point 子对象。为了支持这种“有时你需要初始化虚基类有时你不需要”的机制传统的做法是在构造函数中增加一个额外的参数用来指示是否需要调用虚基类的构造函数。构造函数体内部会条件判断这个参数从而决定调用或不调用相关的虚基类构造函数。下面是对 Point3d 构造函数采用这种策略的扩充结果这种策略在语义上是正确的。例如当我们定义Point3d 的构造函数正确调用了其虚基类 Point 的子对象。当我们定义Vertex3d 的构造函数正确调用了 Point 的构造函数。而 Point3d 和 Vertex 的构造函数则执行了除该调用之外的所有操作。既然行为正确那问题出在哪里呢我们中的许多人都注意到虚基类构造函数在何时被调用其条件是明确定义的。当定义一个完整的类对象时例如 origin它们会被调用而当该对象作为后续派生类对象中的子对象时它们不会被调用。借助这一认识我们可以生成性能更好的构造函数——代价是生成更多的程序代码。一些较新的实现将每个构造函数拆分成两个版本完整对象版本和子对象版本1.完整对象版本无条件调用虚基类构造函数、设置所有虚表指针等。2.子对象版本不调用虚基类构造函数也可能不设置虚表指针等下一节会讨论虚表指针的设置问题。这种构造函数拆分应该能显著提升程序速度。遗憾的是我手头没有实际采用这种方案的编译器因此无法提供数据来证实这一点不过在 Foundation 项目期间Rob Murray——我猜他是出于无奈——手动优化了 cfront 生成的 C 代码消除了不必要的条件测试和虚表指针设置。他报告说取得了可测量的性能提升。