《深度探索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://reddish.fun/posts/Notebook/Inside-the-CPP-Object-Model-note/
作者
bit704
发布于
2023年4月25日
许可协议