《深度探索C++对象模型》笔记
Inside the C++ Object Model
Stanley B. Lippman 著
候捷 译
摘录整理。
前言
这本书是由一位编译器设计者针对中高级C++程序员所写的。
有两个概念可以解释C++对象模型:
语言中直接支持面向对象程序设计的部分。
对于各种支持的底层实现机制。
本书主要专注于第二个概念。
目前所有编译器对于virtual function的实现法都是使用各个class专属的virtual table,大小固定,并且在程序执行前就构造好了。
第0章 导读
对象模型是深层结构的知识,关系到“与语言无关、与平台无关、跨网络可执行”软件组件(software component)的基础原理。也因此,了解 C++对象模型,是学习目前软件组件三大规格(COM、CORBA、SOM)的技术基础。
如果你对COM有兴趣,我也要同时推荐你看另一本书:Essential COM,Don Box著,Addison Wesley公司1998年出版(《COM本质论》,侯捷译,碁峰1998)。
第1章 关于对象
1.1 C++对象模式
在C++中,有两种class data members:static和nonstatic,以及三种class member functions:static、nonstatic和virtual。
指向虚表的指针称为vptr,设定(setting)和重置(resetting)都由每一个 class的 constructor、destructor和 copy assignment运算符自动完成。
每一个 class所关联的 type_info object(用以支持 runtime type identification,RTTI)也经由 virtual table被指出来,通常放在表格的第一个 slot。
1.2 关键词所带来的差异
如果不是为了努力维护与C之间的兼容性,C++远可以比现在更简单些。
C struct在C++中的一个合理用途,是当你要传递“一个复杂的class object的全部或部分”到某个C函数去时,struct声明可以将数据封装起来,并保证拥有与C兼容的空间布局。然而这项保证只在组合(composition)的情况下才存在。如果是“继承”而不是“组合”,编译器会决定是否应该有额外的data me mbers被安插到base struct subobject之中(再一次请你参考3.4节的讨论以及图3.2a和图3.2b)。
1.3 对象的差异
C++程序设计模型直接支持三种programming paradigms(程序设计范式):
- 程序模型(procedural model)。就像 C一样,C++当然也支持它。
- 抽象数据类型模型(abstract data type model,ADT)。
- 面向对象模型(object-oriented model)。
在OO paradigm之中,程序员需要处理一个未知实例,它的类型虽然有所界定,却有无穷可能。相反地,在ADT paradigm中,程序员处理的是一个拥有固定而单一类型的实例,它在编译时期就已经完全定义好了。
C++以下列方法支持多态:
- 经由一组隐式的转化操作。例如把一个 derived class 指针转化为一个指向其 public base type的指针。
- 经由 virtual function机制。
- 经由 dynamic_cast和 typeid运算符。
一个class object的内存包括:
其 nonstatic data members的总和大小。
加上任何由于 alignment的需求而填补(padding)上去的空间(可能存在于 members之间,也可能存在于集合体边界)。
alignment就是将数值调整到某数的倍数。在32位计算机上,通常alignment为4 bytes(32位),以使bus的“运输量”达到最高效率。
加上为了支持 virtual而由内部产生的任何额外负担(overhead)。
“指针类型”会教导编译器如何解释某个特定地址中的内存内容及其大小。
一个pointer或一个reference之所以支持多态,是因为它们并不引发内存中任何“与类型有关的内存委托操作(type-dependent commitment)”;会受到改变的,只有它们所指向的内存的“大小和内容解释方式”而已。
C++通过class的pointers和references来支持多态,这种程序设计风格就称为“面向对象”。
C++也支持具体的ADT程序风格,如今被称为object-based(OB)。例如String class,一种非多态的数据类型。
之前一直不解《C++ Primer(第5版)》中推荐序2里“基于对象”和“面向对象”风格的区别,这里算是明白了。
第2章 构造函数语意学
2.1 Default Constructor的构造操作
被合成的default constructor只满足编译器的需要,而不是程序的需要。因此除了 base class subobjects 和 member clas s objects之外的nonstatic data member(如整数、整数指针、整数数组等等)都不会被初始化。
编译器合成nontrivial default constructor的4种情况:
“带有 Default Constructor”的 Member Class Object
“带有 Default Constructor”的 Base Class
调用顺序优先于1
“带有一个 Virtual Function”的 Class
两种情况:class声明(或继承)一个 virtual function。class派生自一个继承串链,其中有一个或更多的 virtual base classes。
产生vtbl和vptr
“带有一个 Virtual Base Class”的 Class
对于 class 所定义的每一个 constructor,编译器会安插那些“允许每一个virtual base class的执行期存取操作”的代码。如果class没有声明任何constructors,编译器必须为它合成一个default constructor。
2.2 Copy Constructor的构造操作
拷贝构造函数可以是多参数形式,其第二参数及后继参数以一个默认值供应之。
一个class不展现出“bitwise copy semantics”时,编译器合成nontrivial copy constructor的4种情况:
- 当class内含一个member object 而后者的class声明有一个copy constructor时。
- 当 class继承自一个 base class而后者存在一个 copy constructor时(再次强调,不论是被显式声明或是被合成而得)。
- 当 class声明了一个或多个 virtual functions时。
- 当 class派生自一个继承串链,其中有一个或多个 virtual base classes时。
2.3 程序转化语意学
Named Return Value(NRV)优化以result参数取代named return value。NRV优化由编译器完成,如下伪码所示。如果用了NRV优化,那就不必调用拷贝构造函数。
1 |
|
一般而言,面对“以一个class object作为另一个class object的初值”的情形,语言允许编译器有大量的自由发挥空间。其利益当然是导致机器码产生时有明显的效率提升。缺点则是你不能够安全地规划你的copy constructor 的副作用,必须视其执行而定。
如果class声明一个或一个以上的virtual functions,或内含一个virtual
base
class,不能在构造函数中使用memcpy()
或memset()
,否则会导致那些“被编译器产生的内部members”的初值被改写(如vptr)。
2.4 成员们的初始化队伍
在下列情况下,为了让你的程序能够被顺利编译,你必须使用member initialization list:
- 当初始化一个 reference member时;
- 当初始化一个 const member时;
- 当调用一个 base class的 constructor,而它拥有一组参数时;
- 当调用一个 member class的 constructor,而它拥有一组参数时。
编译器会一一操作initialization list,以适当顺序在constructor之内安插初始化操作,并且在任何explicit user code之前。(list中的项目顺序是由class中的members声明顺序决定的,不是由initialization list中的排列顺序决定的。)
第3章 Data语意学
C++Standard并不强制规定如“base class subobjects的排列顺序”或“不同存取层级的data members的排列顺序”这种琐碎细节。它也不规定virtual functions或virtual base classes的实现细节。C++Standard只说:那些细节由各家厂商自定。
class object的大小可能比想象的大,因为:
- 由编译器自动加上的额外 data me mbers,用以支持某些语言特性(主要是各种 virtual特性)。
- 因为 alignment(边界调整)的需要。
3.1 Data Member的绑定
无。讲了一点原来C++编译器在数据绑定上的缺陷。
3.2 Data Member的布局
C++Standard要求,在同一个access section(也就是private、public、protected等区段)中,members的排列只需符合“较晚出现的members在class object 中有较高的地址”这一条件即可。
3.3 Data Member的存取
static member并不内含在一个class object之中,对其存取效率不受对象影响。
欲对一个nonstatic data member进行存取操作,编译器需要把class object的起始地址加上data member的偏移位置(offset)。
当类是派生类并且继承结构中有虚基类,存取从虚基类继承而来的member时,用指针要慢于用对象本身。
3.4 “继承”与Data Member
只要继承不要多态
在没有虚函数的继承中,继承关系可能会导致空间膨胀,因为每个类有自己的padding。
加上多态
引入空间和存取时间上的额外负担,vtbl和vptr。
目前在 C++编译器那个领域里有一个主要的讨论题目:把 vptr 放置在 class object的哪里会最好?
多重继承
多重继承体系中子类的数据布局并没有被C++ Standard要求。
虚拟继承
虚拟继承体系中,子类只有共有父类的一个对象。
Class 如果内含一个或多个 virtual base class subobjects,像istream那样,将被分割为两部分:一个不变区域和一个共享区域。不变区域中的数据,不管后继如何衍化,总是拥有固定的offset(从object的开头算起),所以这一部分数据可以被直接存取。至于共享区域,所表现的就是virtual base class subobject。这一部分的数据,其位置会因为每次的派生操作而有变化,所以它们只可以被间接存取。各家编译器实现技术之间的差异就在于间接存取的方法不同。
3.5 对象成员的效率
用实验测试效率。
3.6 指向Data Members的指针
对应C++Primer 19.4 类数据成员指针的底层解释。
第4章 Function语意学
4.1 Member的各种调用方式
非静态成员函数
C++的设计准则之一就是:nonstatic member function 至少必须和一般的nonmember function有相同的效率。前者也是通过隐藏的this指针访问对象成员,所以实际效率应该是一样的。
C++编译器对name mangling的做法目前还没有统一,但我们知道它迟早会统一。
虚拟成员函数
1 |
|
静态成员函数
静态成员函数没有this指针,因此其:
- 它不能够直接存取其 class中的 nonstatic members。
- 它不能够被声明为 const、volatile或 virtual。
- 它不需要(但可以)经由 class object 才被调用。
4.2 Virtual Member Functions
作者探讨了虚函数调用的实现细节。
在C++中,多态(polymorphism)表示“以一个public base class 的指针(或reference),寻址出一个derived class object”的意思。
thunk只有以assembly代码完成才有效率可言。由于cfront使用C作为其程序代码产生语言,所以无法提供一个有效率的thunk编译器。
我的建议是,不要在一个 virtual base class 中声明 nonstatic data members。如果这么做,你会距离复杂的深渊愈来愈近,终不可拔。
4.3 函数的效能
测试了不同情况下函数调用的效率。
4.4 指向Member Function的指针Pointer-to-Member Functions
对应C++Primer 19.4 类函数成员指针的底层解释。
4.5 Inline Functions
处理一个inline函数有两个阶段:
- 分析函数定义,以决定函数的“intrinsic inline ability”(本质的 inline能力)。“intrinsic”(本质的、固有的)一词在这里意指“与编译器相关”。
- 真正的 inline函数扩展操作是在调用的那一点上。这会带来参数的求值操作(evaluation)以及临时性对象的管理。
第5章 构造、析构、拷贝语意学
5.1 “无继承”情况下的对象构造
1 |
|
观念上,编译器会为Point声明一个 trivial default constructor、一个trivial destructor、一个 trivial copy constructor,以及一个trivial copy assignment operator。但实际上,编译器会分析这个声明,并为它贴上Plain Ol'Data标签。
5.2 继承体系下的对象构造
Constructors的调用顺序是:由根源而末端(bottom up)、由内而外(inside out)。
在构造函数中调用虚函数无法正确实现多态。
5.3 对象复制语意学
一个class对于默认的copy assignment operator,在以下情况,不会表现出bitwise copy语意:
- 当class内含一个member object,而其class有一个copy assignment operator时。
- 当一个class的 base class有一个 copy assignment operator时。
- 当一个class声明了任何 virtual functions (我们一定不要拷贝右端 class object的 vptr地址,因为它可能是一个 derived class object)时。
- 当 class 继承自一个 virtual base class (不论此 base clas s 有没有 copy operator)时。
(和2.2节所述相同)
copy assignment operator在虚拟继承情况下行为不佳,需要小心地设计和说明。许多编译器甚至并不尝试取得正确的语意,它们在每一个中间(调停用)的copy assignment operator中调用每一个base class instance,于是造成virtual base class copy assignment operator 的多个实例被调用。
我建议尽可能不要允许一个virtual base class 的拷贝操作。我甚至提供一个比较奇怪的建议:不要在任何virtual base class中声明数据。
5.4 对象的效能
测试了不同情况下对象拷贝的效率。
5.5 析构语意学
如果class没有定义destructor,那么只有在class内含的member object (抑或class自己的base class)拥有destructor的情况下,编译器才会自动合成出一个来。否则,destructor被视为不需要,也就不需被合成(当然更不需要被调用)。
第6章 执行期语意学
6.1 对象的构造和析构
全局对象
C++程序中所有的global objects都被放置在程序的data segment中。如果显式指定给它一个值,此object将以该值为初值。否则object所配置到的内存内容为0(C并不自动设定初值)。
我建议你根本就不要用那些需要静态初始化的global objects(虽然这项建议几乎普遍地不为C程序员所接受)。
局部静态对象
局部静态对象被要求在所在函数调用时才被构造。
对象数组
6.2 new和delete运算符
new运算符实际上总是以标准的C malloc()完成,虽然并没有规定一定得这么做不可。相同情况,delete运算符也总是以标准的C free()完成。
有一个预先定义好的重载的(overloaded)new运算符,称为placement operator new。它需要第二个参数,类型为void*。(即C++ Primer 12.1.2提到的定位new)
1 |
|
6.3 临时性对象
copy constructor、destructor以及copy assignment operator都可以由使用者供应,所以不能够保证上述两个操作会导致相同的语意。因此以一连串的destruction 和copy construction 来取代assignment一般而言是不安全的,而且会产生临时对象。
1 |
|
malloc(0)
在不同C标准库实现上表现不同,有的会得到分配了MINSIZE
内存的指针,有的会返回空指针:当你 malloc(0)
时会发生什么
作者最后探讨了临时性对象的生命周期。
本章最后的注解里提到了定位delete(与定位new对应)
第7章 站在对象模型的尖端
❓书中用方括号括起来的参考文献在哪里找?例如P279页“我的讨论,是以[CHASE94]、[LAJOIE94a]、[LAJOIE94b]、[LENKOV92]以及[SUN94a]为基础的。”
7.1 Template
有关template的三个主要讨论方向:
1.template的声明。基本来说就是当你声明一个template class、template class member function等等时,会发生什么事情。
2.如何“实例化(instantiates)”class object、inline nonmember以及member template functions。这些是“每一个编译单位都会拥有一份实例”的东西。
3.如何“实例化(instantiates)”nonmember、member template functions以及 static template class members。这些都是“每一个可执行文件中只需要一份实例”的东西。这也就是一般而言 template所带来的问题。
目前的编译器,面对一个template声明,在它被一组实际参数实例化之前,只能施行以有限的错误检查。template中那些与语法无关的错误,程序员可能认为十分明显,编译器却让它通过了,只有在特定实例被定义之后,才会发出抱怨。这是目前实现技术上的一个大问题。
Template中的名称决议法(Resolution)
两个划分:scope of the template definition / scope of the template instantiation
Template之中,对于一个nonmember name 的决议结果,是根据这个name的使用是否与“用以实例化该template的参数类型”有关而决定的。
- 如果其使用互不相关,那么就以“scope of the template declaration”来决定name。
- 如果其使用互有关联,那么就以“scope of the template instantiation”来决定name。
1 |
|
Member Function的实例化行为
作者探讨了成员函数的实例化行为,如何保证只产生一个vtbl实例。
7.2 异常处理
当一个exception 被抛出去时,控制权会从函数调用中被释放出来,并寻找一个吻合的catch子句。如果都没有吻合者,那么默认的处理例程terminate()会被调用。当控制权被放弃后,堆栈中的每一个函数调用也就被推离(popped up)。这个程序称为unwinding the stack。在每一个函数被推离堆栈之前,函数的local class objects的destructor会被调用。
与其他语言特性进行比较,C++编译器支持EH机制所付出的代价最大。某种程度上是由于其执行期的天性以及对底层硬件的依赖,以及UNIX和PC两种平台对于执行速度和程序大小有着不同的取舍优先状态之故。
7.3 执行期类型识别
(Runtime Type Identification,RTTI)
❓开头没有看懂,String类引入operator char*() const重载operator char*() ,为什么会导致示例类向下转换错误?
dynamic_cast运算符可以在执行期决定真正的类型。
对于指针,如果downcast是安全的(也就是说,如果base type pointer指向一个derived class object),这个运算符会传回被适当转换过的指针;如果downcast不是安全的,这个运算符会传回0。
对于引用,如果 reference真正参考到适当的 derived class(包括下一层或下下一层,或下下下一层或……),downcast会被执行而程序可以继续进行;如果 reference并不真正是某一种 derived class,那么,由于不能够传回 0,因此抛出一个 bad_cast exception。
Typeid运算符
typeid运算符传回一个const reference,类型为type_info。
type_infoobjects 也适用于内建类型,以及非多态的使用者自定类型。
7.4 效率有了,弹性呢?
动态共享函数库
class的大小及其每一个直接(或继承而来)的members的偏移位置(offset)都在编译时期就已经固定(虚拟继承的members除外)。这虽然带来了效率,却在二进制层面(binary level)阻碍了弹性。如果 object 布局改变,应用程序就必须重新编译。
共享内存
每一个virtual function在virtual table中的位置已经被写死了。目前的解决方法属于程序层面,程序员必须保证让跨越进程的shared libraries有相同的坐落地址(在SGI中,使用者可以根据所谓的so-location文件,指定每一个shared library的精确位置)。