vptr初始化语意学The Semantics of the vptr Initializationvptr的初始化语义当我们定义一个 PVertex 对象时构造函数的调用顺序如下假设继承体系中的每个类都定义了一个虚函数 size()用于返回该类对象的大小以字节为单位。例如如果我们写那么调用会返回 PVertex 类的大小。而执行则会返回 Point3d 类的大小。进一步假设继承体系中的每个构造函数都包含一个对 size() 的调用。例如当我们定义 PVertex 对象时这五个构造函数调用各自输出的结果应该是什么每次调用 size() 都应该解析到 PVertex::size() 吗毕竟我们正在构造的就是 PVertex 对象还是应该解析到当前正在执行的构造函数所属的那个类的 size() 版本语言规则是在 Point3d 的构造函数内部对 size() 的调用必须解析到 Point3d 的版本而不是 PVertex 的版本。更一般地说在某个类这里即 Point3d 类的构造函数以及析构函数内部正在构造的对象这里是 PVertex 对象对虚函数的调用被限定在该类即 Point3d 类内部活动的虚函数版本上。这一限制是必要的因为构造函数的调用顺序决定了类是从底部向上、再从内向外构建的。因此当基类构造函数执行时派生类部分尚未构造完成。PVertex 对象只有在其构造函数全部执行完毕后才算是一个完整的 PVertex 对象。而当 Point3d 的构造函数执行时仅仅只有 Point3d 这个子对象被构造好了。这意味着当 PVertex 的每个基类构造函数被调用时编译系统必须保证调用到正确的 size() 版本。那该怎么做呢如果虚函数调用仅限于构造函数或析构函数内部的直接调用那么解决方案相当直接只需静态解析每个调用完全不使用虚机制即可。例如在 Point3d 构造函数内部显式调用 Point3d::size() 就行了。但问题在于如果在 size() 内部又调用了另一个虚函数呢此时那个调用也必须解析到 Point3d 的版本。而在其他情况下调用却应该是真正虚的必须走虚机制。也就是说我们需要让虚机制本身“感知”到这次调用是否源自构造函数内部。我猜想一种可行的办法是在构造函数或析构函数内部设置一个标志本质上表示“嘿不这次请静态解析调用。”然后我们可以根据这个标志的状态生成条件调用。这虽然可行但既不够优雅也不够高效——典型的“硬 hack”。我们甚至可以在源代码注释里为自己开脱这种解决方案感觉更像是应对最初设计策略的失败而非解决根本问题——即在执行构造函数期间需要约束候选虚函数的集合。我们不妨想一想到底是什么决定了某个类当前有效的虚函数集合答案是虚表。那么虚表是如何被访问、从而确定当前有效的那一组虚函数的呢通过 vptr 所指向的地址。因此要控制一个类的有效虚函数集合编译系统只需要控制 vptr 的初始化和设置即可当然设置 vptr 是编译器的职责程序员不需要也不应该操心。那么vptr 的初始化应该如何处理本质上这取决于构造函数内部应该在何时初始化 vptr。有三种选择1.最先在所有其他操作之前。2.中间在基类构造函数调用之后、但在执行用户提供的代码或展开成员初始化列表之前。3.最后在所有其他操作之后。答案是第 2 种在基类构造函数调用之后。另外两种选择都行不通。如果你不信可以试着在第 1 种或第 3 种策略下去模拟 size() 的调用过程。第 2 种策略成功解决了“在类内部约束候选虚函数集合”的问题。如果每个构造函数都等到其基类构造函数执行完毕之后再去设置对象的 vptr那么每次都能正确地调用到该调用的虚函数版本。通过让每个基类构造函数把自己的 vptr 设置为该类对应的虚表正在构造的对象在构造函数执行期间就实实在在地变成了那个类的对象。也就是说一个 PVertex 对象会依次变成Point 对象、Point3d 对象、Vertex 对象、Vertex3d 对象最后才变成 PVertex 对象。在每个基类构造函数内部这个对象与构造函数所属类的完整对象没有区别。对于对象而言“个体发育重演系统发育”。构造函数执行的一般算法如下1.在派生类的构造函数中首先调用所有虚基类的构造函数然后调用所有直接基类的构造函数。2.这些调用完成后初始化对象的 vptr可能多个使其指向对应的虚表。3.如果存在成员初始化列表则在构造函数体内展开。这一步必须在 vptr 设置之后进行以防有虚成员函数被调用。4.最后执行用户显式提供的代码。例如给定下面这个用户定义的 PVertex 构造函数下面是一种可能的内部展开形式这完美地解决了我们之前提到的“约束虚机制”的问题。但它是完美的解决方案吗假设我们的 Point 构造函数定义如下而 Point3d 的构造函数定义如下进一步假设我们的 Vertex 和 Vertex3d 构造函数也以类似方式定义。你能看出尽管我们完美地解决了问题但这个解决方案为什么仍然不够完美吗有两种情况必须设置 vptr1.构造完整对象时。如果我们声明一个 Point 对象Point 的构造函数必须设置其 vptr。2.构造子对象期间有虚函数被直接或间接调用时。如果我们声明一个 PVertex 对象那么根据我们对基类构造函数的最新定义它的 vptr 会在每一个基类构造函数中被不必要地设置。解决办法是把构造函数拆分成完整对象版本和子对象版本。在子对象版本中如果可能应省略 vptr 的设置。基于我们目前的了解你应该能回答下面这个问题在构造函数的成员初始化列表中调用类的虚函数是否安全从物理层面说当该函数用于初始化类的某个数据成员时这样做总是安全的。因为正如我们所看到的编译器保证在展开成员初始化列表之前已经设置了 vptr。然而从语义层面看它可能并不安全——因为该函数本身可能依赖于那些尚未初始化的成员。我不推荐这种用法。但是仅从 vptr 的完整性角度来说这种操作是安全的。那么当为基类构造函数提供实参时呢在构造函数的成员初始化列表中调用类的虚函数此时仍然“物理上安全”吗不安全。此时 vptr 要么尚未设置要么被设置成了错误的类。此外可以保证该派生类的虚函数内访问到的任何类数据成员都尚未被初始化。这种错误情况的一个例子classBase{public:Base(intx){std::coutBase got: xstd::endl;}};classDerived:publicBase{public:Derived():Base(computeValue())// 在给基类构造函数传参时调用虚函数{importantData42;}virtualintcomputeValue()const{// 这里访问了 Derived 尚未初始化的成员returnimportantData*2;}private:intimportantData;};5.3 对象复制语意学Object Copy Semantics对象拷贝语义设计一个类时对于“用一个类对象给另一个类对象赋值”我们有三种选择1.什么都不做从而采用默认行为。2.提供一个显式的拷贝赋值运算符。3.显式禁止用一个类对象给另一个类对象赋值。禁止拷贝的方法是将拷贝赋值运算符声明为 private并且不提供定义声明为私有后除了类的成员函数和友元之外其他地方都无法进行赋值而我们又不提供定义这样一来如果有成员函数或友元试图进行拷贝程序就会链接失败诚然依赖链接器的特性——也就是依赖语言外部的东西——并不完全令人满意。在本节中我将探讨拷贝赋值运算符的语义以及编译器通常如何实现它们。我仍然用 Point 类来辅助说明在这个实现中我们没有理由禁止用一个 Point 对象给另一个 Point 对象赋值。问题在于默认行为是否足够如果我们只需要支持简单的赋值操作那么默认行为既充分又高效没有必要显式提供拷贝赋值运算符。只有当默认行为导致语义不安全或不正确时才需要拷贝赋值运算符关于逐成员拷贝及其潜在陷阱的完整讨论参见 [LIPP91c]。对我们的 Point 对象来说默认的逐成员拷贝行为不安全或不正确吗不坐标是按值存储的因此不会出现别名问题或内存泄漏。而且如果我们额外提供一个拷贝赋值运算符程序实际运行起来反而可能变慢。如果我们不为 Point 类提供拷贝赋值运算符而是依赖默认的逐成员拷贝那么编译器真的会生成一个实例吗答案和拷贝构造函数一样实际上不会。只要类满足逐位拷贝语义隐式的拷贝赋值运算符就被视为平凡的不会被合成。在以下情况下类对于默认的拷贝赋值运算符不具备逐位拷贝语义2.2 节有详细讨论1.类包含某个成员对象而该成员对象所属的类有拷贝赋值运算符。2.类派生自某个基类而该基类有拷贝赋值运算符。3.类声明了一个或多个虚函数我们不能直接拷贝右端类对象的 vptr 地址因为它可能指向一个派生类对象。4.类继承自某个虚基类这与基类是否有拷贝赋值运算符无关。标准将不具备逐位拷贝语义的拷贝赋值运算符称为非平凡的。在实际实现中只有非平凡实例才会被合成。因此对于我们的 Point 类下面这个赋值操作是通过将 Point b 逐位拷贝到 Point a 来完成的没有调用任何拷贝赋值运算符。从语义和性能两方面来看这恰恰是我们想要的。注意我们可能仍然希望提供一个拷贝构造函数以便启用具名返回值NRV优化。但拷贝构造函数的存在不应该强迫我们在不需要时也去提供一个拷贝赋值运算符。话虽如此我接下来要引入一个拷贝赋值运算符以便说明它在继承体系下的行为现在让我们派生 Point3d 类注意这里采用的是虚继承如果我们不为 Point3d 定义拷贝赋值运算符编译器就需要基于前面提到的第 2 条和第 4 条原因来合成一个。合成的实例大概如下所示拷贝赋值运算符与拷贝构造函数之间存在一个非正交的方面没有成员赋值列表——即没有与成员初始化列表平行的结构。因此我们不能这样写而必须采用以下两种方式之一来调用基类的拷贝赋值运算符或者没有这个拷贝赋值列表看起来可能只是个小问题但正因如此编译器通常无法抑制中间层基类拷贝赋值运算符的被调用。例如下面是 Vertex 的拷贝赋值运算符其中 Vertex 也是从 Point 虚继承而来的现在让我们从 Point3d 和 Vertex 派生出 Vertex3d。下面是 Vertex3d 的拷贝赋值运算符那么编译器该如何抑制 Point3d 和 Vertex 的拷贝赋值运算符中用户编写的 Point 拷贝赋值运算符实例呢编译器无法照搬传统构造函数中插入额外参数的办法。这是因为与构造函数和析构函数不同对拷贝赋值运算符取地址是合法的。因此下面这段代码完全合法尽管它会让任何试图对拷贝赋值运算符进行智能优化的尝试都彻底失效然而我们无法在合理支持上述代码指对拷贝赋值运算符取地址的同时又根据继承体系的特殊性质向拷贝赋值运算符中插入任意数量的额外参数这一点在支持包含虚基类的类对象数组分配时也带来了问题参见 6.2 节的讨论。另一种做法是编译器可以为拷贝赋值运算符生成拆分函数分别支持“作为最派生类”和“作为中间基类”两种情况。如果拷贝赋值运算符是由编译器生成的那么拆分函数的方案还算定义良好但如果是由类设计者自己编程实现的那就难以定义了。例如下面这段代码该如何拆分呢——尤其当 init_bases() 是虚函数时实际上拷贝赋值运算符在虚继承下的行为并不规范需要谨慎设计并提供清晰的文档说明。在实践中许多编译器甚至不去尝试把语义做对。它们会在每个中间层的拷贝赋值运算符中调用每一个虚基类的实例从而导致虚基类的拷贝赋值运算符被多次调用。cfront、Edison Design Group 的前端、Borland 4.5 C 编译器以及 Symantec 在 Windows 下的最新 C 编译器都是这样做的。我猜你的编译器也如此。那么C 标准对此有什么说法呢关于隐式定义的拷贝赋值运算符是否会对表示虚基类的子对象进行多次赋值标准并未明确规定第 12.8 节。一种基于语言层面的解决方案是为拷贝赋值运算符提供“成员拷贝列表”扩展。如果没有这种扩展任何解决方案都只能基于程序代码实现因此会有些复杂且容易出错。诚然这是语言的一个弱点在使用虚基类的设计中进行代码评审时应当仔细检查这一点。保证最派生类完成虚基类子对象拷贝的一种方法是在派生类的拷贝赋值运算符的最后显式调用该运算符因为编译器的做法不规范显式调用更安全这样做并不能消除虚基类子对象的多重拷贝但能保证最终的语义正确。其他解决方案需要把虚子对象的拷贝抽取到一个单独的函数中并根据调用路径有条件地调用它。我的建议是只要有可能就不要允许对虚基类进行拷贝操作。一个更强的建议是不要在作为虚基类的任何类中声明数据成员。5.4 对象的功能Object Efficiency应翻译为对象效率在下面这组性能测试中我们将测量对象构造和拷贝的开销。测试中Point3d 类的声明会逐步增加复杂度先从 Plain Ol’ Data 开始然后变为抽象数据类型ADT接着依次加入单继承、多继承和虚继承。我们使用下面的函数作为主要测量工具这个函数包含了四次逐成员初始化两个形参、返回值以及局部对象 pC。它还包含了两次逐成员拷贝分别是标有 //(1) 和 //(2) 的行中对 pC 和 b 的赋值。main() 函数如下所示在前两个程序中对象分别用 struct 和公有数据的 class 来表示pA 和 pB 都通过显式初始化列表来初始化这两种表示都具备逐位拷贝语义因此在这组测试中我们预期它们会跑出差不多最佳的成绩。结果如下CC 表现更好的原因在于NCC 生成的循环中多了六条汇编指令。这个“额外开销”并不反映任何特定的 C 语义也不代表 NCC 前端对代码处理得差——事实上两个编译器生成的中间 C 代码基本相当。这仅仅是后端代码生成器的一个小差异罢了。在下一轮测试中唯一的变化是数据成员变为私有并使用内联访问函数和内联构造函数来初始化每个对象。该类仍然具备逐位拷贝语义所以按照常理运行时的性能应该和之前一样。但实际上结果略有偏差我原本以为性能差异并非来自 lots_of_copies() 的执行而是来自 main() 中类对象的初始化。于是我修改了结构体的初始化方式改为下面这样以模拟内联构造函数的内联展开结果发现两种执行的时间都增加了。现在它们与封装类即ADT核心思想之一是封装也就是将成员设为私有的表现一致通过内联展开构造函数来初始化坐标成员在汇编层面会产生两条指令一条是将常量值加载到寄存器另一条是实际存储该值而通过显式初始化列表来初始化坐标成员则只产生一条存储指令因为常量值已被“预加载”封装与非封装的 Point3d 声明之间还有一个差异体现在在 ADT 表示下pC 会自动通过其默认构造函数的内联展开来初始化尽管在这个例子中让它保持未初始化状态其实是安全的。一方面这些差异确实很小但它们给“支持内联的封装与 C 语言中的直接数据操作完全等价”这种断言提供了一个有趣的警示。另一方面这些差异通常并不显著不足以成为放弃数据封装所带来的软件工程好处的理由。不过在那些特别追求性能的关键代码区域里还是值得留意的。在下一轮测试中我将 Point3d 的表示拆分成了一个具体的三层单继承体系结构如下这里没有引入任何虚函数。由于 Point3d 类仍然具备逐位拷贝语义因此单继承的加入应该不会影响逐成员对象初始化或拷贝的开销。结果也证实了这一点接下来的多重继承关系虽然确实有点刻意但就成员分布而言它至少能达到测试的目的——至少能给我们提供一个测试用例 。由于 Point3d 类仍然具备逐位拷贝语义多重继承的加入应该不会增加逐成员对象初始化或拷贝的开销。结果也确实如此——除了优化版的 CC 版本出人意料地跑得稍微好了一点在迄今为止的所有测试中不同版本之间的差异有趣地集中在初始化三个局部对象的开销上而不是逐成员初始化与拷贝的开销。这些操作一直均匀地执行着因为到目前为止的所有表示都支持逐位拷贝语义。然而虚继承的引入会改变这一切。下面是一个单层虚继承体系虚继承使得类不再具备逐位拷贝语义第一层虚继承就打破了逐位拷贝第二层只会让情况更糟。编译器现在会生成并应用内联合成的拷贝构造函数和拷贝赋值运算符。这一变化导致性能开销显著增加为了更好地理解这个数字我回过头去修改之前的表示——从封装类声明开始添加一个虚函数。回忆一下这会打破逐位拷贝语义。编译器同样会生成并应用内联合成的拷贝构造函数和拷贝赋值运算符。这次性能提升虽然没那么显著但仍然比支持逐位拷贝时高出约 40%–50%。如果这些函数是用户提供的非内联版本开销还会更大以下是其他几种表示法下的测试时间这些表示法中逐位拷贝语义被替换成了内联合成的逐成员拷贝构造函数和拷贝赋值运算符。随着继承体系复杂度的增加对象构造和拷贝的默认开销也随之上升5.5 解构语意学Semantics of Destruction析构语义如果类没有定义析构函数那么只有当类包含某个带有析构函数的成员或基类时编译器才会合成一个。否则析构函数被认为是平凡的因此在实际中既不会被合成也不会被调用。例如我们的 Point 类即使包含一个虚函数默认情况下也不会为其合成析构函数类似地假如我们用两个 Point 对象组合成一个 Line 类由于 Point 本身没有析构函数Line 也不会被合成析构函数。此外当我们从 Point 派生出 Point3d 时——即使是虚派生——如果我们没有声明析构函数编译器在实际中也同样不需要合成一个。对于 Point 和 Point3d 这两个类来说析构函数都是不必要的提供它们反而会降低效率。你应该抵制我所说的那种“原始的对称冲动”因为你定义了一个构造函数所以觉得再提供一个析构函数才对称。在实践中你应该因为需要才提供析构函数而不是因为它“感觉”对或者因为你不太确定是否需要它。要判断一个类是否需要程序层面的析构函数或者构造函数就此而言你可以考虑这样一个问题当该类对象的生命周期结束或开始时需要做些什么来保证该对象的完整性那些你希望程序去做的操作否则就得由类的用户来做了最好就是你该放进析构函数或构造函数里的内容。举个例子我们看到pt 和 p 在被用作 foo() 的实参之前都必须先初始化成某个坐标值。构造函数是必要的否则用户就得自己显式提供这些坐标值。通常用户无法通过检查局部变量或堆变量的状态来判断它是否已经被初始化。把构造函数视为程序开销是不对的因为原本需要做的工作仍然存在。没有构造函数使用抽象会更易出错。那么当我们显式 delete p 时呢需要做什么编程操作吗你会不会在调用 delete 之前写下面这样的代码当然不会。删除对象之前没有理由去重置坐标值。也没有任何需要回收的资源。在 pt 和 p 的生命周期结束之前不需要任何用户层面的编程操作因此不需要析构函数。然而考虑一下我们的 Vertex 类。它维护了一个相邻顶点的链表。在 Vertex 对象终止时依次遍历并删除这个相邻顶点链表——这可能是合理的。如果需要这种或其他语义那正是 Vertex 析构函数该做的程序层面工作。当我们从 Point3d 和 Vertex 共同派生出 Vertex3d 时如果不提供显式的 Vertex3d 析构函数那么当 Vertex3d 对象终止时仍然需要调用 Vertex 的析构函数。因此编译器需要合成一个 Vertex3d 析构函数它唯一的工作就是调用 Vertex 的析构函数。如果我们自己提供了 Vertex3d 析构函数编译器会在用户代码执行完毕后对其进行扩充插入对 Vertex 析构函数的调用。用户定义的析构函数会以与构造函数类似的方式被扩充只不过顺序相反1.如果对象包含 vptr则将其重置为当前类所关联的虚表。2.然后执行析构函数的函数体——也就是说vptr 在用户代码执行之前就被重置了。3.如果类中有带有析构函数的成员类对象则按照它们声明顺序的相反顺序调用这些析构函数。4.如果有任何带有析构函数的直接非虚基类则按照它们声明顺序的相反顺序调用这些析构函数。5.如果有任何带有析构函数的虚基类并且当前类代表最派生类则按照这些虚基类原先构造顺序的相反顺序调用它们的析构函数。与构造函数类似目前对析构函数最佳实现策略的思考是维护两个实例1.完整对象版本总会设置 vptr并调用虚基类的析构函数。2.基类子对象版本从不调用虚基类的析构函数并且只有当析构函数体内可能调用虚函数时才会设置 vptr。对象的生命周期在它的析构函数开始执行时就宣告结束。随着各个基类的析构函数依次被调用派生对象实际上会依次变成该基类类型的完整对象。例如一个 PVertex 对象在它所占用的存储空间被回收之前会依次变成Vertex3d 对象、Vertex 对象、Point3d 对象最后变成 Point 对象。如果在析构函数内部调用了成员函数这种对象变形是通过在每个析构函数中、在用户代码执行之前重置 vptr 来实现的。关于析构函数在程序中的实际语义我们将在第 6 章中讨论。