《Inside the C++ Object Model》读后记 —— 为什么 C++ 和 C 可以互操作
正如标题所说,这篇文章是读后记,但我不想成为作者的传声筒,也不想将这篇文章变成只有自己能看懂的阅读笔记。所谓一千个读者就有一千个哈姆雷特,我想将读完这本书后的感想、思考、观点讲述出来,我想这也是我能写出的有价值的文字
这本书是什么
《Inside the C++ Object Model》 的作者 Stanley B. Lippman,同时也是《C++ primer》、《C++ primer plus》的作者,曾在贝尔实验室作为领导者参与 cfront 项目,cfront 是“远古”时期的 C++编译器,它做的工作就是将 C++转变为 C,剩下的编译链接都交给 C 编译器完成
这本书的大致内容
- The Semantics of Constructors
编译器如何合成构造函数、逐步完成对象的构造过程 - The Semantics of Data
编译器是如何决定对象的内存布局以保证面向对象语义和运行时性能,又是如何根据内存布局改写数据成员的访问 - The Semantics of Function 编译器如何将成员方法调用转化为更加底层的普通函数调用,从而能够在现有编译体系下实现
- Semantics of Construction, Destruction, and Copy
编译器如何合成构造函数、析构函数,以保证面向对象语义 - Runtime Semantics
必须由运行时系统提供的面向对象的运行时支持,追踪临时对象生命期、正确初始化全局变量、局部静态变量、局部变量和数组
这本书发版于 1996 年,在 C++20 已经进入生产环境的今天,书中提及的一些概念已经被新标准废弃,但仍有很多内容对理解标准帮助很大。C++面向对象特性是如何影响对象的布局的,对象的布局又是怎样影响构造函数和析构函数,怎样影响 C++的运行时系统,又是怎样影响 C++和 C 互操作。在我看来其中最重要的就是从 C++的领域中划分出了一片区域,这片区域可以安全的和 C 互操作,所以接下来这篇文章将以一个实用、朴实的角度说明 C++和 C 为什么可以互操作(实用、朴实、不严谨、不太符合C++20标准的定义)
C++对象的内存布局
普通对象的内存布局
普通对象也是我胡编乱造的词,指类中不存在虚拟函数、继承。此时 C++对象包括所有非静态数据成员
class A{
private: // private access section
int x;
int y;
public: // public access section
char z;
};
- 标准规定:同一个 access section 内,声明顺序在后的数据成员具有更大的地址,即在
A
的对象中,y
的偏移比x
更大。标准如是规定的意图是允许编译器根据需要在数据成员的间隙插入额外的数据(见下文) - 标准规定:存在多个不同级别的 access section 时,不同 section 的相对位置由编译器自己决定
根据以上两个信息,可以得出:普通对象只有一个 access section 时,编译器不需要在数据成员的间隙中插入额外数据,也不需要决定多个 section 的相对位置,此时对象的内存布局和 C 的结构体是兼容的
+-----------+
| |
| int x |
| |
+-----------+
| |
| int y |
| |
+-----------+
| char z |
+-----------+
| |
| padding |
| |
+-----------+
确定了 A
的内存布局后,A
的非静态数据成员的偏移也就可以在编译期确定,在 A
的非静态成员函数中,访问x
y
z
时,通过隐含的this
指针间接访问,等价于 C 的结构体成员的访问
A::foo(){
return x + y; // this->x + this->y
}
末尾加的 padding 是为了对齐,但这句话太过教科书了。A
的对齐要求是首地址必须是 4 的倍数,在末尾加上 3 字节的 padding 是为了保证在一块内存中连续存放 A 的对象时,下一个对象能够紧邻上一个对象并保证对齐要求。而且在逐字节复制时,在 CISC 架构上可以使用效率更高的专用指令
单继承时
class Base{
int x;
char y;
};
class Derived: Base{
char z;
};
Derived
对象分为两个部分,一个是完整的 Base
对象,另一个时 Derived
的所有非静态成员。也可以说 Derived
对象中有 Base subobject
这两个部分如何排放,标准没有要求。但编译器往往把 Base
放在开头而 Derived
放到末尾,这样就能实现Base* b = new Derived
派生类向上 cast 到基类时,cast 没有运行时开销
Base Derived ??
+-----------+ +-----------+ +-----------+
| | | | | |
| int x | | int x | | int x |
| | | | | |
+-----------+ +-----------+ +-----------+
| char y | | char y | | char y |
+-----------+ +-----------+ +-----------+
| | | | | char z |
| padding | | padding | +-----------+
| | | | | padding |
+-----------+ +-----------+ +-----------+
| char z |
+-----------+
| |
| padding |
| |
+-----------+
派生类的大小为 12 字节,其中有 6 个字节的 padding,为什么不能像??
中表示的那样char z
利用Base
末尾的 padding 呢?
这里提出一个标准中没有,但书中有的词:Bitwise Semantics. 与 Bitwise 相对的还有 member-wise。一个好的编译器应当在大部分情况下都能生成 Bitwise 的复制构造函数,即当复制对象时,不需要 member-wise 复制而是 Bitwise 的复制。当遵守 Bitwise Semantics 时,编译器能够省去函数调用、逐个复制数据的开销,转而使用高效的专用指令
Base *b = new Base;
Base *d = new Derived;
*d = *b;
这段代码显然是良定义的,他产生的效果是 Base 对象复制到 Derived 对象的 Base subobject 中,而Derived::z
不会被覆盖。为了能够遵守 Bitwise Semantics,编译器仍然保留了 Base subobject 中的 padding,否则Derived::z
被 padding 覆盖,其值是未定义的
使用虚拟函数时
所谓多态,可以理解为派生类对象能够完美的嵌入到基类对象的地方。USB3.0 公口能够完美的插入到 USB2.0 的母口中并正常传输数据,这是因为 USB3.0 接口在电气特性和物理特性上兼容 USB2.0 接口,例如任何 USB2.0 定义的针脚也是 USB3.0 定义的针脚;USB2.0 接口具有和 USB3.0 相同的形状,这提示我们:派生类对象和基类对象在内存布局上具有相似性
但是在不使用虚拟机制时,派生类对象和基类对象在内存布局上也具有相似性。于是这里引入第二个条件:虚拟机制需要在对象中开辟一块空间存放运行时信息,用于完成多态的运行时绑定。结合两个信息可以得知:派生类和基类对象中都有这块空间,而且他们的非静态数据成员和这块空间形成的整体在内存布局上具有相似性
那么如何保存这个信息呢,以下使用渐进的思考方式解释为什么编译器都使用了相同的多态实现方法:虚函数表指针
以下方案只是我的猜想,不保证可行性
多态方案 1
第一个想法是编译器为每一个类确定一个唯一的 id,然后将 id 嵌入到对象中,运行时系统可以取出 id,根据 id 查询到对应的类,并通过这个类保存的元信息找到需要调用的虚拟函数
这个方案已经有 Runtime Type Identification(RTTI) 的雏形,通过从对象中取出 id,可以在运行时获取到对象的运行时类型,以支持基类指针尝试向下 cast,即dynamic_cast
考虑 id 嵌入的位置,标准允许嵌入到任何数据成员的间隙,但一般的做法是放到对象开头或结尾,因为这样可以维护最少的元数据
class Point{
int _x;
}
class Point2d: public Point{
public:
virtual int z(){ return 0; }
private:
int _y;
}
class Point3d: public Point2d{
public:
virtual int z(){ return _z; }
private:
int _z;
}
考虑这样的类,内存布局如下
object header
Point Point2d Point3d
+-----------+ +-----------+ +-----------+
| int x | | id | | id |
+-----------+ +-----------+ +-----------+
| int x | | int x |
+-----------+ +-----------+
| int y | | int y |
+-----------+ +-----------+
| int z |
+-----------+
object footer
Point Point2d Point3d
+-----------+ +-----------+ +-----------+
| int x | | int x | | int x |
+-----------+ +-----------+ +-----------+
| int y | | int y |
+-----------+ +-----------+
| id | | id |
+-----------+ +-----------+
| int z |
+-----------+
以上给出了将 id 分别放到对象开头或结尾的例子,我称之为 object header 方案和 object footer 方案
这两种方案孰优孰劣?
- 将 id 放到开头时,任何支持虚拟机制的对象都能在首地址找到 id,即 id 的偏移为零,不需要额外的偏移加法计算,性能最好。而且零是编译期常量,省去了维护元信息的开销。但继承了没有虚拟函数的类时,向上 cast 需要额外操作
- 将 id 放到结尾时,向上 cast 到没有虚拟函数的类时没有额外操作。运行时取出 id 也只需要 inheritance hierachy 中有虚拟函数的最基类的类型的大小,这也是编译期常量
这个方案有不少问题:为了支持不同编译器的编译产物链接到一起能够正常运行,需要保证不同编译器对同一个类产生的 id 是相同的,而且两个不同类的 id 不能相同。人们可以写下几乎无穷个类,但 id 的大小需要在编译期确定下来而且在不同编译器之间达成约定,即 id 的大小是固定的。根据 鸽笼原理,一定会有两不同的类,他们的 id 相同
更进一步,什么是同一个类?C++的类型系统是 Nominal type system,即使存在两个类名之外完全相同的类
class A{
int x;
}
class B{
int x;
}
这两个类也是不同的
那么什么类是相同的?根据 Nominal type system 的定义,只有显式声明两个类相同或两个类名称相同
In computer science, a type system is nominal (also called nominative or name-based) if compatibility and equivalence of data types is determined by explicit declarations and/or the name of the types
这句话就是废话,C++不存在声明两个类具有相同关系的语法,也不允许重复声明一个类。但 Nominal type system 的定义暗示我们,类的名称可以作为唯一标识类的方法,当然在 C++,还得加上一个条件,名称中必须包含或者能够反映它所在的名称空间。于是提出第二个方案
多态方案 2
将类的名称作为唯一标识类的方法,其他与多态方案 1 保持一致。
由于类的名称是变长而且不存在运行时修改手段,所以可以在编译期确定类的名称并保存在数据段中,对象中仅仅存放指针。不同编译器的编译产物链接到一起时,它们应当是 ABI 兼容的,所以无论类的名称有没有经过符号修饰,不同编译器都能在类的名称上取得一致
接下来考虑第二个问题:如何组织元信息管理系统,能够在运行时取得类的名称后,查询到具体调用的函数?
C++没有提供运行时给一个类增加、删除、修改虚拟函数的方法,所以一个类有哪些虚拟函数是编译期能够确定的,即编译期常量。所以完全可以把查询这个动作在编译期完成,所以可以直接把这个类所有的虚拟函数的指针嵌入到对象中
但是这个方法又引入了新的问题,派生类能够增加新的虚拟函数,导致虚拟函数的指针个数增加,给「保证派生类对象的内存布局和基类相似」增加了难度。这里给出一个巧妙的方法,将虚拟函数的指针和类的名称组织在一起(因为它们都在数据段中,这是可行的)
+------------+ +-------------+
| ptr |------------> | class name |
+------------+ +-------------+
| | | ptr to func |
| class | +-------------+
| data | | ptr to func |
| | +-------------+
+------------+
到了这步,恭喜你重新发明了虚函数表
编译器的方案
图中Point
也有虚拟方法,作者给出的是 cfront 实现,cfront 选择将运行时信息插入到对象尾部
这个方案当然也不是完美的,使用函数指针引入了一层间接,使用 vptr 又引入了一层间接,降低了性能
一个提高性能的方法是将函数指针直接嵌入到对象中,代价是增加了对象大小,而且派生类中增加的虚拟函数可能无法嵌入
使用多继承时/使用虚拟继承时
一个非常经典的多继承用例是标准库中的 iostream
iostream
既是 istream
也是 ostream
,即 iostream
既是 istream
的派生类也是 ostream
的派生类
我不想花太多篇幅在虚拟继承上,因为我认为这是一个“不值得”的特性,他的实现方式比较复杂,需要考虑的 corner case 比较多,但使用场景少,而且也有一些坑点
iostream
对象中既有istream
subobect 也有ostream
subobjectistream
subobect 和ostream
subobject 共享一个 ios 对象,这需要虚拟继承机制istream
对象和ostream
subobject 中增加类一个指针用于指向ios
基类对象,这样的设计允许他们共享一个ios
对象- 棱形继承的对象构造比较复杂,而且它的复制构造函数难以正确安全的实现,标准没有给出一个可靠的方案
- 尽量不使用棱形继承,不了解棱形继承就不要使用棱形继承
多继承、无虚拟继承的布局
棱形继承时的布局
构造、析构、复制、移动语义
可以非常粗略的认为,C++中的对象生命期开始于构造函数的调用,结束于析构函数的调用
在构造具有继承关系的对象时,编译器根据分散在基类构造函数、类内初始化代码、初始化列表、构造函数的代码合成一个最终的构造函数。这个构造函数按照以下次序完成对象的构造
- 按声明顺序调用基类构造函数(单继承、多继承时),如果用户没有显示调用,则执行默认初始化
- 将类内构造代码和初始化列表合并,按照声明顺序逐个完成所有非静态数据成员的初始化。如果用户没有提供某个数据成员的初始化代码,则执行默认初始化
- 将 vptr(如果有虚拟机制)设为当前类对应的虚拟函数表的地址
- 执行构造函数的函数体中用户提供的代码
如果有棱形继承,为了保证虚拟基类对象在合适的时机只初始化一次,编译器会在背后做更多工作
第三步保证了在初始化多层单继承关系的中间对象时,虚拟机制被禁用。以 Point3d
继承 Point2d
继承 Point 为例,初始化 Point3d
对象需要先初始化 Point
,然后初始化 Point2d
,最后初始化 Point3d
,即 Point3d
是沿着 inheritance hierachy 逐步完成初始化的,在这个过程中,当前正在初始化的对象依次是合法的 Point
对象、Point2d
对象、Point3d
对象
由于以上所说的初始化顺序,导致初始化 Point3d
的 Point2d
subobject 时,Point3d
还未完成初始化,形象的表述就是还未存在,自然不能使用虚拟机制调用 Point3d
的虚拟函数
与构造函数相反,析构函数会按照初始化的顺序逆序完成析构
当用户没有提供复制构造函数、复制操作符、移动构造函数、移动操作符(以下简称四件套函数)时,编译器会合成(如果可行)member-wise 的四件套函数,这些函数调用逐个成员的四件套函数和基类的四件套函数
一个好的编译器也应当尽可能的实现 Bitwise Semantics,此时编译器可以生成逐字节复制的四件套函数。在实践中此时的四件套函数不会被合成也不会被调用,而是直接内联
编译器实现 Bitwise Semantics 时应当保证它和 member-wise 的行为产生的结果相同,因此至少会增加以下条件
- 用户没有提供四件套函数,因为一般认为用户提供的代码可能有副作用
- 编译器没有“悄悄”在对象中插入一些额外的数据(包括虚拟函数表指针,虚拟基类对象指针),因为派生类和基类的「额外数据」一般不同,而四件套函数应当正确处理派生类和基类
由此可以得出有虚拟函数的类、inheritance hierachy 中存在虚拟继承的类不能实现四件套函数的 Bitwise Semantics
同样,构造函数和析构函数也有 Bitwise Semantics,如果实现了 Bitwise Semantics,不严谨的说,编译器可以执行和原始数据类型相同的默认初始化,原始数据类型的默认初始化就是不初始化,其值分为若干种
- 函数内部的自动变量,其值未定义
- 全局变量,其值为零
- 堆上的变量,其值未定义
同样的,构造函数和析构函数能够实现 Bitwise Semantics 也至少有一些条件
- 用户没有提供构造函数或析构函数,因为一般认为用户提供的代码可能有副作用
- 编译器没有“悄悄”在对象中插入一些额外的数据(包括虚拟函数表指针,虚拟基类对象指针)
由此可以得出有虚拟函数的类、inheritance hierachy 中存在虚拟继承的类不能实现构造函数和析构函的 Bitwise Semantics
如果一个类的四件套函数和构造函数、析构函数都能实现 Bitwise Semantics,此时它就具有和原始数据类型、或者说 C 的结构体相同的特性,能够随意复制到一块内存,又从一块内存中复制回来,这样的类能够随意传入 C 写的库被处理,C 库返回的结构体也能零运行时开销的 cast 到这个类的对象而符合 C++语义,这就是 C++和 C 能够安全互操作的区域
为什么说 C 和 C++兼容
作者并没有在书中回答这个问题,只是我在阅读过程中产生了这个问题的答案
我认为原因是 C 和 C++的编译系统、链接系统(这个词是我胡编乱造出来的)、运行时系统是兼容的,这里的兼容包括两个层面,第一个层面是 C++与 C 共用相同的底层原语,第二个层面是 C++和 C 的系统能够安全的共存,例如
- C 和 C++在 linux 平台编译后都会产生 ELF,都能使用静态链接器或动态链接器识别和处理,这也决定了 C 和 C++可以相互链接
- C++拥有 C 的原始数据类型,C++也能使用 C 的语法,这决定了在语言层面上 C 和 C++互操作几乎不需要胶水代码
- C++的内存分配系统虽然多了构造对象和销毁对象的功能,但内部也会使用 libc 的内存分配系统,而且两者的符号名不一样,所以 C++的内存分配系统和 C 的内存分配系统能够共存
- C++的对象如果不使用虚拟函数、继承等 C++特性,可以在符合 C++面向对象语义要求的同时具有和 C 的结构体相同的内存布局,这决定了数据结构能够跨运行时传递,或者说无缝互操作
异常处理和对象生命期
异常可能在任何地方抛出、终止当前流程并执行栈倒带,但异常处理也应当遵守 C++对象生命期的语义,所以在栈倒带之前,需要销毁当前函数执行迄今构造的所有的栈上的对象,这个是编译器插入额外代码追踪对象构造情况、根据对象构造情况条件地销毁对象实现的。当然这些额外插入的代码会降低运行时性能、增大二进制体积,我想这也是 google 说“we don’t use exceptions”的原因之一
正确看待某些特性
前文我提到了棱形继承的缺点,但我更想强调的是要合理的看待缺点
在批评棱形继承前,应该先清楚,「没有调查就没有发言权」,作为一个技术,它想解决什么问题,实际解决了什么问题,又带来了哪些新问题。于是在使用棱形继承时,我们就能够利用它解决问题并避免它带来的问题。
还要知道这一点:任何技术只要进入了生产环境并创造了价值,那就说明这个技术是有可取之处的
总结
《Inside the C++ Object Model》我个人评价非常高,可以说是 C++必读书籍。读完后再来看 C++20,就会发现虽然二十多年过去了,标准大变样,但其背后的原理、核心没有改变。这本书的精华也就在其讲述的原理、核心,而它提及的一些标准只能作为“过去的标准”的参考
看完这本书就精通 C++了?当然不可能,还差的多,不过至少算是前进了一步吧。
append
最近看了很多人的文章和书籍,逐渐认识到了读许多书、提升内在才能写出有价值的文章。未来我也会写更多本文这样偏读后记的文章,下篇大概率是《最好的告别》的读后记,敬请期待!
最后,附上最近反复听的一首音乐
独りきりで泣いた夜に出会った
この世で一番孤独なこえ
独りきりで泣いたと思っていた
ふたりきりだった
同じ温度で唄えたなら