深度探索C++对象模型 学习笔记 第三章 Data语意学(1)
对于以下代码结构:
以上四个类中没有一个内含明显的数据,有人认为每个class的大小都应该是0,但事实是:
类X实际有1 byte大小,是被编译器安插进去的一个char,这使得两个X类的对象在内存中能有不同的地址:
而类Y和Z的大小与机器和编译器有关,具体受到三个因素的影响:
1.语言本身造成的额外负担
语言支持虚基类时,会导致一些额外负担,派生类中,这个额外负担反映在某种形式的指针上,它可以指向虚基类子对象,或指向一个表格:表格中存放的不是虚基类子对象的地址,就是其偏移量。Y类大小为8 byte的机器上,指针是4 byte的。
2.编译器对特殊情况提供的优化处理
虚基类X子对象的1 byte也存在Y和Z中,一般它被放在派生类中固定部分的尾端。某些编译器会对空虚基类提供特殊支持,但Y类大小为8 byte的机器上未提供这项处理。
3.Alignment限制
类Y和Z的大小目前为5 byte,大部分机器上,结构体的大小会受到alignment的限制,使其能够更有效率地在内存中被存取,在上例机器上,alignment是4 byte,所以类Y和Z必须填补3 byte,最终就是8 byte。
在新近的编译器上,对上图中Y和Z类提供了特殊处理,即空虚基类子对象被视为派生类对象开头的部分,既然有了member,因此就节省了以上第2点中的1 byte,在此模型下,Y和Z的大小都是4 byte。
A的大小某种程度上由你用的编译器而定,上例中Y和Z大小都是8 byte,而A的大小不是16 byte,而是12byte。一个虚拟基类子对象在派生类中只存在一份实体,不管它在继承体系中出现了多少次,A的大小由以下几点组成:
1.被大家共享的一个X实体,大小为1 byte。
2.Y的大小5,减去自己包含的那1 byte,结果是4,Z同理,Y和Z加起来8 byte。
3.A自己的大小为0。
4.前三项总和9 byte,A的alignment需调整至4 byte边界,因此A的大小是12 byte。
考虑对空虚基类做了特殊处理的编译器,X实体的那1 byte会被拿掉,A的大小变为8,填充的3 byte也不需要了。注意:如果在虚拟基类X中放置一个以上成员,那么两种编译器就会产出完全相同的对象布局。
C++标准不规定基类子对象的排列次序,或数据成员排列次序这种琐碎细节,细节由厂商自定。
C++对象模型把非static数据成员直接存在一个类对象中,对于继承而来的非static数据成员也是如此,但没有定义其排列顺序。static数据成员被放置在程序的一个global data segment中,不会影响单个class object的大小,不管该class产生了多少个object(即使是0个),static data members永远只存在一份实体。
3.1 Data Member的绑定
考虑以下代码:

Point3d::X()传回的x是class内部的那个,而非extern的那个,但这一点在早期编译器上并不是这样。
最早的C++编译器上,在Point3d中对x的取用操作会指向global x objcet,这种绑定结果几乎普遍不在大家的预料中,并因此导出两种早期C++防御性编程风格:
1.把所有data member都放在class开头处,以确保正确绑定:
2.所有inline function,不管大小都放在class声明之外:

以上程序设计风格到今天还存在,但它们的必要性伴随着C++ Reference Manual的修订已经消失了。这个古老的规则被称为member rewriting rule,大意是:内联函数的函数体需在完整类声明被解析后才进行求值(此时才绑定其中用到的变量)。C++标准用成员作用域解析规则来细化这个rewriting rule,其效果依然不变是:内联成员函数的函数体会被视为在其类声明之后立即定义那样进行求值。即有一个人写下以下代码时:
成员函数体的分析会延迟到整个类声明被解析完毕后进行,因此,内联成员函数体内的数据成员绑定,要等到整个类声明都可见之后才会发生。
但这一点并不适用于成员函数的参数列表。参数列表中的名字仍然会在它们首次被解析的位置当场进行决议。因此,外部变量(extern)与嵌套类型名之间仍然可能出现非直观的绑定。例如,在下面的代码片段中,两个成员函数签名中length的类型都会被解析为全局typedef的类型——即int。当后续遇到length的嵌套 typedef 声明时,标准要求将之前已经完成的绑定标记为非法。
上图中,mumble方法的参数类型被解析为int,_val成员的类型被解析为float。语言的这一特性仍然要求采用通用的防御性编程风格:始终将嵌套类型声明放在类的开头。在我们的例子中,只要将定义 length 的嵌套 typedef(nested typedef)放在类中任何使用它的地方之前,就能纠正这种非直观的绑定。
3.2 Data Member的布局
已知下面一组data member:
非静态数据成员在每个类对象中按照其声明顺序排列(其间若有静态数据成员,如freeList和chunkSize,则会被忽略)。因此,在我们的示例中,每个Point3d对象都按顺序包含三个float类型成员:x、y、z。而静态数据成员则独立于各个类对象,存储于程序的数据段中。
标准规定,在同一个访问段(即类的私有段、公有段或保护段)内,成员只需满足“较晚声明的成员在类对象中具有较高的地址”(见标准第9.2节)。也就是说,并不要求成员在内存中连续排列。那么,已声明的成员之间可能插入什么呢?后续成员的类型可能会因其对齐约束而需要填充字节。这在C语言和C++语言中都是如此,而在当前实践中,两种语言的成员布局方式是一致的。
此外,编译器可能会合成一个或多个额外的内部数据成员,以支持对象模型。例如,vptr就是这样一个合成的数据成员,当前所有实现都会将其插入到包含一个或多个虚函数的类的每个对象中。那么,vptr应该放置在类对象中的什么位置呢?传统上,它被放在类所有显式声明的成员之后。而近期,则被放在类对象的起始位置。标准通过对布局要求的这种表述方式,赋予了编译器将内部生成的这些成员插入到任意位置的自由,甚至可以插入在程序员显式声明的成员之间。
标准还允许编译器在多个访问段内,以任何认为合适的方式自由排列数据成员的顺序。也就是说,对于以下的类声明:
所得到的类对象的大小和组成与之前的声明相同,但成员的顺序现在由实现决定。实现可以自由选择将y放在最前面,或者将z放在最前面,等等。不过,据我所知,目前还没有任何编译系统这样做。
在实践中,多个访问段会按照声明的顺序拼接成一个连续的块。访问段说明符本身以及访问级别的数量并不会带来任何额外的开销。例如,在一个访问段中声明八个成员,与在八个独立的访问段中分别声明这些成员,在实践中生成的类对象大小是完全相同的。例如一个类中有连续的六个访问段,其生成的对象布局很可能为:
以下模板函数在给定同一个类的两个数据成员的情况下,可以判断出它们在类的布局中哪个排在前面。如果这两个成员是分别在两个不同访问段中声明的第一个成员,则该函数能够识别出哪个访问段在前(如果你对指向类成员的指针不熟悉,请参阅第3.6节)。
以上函数可这样被调用:
此时class_type会绑定为Point3d,data_type1和data_type2会绑定为float。
3.3 Data Member的存取
已知以下代码:
你可能会问,访问 x 数据成员的开销是多少?答案取决于 x 和 Point3d 类的声明方式。x 可以是静态成员,也可以是非静态成员。Point3d 可以是一个独立的类,也可以是派生自单个基类的类。可能性较小但仍有可能的是,它可以是通过多重继承或虚继承派生的类。接下来的几节将依次探讨这些可能性,其中包括对虚基类的首次详细分析。
在开始之前,我先提一个问题。假如有 origin 和 pt 这两个定义:
那么对于坐标数据成员的访问,例如:
通过对象 origin 和通过指针 pt 进行访问时,开销是否存在显著差异?如果你的答案是肯定的,请描述 Point3d 类和 x 数据成员具有哪些特性会导致这种差异。我将在本节末尾回顾这个问题并给出解答。
Static Data Members
静态数据成员实际上是被从类中提升出来了,正如我们在第1.1节中所见,它们被视为像全局变量一样声明(但其可见性被限制在类的作用域内)。每个成员的访问权限、与类的关联关系得以保留,且不会给各个类对象或静态数据成员本身带来任何空间或运行时的开销。
每个类的静态数据成员只有一个实例,存储在程序的数据段中。对静态成员的每次引用,在内部都会被转换为对该唯一外部实例的直接引用。例如:
在C++语言中,这是唯一一种通过指针访问成员与通过对象访问成员在所生成的指令上完全等价的情况。这是因为,通过成员选择运算符(.或->)来访问静态数据成员,仅仅是一种语法上的便利——静态成员并不位于类对象之中,因此访问它时根本不需要借助任何类对象。
那么,如果 chunkSize 是某个复杂继承体系中的继承成员呢?比如它可能是某个虚基类的虚基类中的成员,或是其他同样复杂的继承关系中的成员——这会有影响吗?完全没有。无论继承层级多么复杂,程序中依然只有该成员的唯一一个实例,对其的访问依然是直接寻址的。
假如对静态数据成员的访问是通过函数调用或其他形式的表达式进行的,情况又会如何呢?例如,当我们写下这样的代码:
这里对 foobar() 的调用会如何处理呢?在标准制定之前的语言规范中,这算是个悬而未决的问题:《ARM》(Annotated C++ Reference Manual,带注释的C++参考手册)并未明确规定 foobar() 是否必须被求值。例如,在 cfront 编译器中,这个函数调用就被直接丢弃了。而标准 C++ 则明确规定 foobar() 必须被求值——尽管对其返回值并未实际使用。一种可能的内部转换形式如下所示:
接下来,如果对静态数据成员取地址,会得到什么结果呢?
由于静态成员并不存放在任何类对象之中,取地址操作所得到的并非“指向类成员的指针”,而是一个指向该静态成员数据类型的普通指针。例如,上述表达式会生成一个类型为 const int* 的实际内存地址,这个地址指向程序数据段中 chunkSize 的唯一存储位置。
如果两个类各自声明了一个静态成员 freeList,那么将它们都放入程序的数据段中就会导致命名冲突。编译器通过内部编码每个静态数据成员的名称来解决这一问题——这一过程被形象地称为名称改编(name mangling)——以生成一个唯一的程序标识符。
不同的实现似乎有着各自不同的名称改编方案,每种方案都或多或少地借助表格、语法规则等进行了严谨细致的规定。任何名称改编方案都需满足两个重要要求:
1.算法必须能够生成唯一的名称;
2.当编译系统(或环境工具)需要与用户交互时,这些唯一名称必须能够轻松地重新转换回原始名称。
Nonstatic Data Members
非静态数据成员直接存储在每个类对象之中,并且只能通过显式或隐式的类对象来访问。当程序员在成员函数内部直接访问非静态数据成员时,会产生一个隐式的类对象。例如,在以下代码中:
在看似直接访问 x、y 和 z 的过程中,实际上是通过 this 指针所代表的隐式类对象来完成的。在内部,该函数被改写为如下形式:
对非静态数据成员的访问,需要将类对象的起始地址与该数据成员的偏移位置相加。例如,给定以下代码:
表达式 &origin._y 的地址,等价于以下地址运算的结果:
请注意这里对指向数据成员的指针偏移量所进行的看似奇特的“减一”操作。通过指向数据成员的语法获得的偏移值,总是会被加一处理。这样做的目的是让编译系统能够区分两种情况:一种是指向数据成员的指针确实指向了类中的第一个成员,另一种则是该指针不指向任何成员。关于指向数据成员的指针,将在第3.6节中进行更详细的讨论。
每个非静态数据成员的偏移量在编译时就已经确定,即便该成员属于通过单继承或多继承链获得的基类子对象也是如此。因此,访问非静态数据成员的性能与访问C语言结构体成员或非派生类的成员是等价的。
虚继承则不同——当通过基类子对象来访问其成员时,会引入额外的一层间接性。以如下代码为例:
在性能上,如果 _x 是结构体、普通类、单继承体系或多继承体系中的成员,上述访问的执行效率是等价的;但如果它是虚基类中的成员,则执行速度会稍慢一些。接下来的几节中,我将探讨继承对成员布局的影响。不过在此之前,让我们回顾一下本节开头提出的那个问题:
通过对象 origin 和通过指针 pt 进行访问时,开销是否存在显著差异?答案是:当 Point3d 类在其继承体系中包含一个虚基类,并且被访问的成员(例如 x)是从该虚基类继承而来的成员时,这两种访问方式确实存在显著差异。
在这种情况下,我们无法确定 pt 实际指向的是哪种具体的类类型(因此也就无法在编译时得知该成员的实际偏移量),所以对该成员的访问必须通过额外的间接层推迟到运行时才能解析。而对象 origin 则不同——它的类型就是确定的 Point3d 类,即使是继承自虚基类的成员,其偏移量也能够在编译时固定下来。因此,一个优化能力较强的编译器完全可以在编译期静态地完成对 origin.x 的访问解析。
3.4 继承与Data Member
在C++的继承模型下,派生类对象在内存中被表示为其自身成员与其基类成员的拼接体。但关于基类部分与派生类部分的实际排列顺序,标准并未作出明确规定。从理论上讲,编译器可以自由决定在派生类对象中是将基类部分放在前面,还是将派生类部分放在前面。然而在实践中,基类成员总是出现在派生类成员之前——虚基类的情况除外。
基于这样的继承模型,我们可以提出一个问题:在表示二维点和三维点时,如果分别采用两种独立的抽象数据类型,和使用继承之间会存在怎样的差异呢?例如:

以及采用两层或三层继承层次,其中每个更高维度都作为更低维度的派生类,这两种方式之间有何差异?在接下来的小节中,我们将依次探讨:不含虚函数的单继承、包含虚函数的单继承、多重继承以及虚继承所带来的影响。
图3.1(a)展示了 Point2d 和 Point3d 对象的内存布局(在没有虚函数的情况下,它们与C语言的结构体声明是等价的)。
只要继承不要多态(Inheritance without Polymorphism)
假设程序员希望共享同一套实现,同时又能够继续使用类型特定的二维点或三维点实例。一种设计策略是让 Point3d 从 Point2d 类派生而来,这样 Point3d 将继承 x 坐标和 y 坐标的所有操作与维护。通常情况下,具体继承(concrete inheritance,与虚拟继承相对)不会给内存布局或访问时间带来任何额外开销。
这种设计策略的优势在于,管理 x 坐标和 y 坐标的代码得以局部化。此外,该设计清晰地表明了这两个抽象之间的紧密耦合。Point2d 和 Point3d 类的声明与使用方式,与它们作为两个独立类时相比并无变化——因此,使用这些抽象的程序员无需关心这些对象究竟是相互独立的类类型,还是通过继承关联起来的。图3.1(b)展示了 Point2d 和 Point3d 在未声明虚接口(虚函数)的情况下,采用继承方式时的内存布局。
将两个独立的类通过继承转换为类型/子类型关系,可能会带来哪些潜在陷阱?一个不够周全的设计,实际上可能导致执行相同操作时的函数调用次数翻倍。例如,在我们示例中的构造函数或 operator+=() 如果没有被设为内联(或者编译器出于某些原因无法支持成员函数的内联),那么对 Point3d 对象进行初始化或加法运算时,就需要承担 Point2d 部分和 Point3d 实例两部分的开销。一般而言,选择合适的候选函数进行内联是类设计中一项重要(尽管看起来不那么光鲜)的工作。在最终发布实现之前,确认这些函数确实被内联展开是很有必要的。
将类分解为两层或更深层次的继承体系时,可能遇到的第二个陷阱是:用于表示该抽象的空间可能会因为采用类层次结构而膨胀。这个问题涉及语言对派生类中基类子对象完整性的保证,稍微有些微妙。我们不妨通过一个具体的例子来逐步说明。让我们从一个具体的类开始:
在32位机器上,每个 Concrete 类对象的大小为 8 字节,具体构成如下:
1.val 占用 4 字节;
2.c1、c2、c3 各占用 1 字节;
3.剩余 1 字节用于对齐,以保证类对象整体落在字边界上。
假设经过一番分析后,我们决定采用一种更符合逻辑的表示方式:将 Concrete 拆分为如下的三层继承体系:
从设计角度来看,这种表示方式可能更加合理。然而,从实现的角度来看,我们可能会沮丧地发现,Concrete3 类对象的大小现在变成了 16 字节——是原来大小的两倍。
这是怎么回事呢?问题的关键在于派生类中基类子对象的完整性。让我们逐步分析这个继承体系的内存布局,看看究竟发生了什么。
Concrete1 类包含两个成员——val 和 bit1——它们合计占用 5 个字节。然而,一个 Concrete1 类对象的大小却是 8 字节:即 5 字节的实际数据加上 3 字节的填充,目的是让整个对象对齐到机器字的边界。这一点在 C 语言和 C++ 中是相同的;通常,对齐约束由底层处理器决定。
到目前为止,似乎还没有什么值得抱怨的地方。但真正让那些不够谨慎的程序员感到困惑甚至愤慨的,通常是派生类的内存布局。Concrete2 添加了一个额外的非静态数据成员 bit2,类型为 char。天真的程序员期望它能“塞进”基类 Concrete1 的表示中,即利用原本被浪费的填充字节之一。按照这种思路,Concrete2 类对象的大小也将是 8 字节,其中包含 2 字节的填充。
然而,Concrete2 类的实际布局却保留了基类 Concrete1 子对象中的 3 字节填充。bit2 成员被放置在这 3 字节填充之后,随后又附加了额外的 3 字节填充。这样一来,Concrete2 类对象的大小变成了 12 字节(而非 8 字节),其中多达 6 字节被浪费在了填充上。同样的布局算法导致 Concrete3 类对象的大小达到了 16 字节,其中 9 字节被浪费于填充。
“这太蠢了。”——这是那些不够谨慎的程序员给出的判断,而且不止一个人通过电子邮件、电话或当面跟我这么说过。你能理解语言为什么会这样设计吗?
让我们声明下面这组指针:
pc1_1 和 pc2_2 都可以指向这三个类中的任意一个对象。下面的赋值操作:
应当对所指向对象的 Concrete1 部分执行默认的逐成员复制。如果 pc1_1 实际指向的是一个 Concrete2 或 Concrete3 对象,这对赋值操作中 Concrete1 子对象的部分不应该产生任何影响。
然而,如果语言允许将派生类的成员(如 Concrete2::bit2 或 Concrete3::bit3)“塞进”基类 Concrete1 的子对象中,那么上述语义就无法得到保证。像下面这样的赋值操作:
就会意外地覆盖掉那些被塞进去的继承成员,导致 bit2 的值变得不可预测。毫不夸张地说,用户若要调试这样的问题,将会付出巨大的代价。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)