请看下面这个抽象基类的声明:

你能看出什么问题吗?该类被设计成抽象基类(纯虚函数的存在禁止创建 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 Type)
Point 的第二版声明通过公共接口把私有数据完全封装了起来,不过没有提供任何虚函数接口:


这样一个封装后的 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 Value,NRV),那么该函数会进一步改写如下:

一般来说,如果你的设计中有大量函数需要按值定义并返回一个局部类对象(例如下面这种形式的算术运算):

那么即使默认的逐成员语义已经够用,提供一个显式的拷贝构造函数仍然非常合理。因为它的存在会触发 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 代码,消除了不必要的条件测试和虚表指针设置。他报告说取得了可测量的性能提升)。