深度探索C++对象模型 学习笔记 第五章 构造、解构、拷贝语意学(2)
2026/6/9 4:08:46 网站建设 项目流程

vptr初始化语意学(The Semantics of the vptr Initialization,vptr的初始化语义)

当我们定义一个 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 构造函数也以类似方式定义。你能看出,尽管我们完美地解决了问题,但这个解决方案为什么仍然不够完美吗?

有两种情况必须设置 vptr:
1.构造完整对象时。如果我们声明一个 Point 对象,Point 的构造函数必须设置其 vptr。

2.构造子对象期间,有虚函数被直接或间接调用时。

如果我们声明一个 PVertex 对象,那么根据我们对基类构造函数的最新定义,它的 vptr 会在每一个基类构造函数中被不必要地设置。解决办法是把构造函数拆分成完整对象版本和子对象版本。在子对象版本中,如果可能,应省略 vptr 的设置。

基于我们目前的了解,你应该能回答下面这个问题:在构造函数的成员初始化列表中调用类的虚函数是否安全?

从物理层面说,当该函数用于初始化类的某个数据成员时,这样做总是安全的。因为正如我们所看到的,编译器保证在展开成员初始化列表之前已经设置了 vptr。然而,从语义层面看,它可能并不安全——因为该函数本身可能依赖于那些尚未初始化的成员。我不推荐这种用法。但是,仅从 vptr 的完整性角度来说,这种操作是安全的。

那么,当为基类构造函数提供实参时呢?在构造函数的成员初始化列表中调用类的虚函数,此时仍然“物理上安全”吗?不安全。此时 vptr 要么尚未设置,要么被设置成了错误的类。此外,可以保证:该派生类的虚函数内访问到的任何类数据成员都尚未被初始化。这种错误情况的一个例子:

classBase{public:Base(intx){std::cout<<"Base got: "<<x<<std::endl;}};classDerived:publicBase{public:Derived():Base(computeValue())// 在给基类构造函数传参时调用虚函数{importantData=42;}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 章中讨论。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询