深度探索C++对象模型 学习笔记 第三章 Data语意学(2)
加上多态Adding Polymorphism如果我们希望独立于点是 Point2d 还是 Point3d 实例来对点进行操作就需要在类的继承体系中提供一个虚函数接口。下面我们来看看引入虚函数之后会发生怎样的变化只有在打算以多态的方式操作二维点和三维点时——例如编写如下代码——将虚接口引入我们的设计才有意义在这样的函数中p1 和 p2 可能是二维点也可能是三维点。这是之前所有设计都不曾支持的功能。当然这种灵活性正是面向对象编程的核心所在。然而支持这种灵活性确实会给我们的 Point2d 类带来一些空间和访问时间上的开销1.引入虚函数表每个 Point2d 类都会关联一个虚函数表用于存放它所声明的每个虚函数的地址。这个表的大小通常是所声明的虚函数个数再加上额外的一到两个槽位用于支持运行时类型识别RTTI。2.在每个类对象中引入 vptr 指针vptr 为对象提供了运行时的链接使其能够高效地找到对应的虚函数表。3.对构造函数进行扩充需要初始化对象的 vptr使其指向该类的虚函数表。根据编译器优化的激进程度不同这可能意味着在派生类以及每个基类的构造函数中都要重新设置 vptr这一点将在第5章中详细讨论。4.对析构函数进行扩充需要将 vptr 重新设置为当前子类的虚函数表因为在派生类的析构函数中vptr 很可能已经被设置为指向派生类的虚函数表了。请记住析构函数的调用顺序是相反的先调用派生类再调用基类。这些开销的实际影响取决于程序中 Point2d 对象的数量、生命周期以及通过多态方式使用这些对象所带来的收益。如果一个应用明确知道自己对点的使用仅限于二维点或三维点中的一种而非两种混合使用那么这种多态设计所带来的开销就可能变得难以接受。下面是我们的新版 Point3d 派生类定义尽管该类的声明语法看起来没有任何变化但其内在的一切都已截然不同两个 z() 成员函数以及 operator() 运算符都变成了虚函数。每个 Point3d 类对象中都包含一个额外的 vptr 成员对象这个 vptr 是从 Point2d 继承而来的。此外还存在一张专属于 Point3d 的虚函数表。而对每个虚成员函数的调用方式也变得更为复杂这部分内容将在第4章中详细讨论。目前在C编译器社区中有一个正在争论的话题vptr 究竟应该放在类对象中的哪个位置最好。在最初的 cfront 实现中vptr 被放置在类对象的末尾。目的是为了支持以下继承模式将 vptr 放置在类对象的末尾可以保持基类即 C 结构体原有的对象布局从而使得该类对象能够直接用于 C 代码中。许多人都认为这种继承方式在 C 刚问世时比现在更为常见。到了 2.0 版本之后随着对多重继承和抽象基类的支持被加入以及面向对象编程范式的普遍流行一些编译器实现开始将 vptr 放置在类对象的起始位置例如领导微软最初 C 编译器开发工作的 Martin O’Riordan 就曾有力地论证过这种实现模型。图 3.2(b) 展示了这一布局方式将 vptr 放置在类对象的起始位置在多重继承场景下通过指向类成员的指针调用某些虚函数时效率更高详见第4.4节。否则不仅需要在运行时获取指向类对象起始地址的偏移量还需要额外获取该类中 vptr 所在位置的偏移量。然而这种做法的代价是失去了与 C 语言的互操作性。这个代价有多大呢有多少程序会从一个 C 语言的结构体派生出多态的类呢目前还没有任何经验数据能够支持其中任何一种立场。图3.3展示了引入虚函数之后 Point2d 和 Point3d 的继承布局需要说明的是该图中 vptr 被放置在基类的末尾位置多重继承Multiple Inheritance单继承在继承体系中提供了一种“天然的”多态性尤其体现在基类与派生类类型之间的转换上。请看图3.1(b)、图3.2(a)或图3.3你会发现基类对象和派生类对象的起始地址是相同的。它们的区别仅在于派生类对象扩展了其非静态数据成员的长度。像下面这样的赋值操作将派生类对象的地址赋给基类指针或引用时无论继承层次有多深都不需要编译器做任何额外的干预或地址调整。这种转换是“自然而然”发生的从这个意义上说它提供了最优的运行时效率。从图3.2(b)可以看出将 vptr 放置在类对象的起始位置在一种特殊情况下会破坏单继承的“自然多态性”——即基类没有虚函数而派生类包含虚函数。此时将派生类对象转换为基类类型时需要编译器的干预必须根据 vptr 的大小对要赋值的地址进行相应调整。而在多重继承和虚继承的情形下编译器干预的必要性则更为显著。多重继承既不像单继承那样行为规整也不那么容易建模。其复杂性源于派生类与第二个及后续基类子对象之间那种“非自然”的关系。例如考虑下面这个多重继承的派生类 Vertex3d多重继承带来的问题主要集中体现在派生类对象与它的第二个以及后续基类子对象之间的转换上。这些转换可能以两种形式出现1.直接的转换例如2.通过虚函数机制的间接支持关于虚函数调用所引发的问题将在第4.2节中详细讨论。多重继承中将派生类对象的地址赋给其最左边即第一个基类的指针时其处理方式与单继承完全相同——因为两者指向的是同一个起始地址。这种转换的开销仅仅是地址的简单赋值下展示了多重继承的内存布局然而当需要将派生类对象的地址赋给第二个或后续基类的指针时情况就不同了该地址必须通过加上或减去在向下转型的情况下中间基类子对象的大小来进行调整。例如考虑以下代码那么以下赋值操作需要编译器进行如下的地址转换伪C代码而以下赋值操作则都只需要简单地复制地址即可无需任何偏移调整——因为 Point2d 和 Point3d 都位于最左边的基类位置或者与 Vertex3d 起始地址相同。现在考虑使用指针的情况对于以下赋值情况并不能简单地转换为如下形式因为如果 p3vd 被设为 0那么按照之前的简单加法pv 就会得到一个值为 sizeof(Point3d) 的非零地址这显然是不正确的。因此对于指针而言内部转换必须加入一个条件判断如下所示而对于引用的转换则无需处理可能出现的 0 值因为 C 规定引用永远不可能指向空对象即不存在“空引用”。因此引用转换时可以直接进行地址偏移不需要额外的条件测试。C 标准并没有规定 Vertex3d 中 Point3d 和 Vertex 这两个基类的具体排列顺序。在最初的 cfront 实现中它们总是按照声明的顺序来放置。因此在 cfront 中一个 Vertex3d 对象的内存布局依次包含Point3d 子对象而 Point3d 本身又包含一个 Point2d 子对象紧接着是 Vertex 子对象最后才是 Vertex3d 自己新增的部分。事实上这仍然是当前所有编译器对多重基类进行布局的通用做法虚继承的情况除外。不过某些编译器例如 MetaWare 编译器会进行一种优化如果第二个或后续基类声明了虚函数而第一个基类没有那么它们就会交换多个基类的排列顺序。这种重新排序的好处是可以避免在派生类对象中额外生成一个 vptr可能指的是把派生类的vptr放到基类的虚表里而且只有第一个基类有虚函数时才这样做C标准只规定了虚函数的语义并未规定vptr的实现细节原文这一点了解即可。但是不同编译器对这一优化的重要性看法并不统一而且这种优化目前至少到现在并不普遍。访问第二个或后续基类的数据成员会有额外开销吗答案是不会。在编译阶段成员的位置就已经被确定下来。因此无论访问该成员时使用的是指针、引用还是对象本身其访问方式都如同单继承一样简单——仅需计算一个固定的偏移量即可。这种机制确保了多重继承中的成员访问效率与单继承保持一致不会带来额外的性能开销。虚拟继承Virtual Inheritance多重继承的一个语义副作用是需要支持一种共享子对象继承的形式。其经典示例是原始iostream库的实现istream 和 ostream 类各自都包含一个 ios 子对象。然而在 iostream 的布局中我们只需要一个 ios 子对象。语言层面的解决方案是引入虚拟继承尽管虚继承的语义看似复杂但其在编译器中的实现被证明更为困难。在我们的iostream示例中实现面临的挑战在于需要找到一种合理高效的方法将istream类和ostream类各自维护的ios子对象的两个实例合并为iostream类维护的单个实例同时仍需保持基类与派生类对象指针及引用间的多态赋值能力。通用实现方案如下。像istream这样包含一个或多个虚拟基类子对象的类其内存布局被划分为两个区域不变区域和共享区域。不变区域内的数据无论后续如何派生其相对于对象起始地址的偏移量始终保持不变。因此可以直接访问不变区域内的成员。共享区域则代表虚拟基类子对象该区域内数据的偏移量会随每次派生而浮动。因此共享区域内的成员需要通过间接方式访问。不同编译器实现方案的主要差异就在于这种间接访问的方法。以下面代码为例展示了三种主流策略以下是一个虚拟继承的Vertex3d类的层次结构只展示了其中的数据部分通用的布局策略是首先放置派生类中不变的部分即派生类自身新增的非静态数据成员然后再构建共享的部分即虚基类子对象。然而还有一个问题尚未解决实现层面如何访问类的共享部分在最初的 cfront 实现中编译器会在每个派生类对象中插入一个指向每个虚基类的指针。对继承而来的虚基类成员的访问就是通过这个相关的指针间接完成的。例如考虑以下 Point3d 的运算符实现会被内部转换为类似下面的伪代码伪C代码而派生类与基类之间的转换例如在 cfront 的实现模型下则会被转换为这种实现模型存在两个主要的缺陷1.每个虚基类都会在类对象中引入一个额外的指针。理想情况下我们期望类对象的空间开销是一个常量与继承体系中虚基类的数量无关——你可以思考一下如何才能实现这一目标。2.虚继承链越长间接访问的层级也越深。也就是说如果存在三层虚继承就需要通过三个虚基类指针逐级间接访问。理想情况下我们希望无论虚继承的深度如何访问时间都保持常量。MetaWare 以及其他仍在使用 cfront 原始实现模型的编译器通过将嵌套的虚基类指针全部提升即复制到派生类对象中解决了第二个问题——即访问时间随虚继承深度增加而增加的问题。这种方法虽然以重复存储嵌套的虚基类指针为代价但换来了恒定时间的访问性能。MetaWare 还提供了一个编译时开关允许程序员自行选择是否生成这些重复的指针。图 3.5(a) 展示了这种指向基类的指针实现模型针对第一个问题每个虚基类都引入一个额外指针业界主要有两种通用的解决方案。微软的编译器引入了虚基类表virtual base class table的概念每个包含一个或多个虚基类的类对象中会插入一个指向虚基类表的指针而各个虚基类所对应的指针则被统一存放在这张表中。尽管这种解决方案已经存在多年但据我所知目前还没有其他编译器采用它这可能是因为微软对其虚函数实现申请了专利从而实际上禁止了其他编译器的使用。第一个问题的第二种解决方案——也是 Bjarne至少在我与他一起从事 Foundation 项目期间所偏爱的方案——是不直接存储虚基类的地址而是在虚函数表中存放虚基类在派生类对象中的偏移量图 3.5(b) 展示了这种基类偏移实现模型我曾在 Foundation 研究项目中实现了这一方案将虚基类条目与虚函数条目交织在一起。在较新的 Sun 编译器中虚函数表同时支持正索引和负索引正索引与之前一样用于访问虚函数集负索引则用于获取虚基类的偏移量。在这种策略下Point3d 的加法运算符会被翻译成如下通用形式为保持可读性省略了类型转换也未展示更高效的地址预计算尽管在这种策略下访问继承而来的成员的开销会更高一些但这种开销仅局限于实际使用该成员的地方。而派生类与基类实例之间的转换例如在该实现模型下会被转换为如下形式伪C代码以上所述均为具体的实现模型并非 C 标准所强制要求的。它们分别以各自的方式解决了同一个核心问题如何访问一个位置可能随每次派生而变化的共享子对象。由于支持虚基类所带来的开销和复杂性不同的编译器实现之间存在一定差异并且这种实现很可能会随着时间推移而继续演进。通过非多态类对象即具体的类对象而非指针或引用来访问继承而来的虚基类成员时例如编译器可以将其优化为直接的成员访问这与通过对象调用虚函数时能够在编译期解析是类似的。因为对象的类型在程序的两次访问之间不会改变所以虚基类子对象位置可能变化的问题在这种情况下并不存在。一般而言虚基类最有效的用法是将其作为不含任何数据成员的抽象虚基类。3.5 对象成员的效率Object Member Efficiency接下来的这组测试旨在衡量使用聚合、封装和继承所带来的额外开销。所有测试的基准都是对独立局部变量进行赋值、加法和减法等操作时的访问成本实际执行的表达式循环会迭代 1000 万次其形式如下所示当然具体的语法会随着坐标点的表示方式不同而有所变化第一个测试用于与使用独立变量的情况进行对比其场景是使用一个包含三个 float 类型元素的局部数组代码中pA和pB分别是两个包含三个元素的数组数组下标为x、y、z这三个下标是枚举类型的默认x是0、y是1、z是2第二个测试将同质的数组元素转换为一个 C 结构体数据抽象其中包含三个具有明确名称的 float 成员x、y 和 z在抽象的阶梯上下一步便是引入数据封装以及内联访问函数的使用。此时点的表示方式被封装为一个独立的 Point3d 类。我尝试了两种形式的访问函数1.第一种定义一个返回引用的内联函数使其能够出现在赋值运算符的两侧对每个坐标元素的实际访问其代码形式如下所示2.第二种提供了一对get和set函数此时每个坐标值的赋值操作则呈现为如下形式表3.1列出了两种编译器运行测试的结果仅当两种编译器的性能表现存在显著差异时我才会将它们的时间数据分开列出就实际程序性能而言这里的关键在于当开启优化后封装以及使用内联访问函数并不会带来任何运行时的性能开销。我很好奇为什么在 CC 编译器下数组访问的速度几乎比 NCC 慢了一倍尤其是这里的数组访问仅仅涉及 C 语言层面的数组操作并没有用到任何“复杂”的 C 特性。一位代码生成方面的专家将这种异常现象归结为“代码生成中的某种特殊行为……仅在某些特定编译器中才会出现”。或许事实确实如此但问题在于这个编译器恰好是我目前用来开发软件的那个。如果你愿意不妨叫我“好奇的乔治”。如果你对此不感兴趣可以直接跳过接下来的几段内容。在下面的汇编输出中l.s 表示加载单精度浮点值s.s 表示存储单精度浮点值sub.s 表示两个单精度浮点数相减。对于两个编译器生成的汇编代码它们都执行了相同的操作加载两个值相减然后存储结果。但在效率较低的 CC 输出中每个局部变量的地址都被计算出来并存入寄存器addu 表示无符号加法在 NCC 生成的汇编代码中加载步骤直接计算出地址无需额外的地址加法指令如果局部变量需要被多次访问那么 CC 编译器的策略可能会更高效。然而对于单次访问来说那种将变量地址预先放入寄存器的做法虽然看似合理却会显著增加表达式的开销。无论如何一旦开启优化两种编译器生成的代码序列最终都会被转换为相同的形式循环内的所有操作都直接在寄存器中的值上完成。一个显而易见的结论是如果不开启优化几乎不可能准确推测程序的性能特征因为代码很可能受制于“特定编译器独有的代码生成特性”。因此在试图通过源代码级别的“优化”来加速程序之前务必要进行实际的性能测量而不是仅仅依赖猜测和直觉。在接下来的测试中我首先引入了 Point 抽象的三层单继承表示然后又引入了虚继承表示。我分别测试了直接访问即对象.成员的方式访问和内联访问即对象.x()x方法直接返回成员x的值两种方式多重继承与这个模型不太契合因此我决定暂不测试。整个继承层次的基本结构如下在虚继承的测试中我设置了两种情况1.单层虚继承Point2d 虚继承自 Point1d。2.两层虚继承在单层虚继承的基础上Point3d 再虚继承自 Point2d。表 3.2 列出了这两种编译器运行测试的结果同样只有当两种编译器的性能表现存在显著差异时我才会将它们的时间数据分开列出单继承按理说不会影响测试的性能表现因为所有成员在派生类对象中是连续存储的并且它们的偏移量在编译时就已经确定。正如所料测试结果与独立抽象数据类型的情况完全一致多重继承下应该也是如此不过我没有亲自验证这一点。再次强调一个值得注意的现象在关闭优化的情况下那些凭直觉认为性能应该相同的操作比如直接访问成员 vs. 通过内联函数访问实际上内联函数的版本反而更慢。这里再次得到的教训是关心效率的程序员必须实际测量程序的性能而不能仅凭猜测和假设来推断。另外还需注意优化器并非总能如预期般工作。我不止一次遇到过这样的情况在开启优化时编译失败而“正常”关闭优化时却能顺利通过编译。虚继承下的性能表现令人失望两种编译器都没能意识到对继承而来的数据成员 pt1d::_x 的访问实际上是通过非多态类对象进行的因此运行时根本不需要间接访问。尽管在编译时该成员在两个 Point3d 对象中的位置就已经固定但编译器依然生成了对 pt1d::_x 的间接访问代码在两层虚继承的情况下对 pt1d::_y 也是如此。这种间接访问严重抑制了优化器将全部操作移至寄存器中执行的能力。不过对于未优化的可执行程序而言这种间接访问的影响并不显著。3.6 指向Data Member的指针Pointer to Data Members指向数据成员的指针是 C 语言中一个略显晦涩但却很有用的特性尤其是在你需要探查类的底层成员布局时。例如可以用它来判断 vptr 是位于类对象的起始位置还是末尾。另一个用途在第 3.2 节中已经展示过是确定类中不同访问段的排列顺序。正如我所说这是一个虽然有些隐晦、但具有潜在实用价值的语言特性。来看下面这个 Point3d 类的声明。它声明了一个虚函数、一个静态数据成员以及三个坐标值每个 Point3d 类对象中的成员布局都包含三个坐标值按 x、y、z 的顺序排列此外还有一个 vptr回想一下静态数据成员 origin 是被提升到各个类对象之外的。布局中唯一的实现相关方面就是 vptr 的放置位置。标准允许 vptr 被放置在对象内的任意位置可以在开头、末尾或者三个成员之间的任何地方。在实践中所有实现都将其放在开头或末尾。那么取其中一个坐标成员的地址意味着什么例如下面的表达式应该产生什么值我自己测试时发现z是protected的无法访问我用的MSVC 编译器版本号: 1938它将产生 z 坐标在类对象内的偏移量。至少这个偏移量必须等于 x 和 y 成员的大小之和因为标准要求同一个访问级别内的成员按照声明顺序排列。然而编译器可以自行决定将 vptr 放置在坐标成员之前、之间或之后。同样在实践中vptr 要么放在类对象的开头要么放在末尾。在32位机器上每个 float 占4字节因此我们预期偏移量要么是8字节如果中间没有 vptr 间隔要么是12字节如果有 vptr 间隔——在32位架构下vptr 以及一般的指针都占4字节。但是这个预期差了一个1——这可以说是 C 和 C 程序员历来容易犯的一种错误。实际上三个坐标成员在类布局中的物理偏移量分别是若 vptr 放在末尾则为 0、4、8若 vptr 放在开头则为 4、8、12。然而取成员地址所返回的值总是会被加一。因此实际得到的数值是 1、5、9 等。你能猜到 Bjarne 为什么要这样做吗但我输出的内容没有加1是4、8、12我用的MSVC 编译器版本号: 1938问题在于如何区分“不指向任何数据成员的指针”和“指向第一个数据成员的指针”。例如为了区分 p1 和 p2每个实际成员的偏移值都被加1处理。因此编译器以及用户在实际使用该值访问成员之前都必须记住先减1。基于现在对指向数据成员的指针的了解我们就能很清楚地解释下面两种写法的区别和取非静态数据成员的地址得到的是该成员在类中的偏移量而取绑定到具体类对象的数据成员的地址得到的则是该成员在内存中的实际地址。表达式 origin.z 的结果是将 z 的偏移量减1后加到 origin 的起始地址上实际上由于C地址空间是从高地址向低地址方向扩张的因此应该是用origin的地址减去z的偏移量加1。返回值的类型是float*而不是float Point3d::*因为它指向的是一个具体的实例这与取静态数据成员的地址非常相似。在多重继承下当我们将一个指向第二个或后续基类的成员指针与派生类对象绑定使用时情况会变得复杂因为需要额外加上一个偏移量才能正确访问。举个例子假设有如下定义当 bmp 作为第一个参数传递给 func1() 时必须根据 Base1 子对象的大小对其进行调整。否则在 func1() 内部执行pd-*dmp时将会错误地访问 Base1::val1而不是程序员期望的 Base2::val2。针对这种情况编译器会在内部进行如下转换然而一般情况下我们无法保证 bmp 不是空指针即 0因此还需要加上空指针检查指向成员的指针的效率以下这组测试旨在衡量在三维点的各种类表示形式下使用指向成员的指针会带来多少额外开销。前两种情形都不涉及继承。第一种情形取绑定到具体对象成员的地址普通指针。例如对点 pA 和 pB 的三个坐标成员取地址然后通过指针进行赋值、加法和减法操作第二种情形取指向数据成员的指针成员指针。例如指向 Point3d 类数据成员的指针然后使用指向成员的指针语法将指针与具体对象 pA 和 pB 绑定回忆一下在第3.5节中执行的该函数直接数据成员测试在两个编译器上开启优化时平均用户时间为 0.80关闭优化时为 1.42。下面这两项测试指向上节的两种成员指针方式的结果连同直接数据访问的结果一并列在表 3.3 中未优化时的结果符合预期——也就是说通过绑定指针访问成员时多出来的一层间接操作让执行时间增加了一倍以上。而改用成员指针访问时间又近乎再翻一番。将数据成员指针绑定到类对象的过程相当于在对象地址上增加一个“偏移量减1”的调整值。当然更重要的一点是优化器能够将这三种访问方式的性能拉平到同一水平唯独NCC优化器表现异常这里值得注意的是NCC生成的可执行文件在开启优化后性能仍惨不忍睹反映的是其生成的汇编代码优化质量糟糕而不是C源码层面的属性问题。对比CC与NCC在未优化时产生的汇编输出两者其实完全一致。接下来的一组测试着眼于继承关系对数据成员指针性能的影响。在第一项测试中原先独立的Point类被重新设计成一个三层单继承体系每层类持有一个坐标值成员接下来的表示法仍保留三层单继承体系但引入了一层虚继承Point2d 虚继承自 Point。这样一来每次访问 Point::x 时都是在访问一个虚基类的数据成员。之后更多是出于好奇而非实际需要最终版本又加入了第二层虚继承即 Point3d 虚继承自 Point2d。表 3.4 展示了测试结果注意NCC 优化器在各轮测试中表现始终糟糕故未将其列入表中由于继承而来的数据成员直接存储在类对象内部引入继承对代码性能完全没有影响。而引入虚继承的主要后果是削弱了优化器的效能。原因何在在这两种实现中每增加一层虚继承就会额外引入一层间接访问。对于两种实现而言每次对 Point::x 的访问比如都会被转译成对象中的虚基类指针指向对象中虚基类对象所在位置而非更直接的正是这多出来的一层间接操作降低了优化器将所有处理过程搬进寄存器的能力。