《深度探索C++对象模型》笔记

Inside the C++ Object Model

Stanley B. Lippman 著

候捷 译

摘录整理。

前言

这本书是由一位编译器设计者针对中高级C++程序员所写的。

有两个概念可以解释C++对象模型:

  1. 语言中直接支持面向对象程序设计的部分。

  2. 对于各种支持的底层实现机制。

本书主要专注于第二个概念。

目前所有编译器对于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。

C++对象模型

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(程序设计范式):

  1. 程序模型(procedural model)。就像 C一样,C++当然也支持它。
  2. 抽象数据类型模型(abstract data type model,ADT)。
  3. 面向对象模型(object-oriented model)。

在OO paradigm之中,程序员需要处理一个未知实例,它的类型虽然有所界定,却有无穷可能。相反地,在ADT paradigm中,程序员处理的是一个拥有固定而单一类型的实例,它在编译时期就已经完全定义好了。

C++以下列方法支持多态:

  1. 经由一组隐式的转化操作。例如把一个 derived class 指针转化为一个指向其 public base type的指针。
  2. 经由 virtual function机制。
  3. 经由 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种情况:

  1. “带有 Default Constructor”的 Member Class Object

  2. “带有 Default Constructor”的 Base Class

    调用顺序优先于1

  3. “带有一个 Virtual Function”的 Class

    两种情况:class声明(或继承)一个 virtual function。class派生自一个继承串链,其中有一个或更多的 virtual base classes。

    产生vtbl和vptr

  4. “带有一个 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种情况:

  1. 当class内含一个member object 而后者的class声明有一个copy constructor时。
  2. 当 class继承自一个 base class而后者存在一个 copy constructor时(再次强调,不论是被显式声明或是被合成而得)。
  3. 当 class声明了一个或多个 virtual functions时。
  4. 当 class派生自一个继承串链,其中有一个或多个 virtual base classes时。

2.3 程序转化语意学

Named Return Value(NRV)优化以result参数取代named return value。NRV优化由编译器完成,如下伪码所示。如果用了NRV优化,那就不必调用拷贝构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
X bar()
{
X xx;
// ...处理xx
return xx;
}
void bar(X &__result)
{
// default constructor被调用
// C++伪码
__result.X::X();
// ...处理__result
return;
}

一般而言,面对“以一个class object作为另一个class object的初值”的情形,语言允许编译器有大量的自由发挥空间。其利益当然是导致机器码产生时有明显的效率提升。缺点则是你不能够安全地规划你的copy constructor 的副作用,必须视其执行而定。

如果class声明一个或一个以上的virtual functions,或内含一个virtual base class,不能在构造函数中使用memcpy()或memset(),否则会导致那些“被编译器产生的内部members”的初值被改写(如vptr)。

2.4 成员们的初始化队伍

在下列情况下,为了让你的程序能够被顺利编译,你必须使用member initialization list:

  1. 当初始化一个 reference member时;
  2. 当初始化一个 const member时;
  3. 当调用一个 base class的 constructor,而它拥有一组参数时;
  4. 当调用一个 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的大小可能比想象的大,因为:

  1. 由编译器自动加上的额外 data me mbers,用以支持某些语言特性(主要是各种 virtual特性)。
  2. 因为 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
2
3
ptr->normalize();
// 调用虚函数,(可能)发生以下转化:
(*ptr->vptr[1])(ptr);

静态成员函数

静态成员函数没有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函数有两个阶段:

  1. 分析函数定义,以决定函数的“intrinsic inline ability”(本质的 inline能力)。“intrinsic”(本质的、固有的)一词在这里意指“与编译器相关”。
  2. 真正的 inline函数扩展操作是在调用的那一点上。这会带来参数的求值操作(evaluation)以及临时性对象的管理。

第5章 构造、析构、拷贝语意学

5.1 “无继承”情况下的对象构造

1
2
3
4
typedef struct
{
float x,y,z;
}Point;

观念上,编译器会为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语意:

  1. 当class内含一个member object,而其class有一个copy assignment operator时。
  2. 当一个class的 base class有一个 copy assignment operator时。
  3. 当一个class声明了任何 virtual functions (我们一定不要拷贝右端 class object的 vptr地址,因为它可能是一个 derived class object)时。
  4. 当 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
2
3
4
5
6
7
8
// arena指向内存中的一个区块,用以放置新产生出来的Point2w object。
Point2s *ptw = new (arena) Point2w;
// 实际操作,看起来很多余,但定位new的真正威力是将构造函数自动实施于arena所指的地址上
void* operator new(size_t, void* p)
{
return p;
}
//C++Standard说arena必须指向相同类型的class,要不就是一块“新鲜”内存,足够容纳该类型的object。并未定义多态行为。

6.3 临时性对象

copy constructor、destructor以及copy assignment operator都可以由使用者供应,所以不能够保证上述两个操作会导致相同的语意。因此以一连串的destruction 和copy construction 来取代assignment一般而言是不安全的,而且会产生临时对象。

1
2
3
4
5
// 不会产生临时对象
T c = a + b;
// 会产生临时对象
c = a + b;
a + b;

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// scope of the template definition 
extern double foo(double);

template <class type>
class ScopeRules
{
public:
void invariant() {
_member = foo(_val);
}
type type_dependent() {
return foo(_member);
}
private:
int _val;
type _member;
}
// scope of the template instantiation
extern int foo(int);

ScopeRules<int> sr0;
// 调用extern double foo(double);
sr0.invariant();
// 调用extern int foo(int);
sr0.type_dependent();

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的精确位置)。


《深度探索C++对象模型》笔记
https://tech.reddish.fun/Notebook/Inside-the-CPP-Object-Model-note/
作者
bit704
发布于
2023年4月25日
许可协议