深度探索C++对象模型
C++在布局以及存取时间上主要的额外负担是由virtual引起的
- virtual function :支持一个有效率的执行期绑定,多态。
- virtual base class :实现多次出现在继承体系中的base class,有一个单一而被共享的实例
1.1 C++对象模型
加上封装后的布局成本
在C++中,有两种class data members:static和nonstatic;三种class member functions:static、nonstatic和virtual。
C++对象模型
在此模型中,Nonstatic data members被配置于每一个class object之内,static data members则被存放在class object之外。static和nonstatic function members也被放在class object之外。
Virtual functions则以两个步骤支持之:
- 每一个 class产生一堆虚函数指针,放在表格之中。这个表格被称为虚函数表(vtbl)。
- 每一个class object安插一个指针,指向相关的virtual table。通常这个指针被称为虚表指针(vptr)。vptr的设定(setting)和重置(resetting)都由每一个class的constructor、destructor和copy assignment运算符自动完成。
1.2 关键词所带来的差异
如果一个程序员迫切需要一个相当复杂的C++ class的某部分数据,使他拥有C声明的那种模样,那么那一部分最好抽取出来成为一个独立的struct声明。C struct在C++中的一个合理用途,是当你要传递“一个复杂的class object的全部或部分”到某个C函数去时,struct声明可以将数据封装起来,并保证拥有与C兼容的空间布局。
1.3 对象的差异
C++程序设计模型直接支持三种programming paradigms(程序设计范式):
- 程序模型(procedural model)。就像 C一样,C++当然也支持它。
- 基于对象模型(abstract data type model,ADT)。此模型所谓的“抽象”是和一组表达式(public接口)一起提供的,那时其运算定义仍然隐而未明。
- 面向对象模型(object-oriented model)。在此模型中有一些彼此相关的类型,通过一个抽象的 base class(用以提供共同接口)被封装起来。
多态的主要用途是经由一个共同的接口来影响类型的封装,这个接口通常被定义在一个抽象的base class中。
需要多少内存才能够表现一个class object?一般而言要有:
- 其 nonstatic data members的总和大小。
- 加上任何由于 alignment(译注)的需求而填补(padding)上去的空间(可能存在于 members之间,也可能存在于集合体边界)。译注:alignment就是将数值调整到某数的倍数。在32位计算机上,通常alignment为4 bytes(32位),以使bus的“运输量”达到最高效率。
- 加上为了支持 virtual而由内部产生的任何额外负担(overhead)。
指针的类型(The Type of a Pointer)
“指针类型”会教导编译器如何解释某个特定地址中的内存内容及其大小。转换(cast)其实是一种编译器指令,并不改变一个指针所含的真正地址,它只影响“被指出之内存的大小和其内容”的解释方式。
总而言之,多态是一种威力强大的设计机制,允许你继承一个抽象的public接口之后,封装相关的类型。需要付出的代价就是额外的间接性——不论是在“内存的获得”或是在“类型的决断”上。C++通过class的pointers和references来支持多态,这种程序设计风格就称为“面向对象”。
C++也支持具体的ADT程序风格,如今被称为object-based(OB)。非多态的数据类型提供一个public 接口和一个private实现品,包括数据和算法,但是不支持类型的扩充。OB设计比对等的OO设计速度更快而且空间更紧凑。速度快是因为所有的函数调用操作都在编译时期解析完成,对象建构起来时不需要设置 virtual机制;空间紧凑则是因为每一个class object 不需要负担传统上为了支持virtual机制而需要的额外负荷。不过,OB设计比较没有弹性。
二、构造函数语意学
2.1 Default Constructor的构造操作
“带有 Default Constructor”的 Member Class Object
如果一个class没有任何constructor,但它内含一个member object,而后者有default constructor,那么这个class的implicit default constructor就是“nontrivial”,编译器需要为该class 合成出一个default constructor。不过这个合成操作只有在constructor真正需要被调用时才会发生。再一次请你注意,被合成的default constructor只满足编译器的需要,而不是程序的需要。
“带有 Default Constructor”的 Base Class
如果一个没有任何constructors的class派生自一个“带有default constructor”的base class,那么这个derived class 的default constructor 会被视为nontrivial,并因此需要被合成出来。它将调用上一层 base classes 的 default constructor(根据它们的声明顺序)。对一个后继派生的class而言,这个合成的constructor和一个“被显式提供的default constructor”没有什么差异。
“带有一个 Virtual Function”的 Class
另有两种情况,也需要合成出default constructor:
- class声明(或继承)一个 virtual function。
- class派生自一个继承串链,其中有一个或更多的 virtual base classes。
“带有一个 Virtual Base Class”的 Class
Virtual base class 的实现法在不同的编译器之间有极大的差异。然而,每一种实现法的共同点在于必须使virtual base class在其每一个derived class object中的位置,能够于执行期准备妥当。
有4种情况,会造成“编译器必须为未声明 constructor 的classes合成一个default constructor”。C++Standard 把那些合成物称为 implicit nontrivial default constructors。被合成出来的constructor只能满足编译器(而非程序)的需要。它之所以能够完成任务,是借着“调用member object或base class的default constructor”或是“为每一个object初始化其virtual function机制或virtual base class机制”而完成的。至于没有存在那4种情况而又没有声明任何constructor的classes,我们说它们拥有的是implicit trivial default constructors,它们实际上并不会被合成出来。
在合成的 default constructor 中,只有 base class subobjects 和 member class objects会被初始化。所有其他的nonstatic data member(如整数、整数指针、整数数组等等)都不会被初始化。这些初始化操作对程序而言或许有需要,但对编译器则非必要。如果程序需要一个“把某指针设为0”的default constructor,那么提供它的人应该是程序员。
C++新手一般有两个常见的误解:
- 任何class如果没有定义default constructor,就会被合成出一个来。
- 编译器合成出来的default constructor会显式设定“class 内每一个 data member的默认值”。
2.2 Copy Constructor的构造操作
Default Memberwise Initialization
当class object 以“相同 class 的另一个 object”作为初值,其内部是以所谓的default memberwise initialization手法完成的,也就是把每一个内建的或派生的data member(例如一个指针或一个数组)的值,从某个object拷贝一份到另一个object身上。不过它并不会拷贝其中的 member class object,而是以递归的方式施行 memberwise initialization。
C++Standard上说,如果class没有声明一个copy constructor,就会有隐式的声明(implicitly declared)或隐式的定义(implicitly defined)出现。和以前一样,C++Standard 把copy constructor区分为trivial和nontrivial两种。只有nontrivial的实例才会被合成于程序之中。决定一个copy constructor是否为trivial的标准在于class 是否展现出所谓的“bitwise copy semantics”。
Bitwise Copy Semantics(位逐次拷贝)
在这被合成出来的copy constructor中,如整数、指针、数组等等的non class members也都会被复制,正如我们所期待的一样。
不要 Bitwise Copy Semantics!
什么时候一个class不展现出“bitwise copy semantics”呢?有4种情况:
- 当class内含一个member object而后者的class声明有一个copy constructor时(不论是被 class设计者显式地声明,就像前面的 String那样;或是被编译器合成,像 class Word那样)。
- 当class继承自一个base class而后者存在一个copy constructor时(再次强调,不论是被显式声明或是被合成而得)。
- 当class声明了一个或多个virtual functions时。
- 当class派生自一个继承串链,其中有一个或多个virtual base classes时。
重新设定Virtual Table的指针
回忆编译期间的两个程序扩张操作(只要有一个class声明了一个或多个virtual functions就会如此):
- 增加一个virtual function table(vtbl),内含每一个有作用的virtual function的地址。
- 一个指向virtual function table的指针(vptr),安插在每一个class object内。
合成出来的ZooAnimal copy constructor 会显式设定object的vptr指向ZooAnimal class的virtual table,而不是直接从右手边的class object中将其vptr现值拷贝过来。
处理 Virtual Base Class Subobject
Virtual base class的存在需要特别处理。一个class object 如果以另一个object作为初值,而后者有一个 virtual base classsubobject,那么也会使“bitwise copy semantics”失效。
每一个编译器对于虚拟继承的支持承诺,都代表必须让“derived class object中的virtual base class subobject位置”在执行期就准备妥当。维护“位置的完整性”是编译器的责任。“Bitwise copy semantics”可能会破坏这个位置,所以编译器必须在它自己合成出来的copy constructor中做出仲裁。
我们已经看过4种情况,在那些情况下class不再保持“bitwise copy semantics”,而且 default copy constructor 如果未被声明的话,会被视为nontrivial。在这4种情况下,如果缺乏一个已声明的copy constructor,编译器为了正确处理“以一个class object 作为另一个class object 的初值”,必须合成出一个copy constructor。
2.3 程序转化语意学
转化
- 显式的初始化操作(Explicit Initialization)
- 参数的初始化(Argument Initialization)
- 返回值的初始化(Return Value Initialization)
优化方法:
- 在使用者层面做优化(Optimization at the User Level)
- 在编译器层面做优化(Optimization at the Compiler Level)。Named Return Value(NRV)优化
copy constructor的应用,迫使编译器多多少少对你的程序代码做部分转化。尤其是当一个函数以传值(by value)的方式传回一个class object,而该class有一个copy constructor(不论是显式定义出来的,或是合成的)时。这将导致深奥的程序转化——不论在函数的定义上还是在使用上。此外,编译器也将copy constructor的调用操作优化,以一个额外的第一参数(数值被直接存放于其中)取代 NRV。程序员如果了解那些转换,以及copy constructor 优化后的可能状态,就比较能够控制其程序的执行效率。
2.4 成员们的初始化队伍(Member Initialization List)
当你写下一个constructor时,就有机会设定class members的初值。要不是经由member initialization list,就是在constructor函数本体之内。
在下列情况下,为了让你的程序能够被顺利编译,你必须使用member initialization list:
- 当初始化一个reference member时;
- 当初始化一个const member时;
- 当调用一个base class的constructor,而它拥有一组参数时;
- 当调用一个member class的constructor,而它拥有一组参数时。
编译器会一一操作initialization list,以适当顺序在constructor之内安插初始化操作,并且在任何explicit user code之前。list中的项目顺序是由class中的members声明顺序决定的,不是由initialization list中的排列顺序决定的。
简略地说,编译器会对initialization list 一一处理并可能重新排序,以反映出members的声明顺序。它会安插一些代码到constructor体内,并置于任何explicit user code之前。
三、Data语意学
Nonstatic data members放置的是“个别的class object”感兴趣的数据,static data members则放置的是“整个class”感兴趣的数据。
对于nonstatic data members,直接存放在每一个class object之中。对于继承而来的nonstatic data members (不管是virtual还是nonvirtual base class)也是如此。至于static data members,则被放置在程序的一个global data segment 中,不会影响个别的class object的大小。在程序之中,不管该class被产生出多少个objects(经由直接产生或间接派生),static data members永远只存在一份实例(译注:甚至即使该class没有任何object实例,其static data members也已存在)。
3.1 Data Member的绑定
因此在一个inline member function躯体之内的一个data member绑定操作,会在整个class声明完成之后才发生。然而,这对于member function的argument list并不为真。Argument list中的名称还是会在它们第一次遭遇时被适当地决议(resolved)完成。
3.2 Data Member的布局
Nonstatic data members在class object中的排列顺序将和其被声明的顺序一样,任何中间介入的static data members都不会被放进对象布局之中。
编译器还可能会合成一些内部使用的data members,以支持整个对象模型。vptr就是这样的东西,目前所有的编译器都把它安插在每一个“内含virtual function之class”的 object 内。一些编译器把vptr放在一个class object的最前端。
3.3 Data Member的存取
Static Data Members
每一个static data member只有一个实例,存放在程序的data segment之中。每次存取static member时,就会被内部转化为对该唯一extern实例的直接存取操作。
Nonstatic Data Members
Nonstatic data members直接存放在每一个class object 之中。经由显式的(explicit)或隐式的(implicit)class object存取它们。欲对一个nonstatic data member进行存取操作,编译器需要把class object的起始地址加上data member的偏移位置(offset)。
3.4 “继承”与Data Member
在C++继承模型中,一个derived class object所表现出来的东西,是其自己的members加上其base class members的总和。至于derived class members和base class members的排列顺序,则并未在C++Standard中强制指定;理论上编译器可以自由安排之。在大部分编译器上头,base class members总是先出现,但属于virtual base class的除外。
a. 没有继承没有多态:
b. 只有继承没有多态:
c. 单继承加多态:
d. 多重继承
对一个多重派生对象,将其地址指定给第一个base class的指针,情况将和单一继承时相同,因为二者都指向相同的起始地址。至于第二个或后继的 base class 的地址指定操作,则需要将地址修改过:加上(介于中间的base class subobject(s)大小。
e. 虚拟继承
四、Function语意学
C++支持三种类型的member functions:static、nonstatic和virtual。
4.1 Member 的各种调用方式
Nonstatic Member Functions(非静态成员函数)
C++的设计准则之一就是:nonstatic member function至少必须和一般的nonmember function有相同的效率。
名称的特殊处理(Name Mangling)一般而言,member的名称前面会被加上class名称,形成独一无二的命名。
Virtual Member Functions(虚拟成员函数)
( * ptr->vptr[1])( ptr )
- vptr表示由编译器产生的指针,指向virtual table。它被安插在每一个“声明有(或继承自)一个或多个 virtual functions”的class object中。事实上其名称也会被“mangled”,因为在一个复杂的class派生体系中,可能存在多个vptrs。
- 1是virtual table slot的索引值,关联到 normalize()函数。
- 第二个ptr表示this指针。
Static Member Functions(静态成员函数)
如果取一个static member function的地址,获得的将是其在内存中的位置,也就是其地址。由于static member function没有this指针,所以其地址的类型并不是一个“指向class member function的指针”,而是一个“nonmember函数指针”。
4.2 Virtual Member Functions(虚拟成员函数)
virtual function的一般实现模型:每一个class有一个virtual table,内含该class之中有作用的virtual function的地址,然后每个object有一个vptr,指向virtual table的所在。在C++中,多态(polymorphism)表示“以一个public base class 的指针(或reference),寻址出一个derived class object”的意思。
一个class只会有一个virtual table。每一个table内含其对应之class object中所有active virtual functions函数实例的地址。这些active virtual functions包括:
- 这一class所定义的函数实例;
- 继承自base class的函数实例;
- 一个pure_virtual_called()函数实例,它既可以扮演pure virtual function的空间保卫者角色,也可以当做执行期异常处理函数(有时候会用到)。每一个virtual function都被指派一个固定的索引值,这个索引在整个继承体系中保持与特定的virtual function的关系。
现在,如果我有这样的式子:ptr->z()
我如何有足够的知识在编译时期设定virtual function的调用呢?
- 一般而言,在每次调用
z()
时,我并不知道ptr所指对象的真正类型。然而我知道,经由 ptr可以存取到该对象的virtual table。 - 虽然我不知道哪一个
z()
函数实例会被调用,但我知道每一个z()
函数地址都被放在slot 4中。这些信息使得编译器可以将该调用转化为:(*ptr->vptr[4])(ptr)
多重继承下的Virtual Functions
在多重继承中支持virtual functions,其复杂度围绕在第二个及后继的base classes身上,以及“必须在执行期调整this指针”这一点。
虚拟继承下的Virtual Functions
五、构造、析构、拷贝语意学
一般而言,class的data member应该被初始化,并且只在constructor中或是在class的其他member functions中指定初值。其他任何操作都将破坏封装性质,使class的维护和修改更加困难。
5.1 “无继承”情况下的对象构造
纯虚函数的存在(Presence of a Pure Virtual Function)
可以定义和调用(invoke)一个pure virtual function;不过它只能被静态地调用(invoked statically),不能经由虚拟机制调用。
5.2 继承体系下的对象构造
Constructor可能内含大量的隐藏码,因为编译器会扩充每一个constructor,扩充程度视class T的继承体系而定。一般而言编译器所做的扩充操作大约如下:
- 记录在member initialization list中的data members初始化操作会被放进constructor的函数本体,并以members的声明顺序为顺序。
- 如果有一个member并没有出现在member initialization list之中,但它有一个default constructor,那么该default constructor必须被调用。
- 在那之前,如果class object有virtual table pointer(s),它(们)必须被设定初值,指向适当的virtual table(s)。
- 在那之前,所有上一层的base class constructors必须被调用,以base class的声明顺序为顺序(与 member initialization list中的顺序没关联)。如果base class被列于member initialization list 中,那么任何显式指定的参数都应该传递过去。如果base class没有被列于member initialization list中,而它有default constructor(或default memberwise copy constructor),那么就调用之。如果base class是多重继承下的第二或后继的base class,那么this指针必须有所调整。
- 在那之前,所有virtual base class constructors必须被调用,从左到右,从最深到最浅。如果class被列于member initialization list中,那么如果有任何显式指定的参数,都应该传递过去。若没有列于list之中,而class有一个default constructor,亦应该调用之。此外,class中的每一个virtual base class subobject的偏移位置(offset)必须在执行期可被存取。如果class object是最底层(most-derived)的class,其constructors可能被调用;某些用以支持这一行为的机制必须被放进来。
虚拟继承(Virtual Inheritance)
在此状态中,“virtual base class constructors的被调用”有着明确的定义:只有当一个完整的class object 被定义出来时,它才会被调用;如果object只是某个完整object的subobject,它就不会被调用。
vptr初始化语意学(The Semantics of the vptr Initialization)
根本的解决之道是,在执行一个constructor时,必须限制一组virtual functions候选名单。所以为了控制一个class中有所作用的函数,编译系统只要简单地控制住vptr的初始化和设定操作即可。在base class constructors调用操作之后,但是在程序员供应的代码或是“member initialization list中所列的 members初始化操作”之前。
constructor的执行算法通常如下:
- 在derived class constructor中,“所有virtual base classes”及“上一层base class”的 constructors会被调用。
- 上述完成之后,对象的vptr(s)被初始化,指向相关的virtual table(s)。
- 如果有member initialization list的话,将在constructor体内扩展开来。这必须在vptr被设定之后才做,以免有一个virtual member function被调用。
- 最后,执行程序员所提供的代码。
5.3 对象复制语意学(Object Copy Semantics)
我建议尽可能不要允许一个virtual base class的拷贝操作。我甚至提供一个比较奇怪的建议:不要在任何virtual base class中声明数据。
5.4 对象的效能(Object Efficiency)
5.5 析构语意学(Semantics of Destruction)
如果class没有定义destructor,那么只有在class内含的member object (或class自己的base class)拥有destructor的情况下,编译器才会自动合成出一个来。否则,destructor被视为不需要,也就不需被合成(当然更不需要被调用)。
- 一个由程序员定义的destructor被扩展的方式类似constructors被扩展的方式,但顺序相反:如果object内含一个vptr,那么首先重设(reset)相关的virtual table。
- destructor的函数本体现在被执行,也就是说vptr会在程序员的代码执行前被重设(reset)。
- 如果class拥有member class objects,而后者拥有destructors,那么它们会以其声明顺序的相反顺序被调用。
- 如果有任何直接的(上一层)nonvirtual base classes拥有destructor,它们会以其声明顺序的相反顺序被调用。
- 如果有任何virtual base classes拥有destructor,而目前讨论的这个class是最尾端(most-derived)的class,那么它们会以其原来的构造顺序的相反顺序被调用。
六、执行期语意学(Runtime Semantics)
C++的一件困难事情:不太容易从程序源码看出表达式的复杂度。
一般而言我们会把object尽可能放置在使用它的那个程序区段附近,这么做可以节省非必要的对象产生操作和摧毁操作。
全局对象(Global Objects)
由于这样的限制,下面这些munch策略就浮现出来了:
- 为每一个需要静态初始化的文件产生一个
_sti()
函数,内含必要的constructor调用操作或inline expansions。 - 在每一个需要静态的内存释放操作(static deallocation)的文件中,产生一个
__std()
函数(译注:我想std就是static deallocation的缩写),内含必要的destructor调用操作,或是其 inline expansions。 - 提供一组runtime library“munch”函数:一个
_main()
函数(用以调用可执行文件中的所有__sti()
函数),以及一个exit()
函数(以类似方式调用所有的__std()
函数)。
局部静态对象(Local Static Objects)
首先,我导入一个临时性对象以保护mat_identity的初始化操作。第一次处理identity()时,这个临时对象被评估为false,于是constructor会被调用,然后临时对象被改为true。这样就解决了构造的问题。而在相反的那一端,destructor也需要有条件地施行于mat_identity身上,但只有在mat_identity已经被构造起来才算数。要判断mat_identity是否被构造起来,很简单,如果那个临时对象为true,就表示构造好了。
6.2 new和delete运算符
运算符new的使用,看起来似乎是个单一运算。但事实上它是由两个步骤完成的:
- 通过适当的new运算符函数实例,配置所需的内存
- 将配置得来的对象设立初值
寻找数组维度,对于delete运算符的效率带来极大的冲击,所以才导致这样的妥协:只有在中括号出现时,编译器才寻找数组的维度,否则它便假设只有单独一个objects要被删除。如果程序员没有提供必须的中括号,那么就只有第一个元素会被析构。其他的元素仍然存在——虽然其相关的内存已经被要求归还了。
6.3 临时性对象(Temporary Objects)
临时性对象在完整表达式尚未评估完全之前,不得被摧毁。也就是说某些形式的条件测试现在必须被安插进来,以决定是否要摧毁和第二算式有关的临时对象。