深入C++对象模型笔记

一、关于对象

1.0 加上封装后的布局成本(Layout Costs for Adding Encapsulation)

C++在布局以及存取时间上主要的额外负担是由virtual引起的:

  • virtual function 机制用以支持一个有效率的“执行期绑定”(runtime binding)。
  • virtual base class 用以实现“多次出现在继承体系中的base class,有一个单一而被共享的实例(ios)。

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:

  1. 每一个 class 产生出一堆指向virtual functions的指针,放在表格之中。这个表格被称为 virtual table(vtbl)。
  2. 每一个class object 被安插一个指针,指向相关的virtual table。通常这个指针被称为 vptr。vptr的设定(setting)和重置(resetting)都由每一个class的constructor、destructor和copy assignment运算符自动完成。每一个 class所关联的 type_info object(用以支持runtime type identification,RTTI)也经由virtual table被指出来,通常放在表格的第一个slot(地址空间)。

1.2 关键词所带来的差异

C struct在C++中的一个合理用途,是当你要传递“一个复杂的class object的全部或部分”到某个C函数去时,struct声明可以将数据封装起来,并保证拥有与C兼容的空间布局。

然而这项保证只在组合(composition)的情况下才存在。如果是“继承”而不是“组合”,编译器会决定是否应该有额外的data members被安插到base struct subobject之中。

1.3 对象的差异

C++程序设计模型直接支持三种programming paradigms(程序设计范式):

  1. 程序模型(procedural model)。就像 C一样,C++当然也支持它。
  2. 抽象数据类型模型(abstract data type model,ADT)。此模型所谓的“抽象”是和一组表达式(public接口)一起提供的,实际仍然未定义。
  3. 面向对象模型(object-oriented model)。在此模型中有一些彼此相关的类型,通过一个抽象的 base class(用以提供共同接口)被封装起来。

只有通过pointer或reference间接处理对象,才能支持多态性质。

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

  1. 经由一组隐式的转化操作。例如把一个 derived class 指针转化为一个指向其 public base type的指针。
  2. 经由 virtual function 机制。
  3. 经由 dynamic_cast 和 typeid 运算符。

多态的主要用途是经由一个共同的接口来影响类型的封装,这个接口通常被定义在一个抽象的base class中。

需要多少内存才能够表现一个class object?一般而言要有:

  • 其 nonstatic data members的总和大小。

  • 加上任何由于 alignment 的需求而填补(padding)上去的空间。

    alignment就是将数值调整到某数的倍数。在32位计算机上,通常alignment为4 bytes(32位),以使bus的“运输量”达到最高效率。

  • 加上为了支持 virtual 而由内部产生的任何额外负担(overhead)。

“指向不同类型之各指针”间的差异,既不在其指针表示法不同,也不在其内容(代表一个地址)不同,而是在其所寻址出来的object类型不同。

也就是说,“指针类型”会教导编译器如何解释某个特定地址中的内存内容及其大小。

转换(cast)其实是一种编译器指令。大部分情况下它并不改变一个指针所含的真正地址,它只影响“被指出之内存的大小和其内容”的解释方式。

总而言之,多态是一种威力强大的设计机制,允许你继承一个抽象的public接口之后,封装相关的类型。需要付出的代价就是额外的间接性——不论是在“内存的获得”或是在“类型的决断”上。C++通过class的pointers和references来支持多态,这种程序设计风格就称为“面向对象”。

C++也支持具体的ADT程序风格,如今被称为object-based(OB)。例如String class,一种非多态的数据类型。String class可以展示封装的非多态形式;它提供一个public 接口和一个private实现,包括数据和算法,但是不支持类型的扩充。如今的 Go,Rust 都是走OB的路线。

一个OB设计可能比一个对等的OO设计速度更快而且空间更紧凑。速度快是因为所有的函数调用操作都在编译时期解析完成,对象建构起来时不需要设置 virtual 机制;空间紧凑则是因为每一个 class object 不需要负担传统上为了支持virtual机制而需要的额外负荷。不过,OB设计比较没有弹性。

二、构造函数语意学

  • implicit:暗中的、隐式的(在程序源代码中没有出现)
  • explicit:显式的(程序源代码中出现)
  • trivial:没有用的
  • nontrivial:有用的
  • memberwise:对每一个member施以……
  • bitwise:对每一个bit施以……
  • semantics:语意

2.1 Default Constructor的构造操作

C++新手一般有两个常见的误解:

  1. 任何class如果没有定义default constructor,就会被合成出一个来。
  2. 编译器合成出来的default constructor会显式设定“class 内每一个 data member的默认值”。

有4种情况,会造成“编译器必须为未声明 constructor 的classes合成一个default constructor”。C++Standard 把那些合成物称为 implicit nontrivial default constructors。

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

如果一个class没有任何constructor,但它内含一个member object,而后者有default constructor,那么这个class的 implicit default constructor就是“nontrivial”,编译器需要为该class 合成出一个default constructor。不过这个合成操作只有在constructor真正需要被调用时才会发生。再一次请你注意,被合成的default constructor只满足编译器的需要,而不是程序的需要。

如果对象的成员没有定义默认构造函数,那么编译器合成的默认构造函数将不会为之提供初始化。例如类A包含两个数据成员对象,分别为:string strchar* Cstr,那么编译器生成的默认构造函数将只提供对string类型成员的初始化,而不会提供对 char* 类型的初始化。

  1. “带有 Default Constructor”的 Base Class

如果一个没有任何constructors的class派生自一个“带有default constructor”的base class,那么这个derived class 的default constructor 会被视为nontrivial,并因此需要被合成出来。它将调用上一层 base classes 的 default constructor(根据它们的声明顺序)。对一个后继派生的class而言,这个合成的constructor和一个“被显式提供的default constructor”没有什么差异。

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

另有两种情况,也需要合成出default constructor:

  • class声明(或继承)一个 virtual function。
  • class派生自一个继承串链,其中有一个或更多的 virtual base classes。
  1. “带有一个 Virtual Base Class”的 Class

Virtual base class 的实现,在不同的编译器之间有极大的差异。然而,每一种实现法的共同点在于必须使virtual base class在其每一个derived class object中的空间位置,能够于执行期准备妥当。

被合成出来的constructor只能满足编译器(而非程序)的需要。

至于没有存在那4种情况而又没有声明任何constructor的classes,实际上default constructor并不会被合成出来。

在合成的 default constructor 中,只有 base class subobjects 和 member class objects 会被初始化。所有其他的nonstatic data member(如整数、整数指针、整数数组等等)都不会被初始化。

这些初始化操作对程序而言或许有需要,但对编译器则非必要。如果程序需要一个“把某指针设为0”的default constructor,那么提供它的人应该是程序员。

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(位逐次拷贝)

在这被合成出来的default copy constructor中,如整数、指针(浅拷贝)、数组等等的non class members也都会被复制。

什么时候一个class不展现出“bitwise copy semantics”呢?有4种情况:

  1. 当class内含一个member object而后者的class声明有一个copy constructor时(不论是被 class设计者显式地声明,就像前面的 String那样;或是被编译器合成,像 class Word那样)。
  2. 当class继承自一个base class而后者存在一个copy constructor时(不论是被显式声明或是被合成而得)。
  3. 当class声明了一个或多个virtual functions时。
  4. 当class派生自一个继承串链,其中有一个或多个virtual base classes时。

重新设定Virtual Table的指针

回忆编译期间的两个程序扩张操作(只要有一个class声明了一个或多个virtual functions就会如此):

  • 增加一个virtual function table(vtbl),内含每一个有作用的virtual function的地址。
  • 一个指向virtual function table的指针(vptr),安插在每一个class object内。

编译器合成出来的 copy constructor 会显式设定 lhs object的 vptr 指向 rhs 的 virtual table,而不是直接从rhs的class object 中将vptr值拷贝过来。

处理 Virtual Base Class Subobject

1
2
3
4
5
6
7
class Base{
int x;
};

class Derived : Base {
int y;
};

在这个继承体系里,每个 Derived 类型的 object 就包含了一个 Base 类型的 subobject 。

一个subobject可以是成员子对象(member subobject),基类子对象(base class subobject),或者成员数组的元素。

Virtual base class的存在需要特别处理。一个class object 如果以另一个object作为初值,而后者有一个 virtual base class subobject,那么也会使“bitwise copy semantics”失效。

每一个编译器对于虚继承,都必须让“derived class object中的virtual base class subobject位置”在执行期就准备妥当。维护“位置的完整性”是编译器的责任。

“Bitwise copy semantics”可能会破坏这个位置。

小结

Bitwise Copy Semantics部分展示了4种情况,在那些情况下class不再保持“bitwise copy semantics”,而且 default copy constructor 会被视为 nontrivial。

  1. 当class内含一个member object而后者的class声明有一个copy constructor时(不论是被 class设计者显式地声明,就像前面的 String那样;或是被编译器合成,像 class Word那样)。
  2. 当class继承自一个base class而后者存在一个copy constructor时(不论是被显式声明或是被合成而得)。
  3. 当class声明了一个或多个virtual functions时。
  4. 当class派生自一个继承串链,其中有一个或多个virtual base classes时。

在这4种情况下,如果缺乏一个已声明的 copy constructor,编译器为了正确处理“以一个class object 作为另一个class object 的初值”,必须合成出一个default 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 优化后的可能状态,就比较能够控制其程序的执行效率。

命名返回值优化 (Named Return Value Optimization)

对于一个如foo()这样的函数,它的每一个返回分支都返回相同的对象,编译器有可能对其做Named return Value优化(下文都简称NRV优化),方法是以引用的方式传入一个参数result取代返回对象。

1
2
3
4
5
6
7
X foo() { 
X xx;
if(...)
return xx;
else
return xx;
}

优化后的foo()result取代xx

1
2
3
4
5
6
7
8
9
void  foo(X &result) {
result.X::X();
if(...) {
return;
}
else {
return;
}
}

对比优化前与优化后的代码可以看出,对于一句类似于X a = foo()这样的代码,NRV优化后的代码相较于原代码节省了一个临时对象的空间(省略了xx),同时减少了两次函数调用(减少xx对象的默认构造函数和析构函数,以及一次拷贝构造函数的调用),增加了一次 X a 的默认构造函数的调用。

2.4 成员们的初始化队伍(Member Initialization List)

当你写下一个constructor时,就有机会设定class members的初值。经由member initialization list,或在constructor函数本体之内。

在下列情况下,为了让你的程序能够被顺利编译,你必须使用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中的排列顺序决定的。

简略地说,编译器会对initialization list 一一处理并可能重新排序,以反映出members的声明顺序。它会安插一些代码到constructor体内,并置于任何explicit user code之前。initialzation list 的执行先于用户自定义的函数体。

三、Data语意学(The Semantics of Data)

class的data members以及class hierarchy是中心议题。一个class的data members,一般而言,可以表现这个class在程序执行时的某种状态。Nonstatic data members放置的是“个别的class object”感兴趣的数据,static data members则放置的是“整个class”感兴趣的数据。

C++对象模型尽量以空间优化和存取速度优化的考虑来表现nonstatic data members,并且保持和C语言struct数据配置的兼容性。它把数据直接存放在每一个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)完成。

需要某种防御性程序风格:请总是把“nested type声明”放在class的起始处。

3.2 Data Member的布局

Nonstatic data members在class object中的排列顺序将和其被声明的顺序一样,任何中间介入的static data members都不会被放进对象布局之中。

编译器还可能会合成一些内部使用的data members,以支持整个对象模型。vptr就是这样的东西,目前所有的编译器都把它安插在每一个“内含virtual function之class”的 object 内。一些编译器把vptr放在一个class object的最前端。

在VC中数据成员的布局顺序为:

  1. vptr部分(如果基类有,则继承基类的)
  2. vbptr (如果需要)
  3. 基类成员(按声明顺序)
  4. 自身数据成员
  5. 虚基类数据成员(按声明顺序)

3.3 Data Member的存取

Static Data Members

每一个static data member只有一个实例,存放在程序的data segment之中。每次程序参阅(取用)static member时,就会被内部转化为对该唯一extern实例的直接参考操作。

static member并不内含在一个class object之中。

若名称冲突,编译器的解决方法是暗中对每一个static data member编码(这种手法有个很美的名称:name-mangling),以获得一个独一无二的程序识别代码。

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(es) members的总和。至于derived class members和base class(es)members的排列顺序,则并未在C++Standard中强制指定;理论上编译器可以自由安排之。在大部分编译器上头,base class members总是先出现,但属于virtual base class的除外(一般而言,任何一条通则一旦碰上virtual base class就没辙了,这里亦不例外)。

只要继承不要多态(Inheritance without Polymorphism)

加上多态(Adding Polymorphism)

虚拟继承(Virtual Inheritance)

多重继承的一个语意上的副作用就是,它必须支持某种形式的“shared subobject继承”。

虚继承的子类有自己的一个vptr。

3.5 对象成员的效率

单一继承应该不会影响测试的效率,因为members被连续存储于derived class object中,并且其offset在编译时期就已知了。虚拟继承的效率会降低。

3.6 指向Data Members的指针

如何区分一个“没有指向任何data member”的指针,和一个指向“第一个data member”的指针?

为了区分p1和p2,每一个真正的member offset值都被加上1。不论编译器或使用者都必须记住,在真正使用该值以指出一个member之前,请先减掉1。

四、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(虚拟成员函数)

1
( * 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”的意思。

C++对“积极多态(active polymorphism)”的唯一支持,就是对于virtual function call的决议(resolution)操作。有了RTTI,就能够在执行期查询一个多态的pointer或多态的reference了。

欲鉴定哪些 classes 展现多态特性,我们需要额外的执行期信息。一如我所说,关键词class和struct并不能够帮助我们。由于没有导入像是polymorphic之类的新关键词,因此识别一个class是否支持多态,唯一适当的方法就是看看它是否有任何virtual function。只要class拥有一个virtual function,它就需要这份额外的执行期信息。

在实现上,首先我可以在每一个多态的class object身上增加两个members:

  1. 一个字符串或数字,表示class的类型;
  2. 一个指针,指向某表格,表格中持有程序的virtual functions的执行期地址。

然而,执行期备妥那些函数地址,只是解答的一半而已。另一半解答是找到那些地址。两个步骤可以完成这项任务:

  1. 为了找到表格,每一个class object被安插了一个由编译器内部产生的指针,指向该表格。
  2. 为了找到函数地址,每一个virtual function被指派一个表格索引值。

这些工作都由编译器完成。执行期要做的,只是在特定的virtual table slot(记录着virtual function的地址)中激活virtual function。

一个class只会有一个virtual table。每一个table内含其对应之class object中所有active virtual functions函数实例的地址。

现在,如果我有这样的式子: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

右下角的vtbl有问题,指的应该是Point2d的函数地址。

4.3 函数的效能

nonmember、static member或nonstatic member函数都被转化为完全相同的形式。所以我们毫不惊讶地看到三者的效率完全相同。

4.4 指向Member Function的指针

取一个nonstatic data member的地址,得到的结果是该member在class布局中的bytes位置(再加1)。你可以想象它是一个不完整的值,它需要被绑定于某个class object的地址上,才能够被存取。取一个nonstatic member function的地址,如果该函数是nonvirtual,得到的结果是它在内存中真正的地址。然而这个值也是不完全的。它也需要被绑定于某个class object的地址上,才能够通过它调用该函数。所有的nonstatic member functions都需要对象的地址(以参数this指出)。

使用一个“member function指针”,如果并不用于virtual function、多重继承、virtual base class等情况的话,并不会比使用一个“nonmember function指针”的成本更高。

支持“指向 Virtual Member Functions”的指针

对一个nonstatic member function取其地址,将获得该函数在内存中的地址。然而面对一个virtual function,其地址在编译时期是未知的,所能知道的仅是virtual function在其相关之virtual table中的索引值。也就是说,对一个virtual member function取其地址,所能获得的只是一个索引值。

“指向 Member Functions之指针”的效率

一个“指向member function的指针”,是一个结构,内含三个字段:index、faddr和delta。index若不是内含一个相关virtual table的索引值,就是以-1表示函数是nonvirtual。faddr持有nonvirtual member function 的地址。delta持有一个可能的this指针调整值。

4.5 Inline Functions

一般而言,处理一个inline函数,有两个阶段:

  1. 分析函数定义,以决定函数的“intrinsic inline ability”(本质的 inline能力)。“intrinsic”(本质的、固有的)一词在这里意指“与编译器相关”。如果函数因其复杂度,或因其建构问题,被判断不可成为inline,它会被转为一个static函数,并在“被编译模块”内产生对应的函数定义。
  2. 真正的inline函数扩展操作是在调用的那一点上。这会带来参数的求值操作(evaluation)以及临时性对象的管理。

局部变量(Local Variables)

一般而言,inline函数中的每一个局部变量都必须被放在函数调用的一个封闭区段中,拥有一个独一无二的名称。如果inline函数以单一表达式(expression)扩展多次,则每次扩展都需要自己的一组局部变量。如果inline函数以分离的多个式子(discrete statements)被扩展多次,那么只需一组局部变量,就可以重复使用(译注:因为它们被放在一个封闭区段中,有自己的scope)。

然而一个inline函数如果被调用太多次的话,会产生大量的扩展码,使程序大小暴涨。

五、构造、析构、拷贝语意学

一般而言,class的data member应该被初始化,并且只在constructor中或是在class的其他member functions中指定初值。其他任何操作都将破坏封装性质,使class的维护和修改更加困难。

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

可以定义和调用(invoke)一个pure virtual function;不过它只能被静态地调用(invoked statically),不能经由虚拟机制调用。

唯一的例外就是pure virtual destructor:class设计者一定得定义它。为什么?因为每一个derived class destructor会被编译器加以扩张,以静态调用的方式调用其“每一个virtual base class”以及“上一层base class”的destructor。因此,只要缺乏任何一个base class destructors的定义,就会导致链接失败。

这样的设计是以C++语言的一个保证为前提:继承体系中每一个class object的destructors都会被调用。所以编译器不能够压抑这一调用操作。一个比较好的替代方案就是,不要把virtual destructor声明为pure。

5.2 继承体系下的对象构造

constructor的执行算法通常如下:

  1. 在derived class constructor中,“所有virtual base classes”及“上一层base class”的 constructors会被调用。
  2. 上述完成之后,对象的vptr(s)被初始化,指向相关的virtual table(s)。
  3. 如果有member initialization list的话,将在constructor体内扩展开来。这必须在vptr被设定之后才做,以免有一个virtual member function被调用。
  4. 最后,执行程序员所提供的代码。

5.3 对象复制语意学(Object Copy Semantics)

尽可能不要允许一个virtual base class的拷贝操作。我甚至提供一个比较奇怪的建议:不要在任何virtual base class中声明数据。

5.4 析构语意学(Semantics of Destruction)

如果class没有定义destructor,那么只有在class内含的member object (或class自己的base class)拥有destructor的情况下,编译器才会自动合成出一个来。否则,destructor被视为不需要,也就不需被合成(当然更不需要被调用)。

小结

  1. 即使是一个抽象基类,如果它有非静态数据成员,也应该给它提供一个带参数的构造函数,来初始化它的数据成员。类的data member应当被初始化,且只在其构造函数或其member function中初始化。

  2. 不要将析构函数设计为纯虚的,这不是一个好的设计。将析构函数设计为纯虚函数意味着,即使纯虚函数在语法上允许我们只声明而不定义纯虚函数,但还是必须实现该纯虚析构函数,否则它所有的继承类都将遇到链接错误。

  3. 真的必要的时候才使用虚函数,不要滥用虚函数。虚函数意味着不小的成本,编译很可能给你的类带来膨胀效应。

  4. 不能决定一个虚函数是否需要 const ,那么就不要它。

  5. 决不在构造函数或析构函数中使用虚函数机制。在构造函数中,每次调用虚函数会被决议为当前构造函数所对应类的虚函数实体,虚函数机制并不起作用。

六、执行期语意学(Runtime Semantics)

C++的一件困难事情:不太容易从程序源码看出表达式的复杂度。

一般而言我们会把object尽可能放置在使用它的那个程序区段附近,这么做可以节省非必要的对象产生操作和摧毁操作。

6.1 对象的构造和析构

全局对象(Global Objects)

由于这样的限制,下面这些munch策略就浮现出来了:

  1. 为每一个需要静态初始化的文件产生一个_sti()函数,内含必要的constructor调用操作或inline expansions。
  2. 在每一个需要静态的内存释放操作(static deallocation)的文件中,产生一个__std()函数(译注:我想std就是static deallocation的缩写),内含必要的destructor调用操作,或是其 inline expansions。
  3. 提供一组runtime library“munch”函数:一个_main()函数(用以调用可执行文件中的所有__sti()函数),以及一个exit()函数(以类似方式调用所有的__std()函数)。

Lippman建议:根本就不要使用那些需要静态初始化的全局对象。真的非要一个全局对象,而且这个对象还需要静态初始化?用一个函数封装一个静态局部对象,也是一样的效果。

6.2 new和delete运算符

运算符new expression运算的使用,看起来似乎是个单一运算。int *p=new int (5)实际上包含着两个步骤:

  1. 调用一个合适的operator new实体分配足够的未类型化的内存。
  2. 调用合适的构造函数初始化这块内存,当然int没有构造函数,但是会进行赋值操作:*p=5

delete寻找数组维度,对于delete运算符的效率带来极大的冲击,所以才导致这样的妥协:只有在中括号出现时,编译器才寻找数组的维度,否则它便假设只有单独一个objects要被删除。如果程序员没有提供必须的中括号,那么就只有第一个元素会被析构。其他的元素仍然存在——虽然其相关的内存已经被要求归还了。

new expression和operator new

new expression和operator new完全不是一回事,但关系不浅——operator new 为new expression分配内存。且不能重定义new或delete expression的行为。

operator new其实也是可以直接利用的,譬如当我们只想分配内存,而不愿意进行初始化的时候,我们就可以直接用operator new 来进行。用法如下:

1
T* newelements = static_cast<T*>(operator new ( sizeof(T) );

标准库重载有两个版本的operator new,分别为单个对象和数组对象服务,单个对象版本的提供给分配单个对象new expression调用,数组版的提供给分配数组的 new expression 调用:

1
2
void *operator new(size_t);     // allocate an object
void *operator new[](size_t); // allocate an array

我们可以分别重载这两个版本,来定义我们自己的分配单个对象或对象数组的内存方式。当我们自己在重载operator new时,不一定要完全按照上面两个版本的原型重载,唯一的两个要求是:返回一个void*类型和第一个参数的类型必须为size_t

在类中重载的operator new和operator delete是隐式静态的,因为前者运行于对象构造之前,后者运行于对象析构之后,所以他们不能也不应该拥有一个this指针来存取数据。

placement operator new

placement operator new用来在指定地址上构造对象,要注意的是,它并不分配内存,仅仅是对指定地址调用构造函数。

point *pt = new(p) point3d;

它是operator new的一个重载版本。它的实现方式异常简单,传回一个指针即可:

1
2
3
void* operator new(site_t,void *p){
return p;
}

看一份代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Base {
int j;
virtual void f();
};

struct Derived : Base {
void f();
};

void fooBar() {
Base b;
b.f(); // Base::f() invoked
b.~Base(); //析构掉了,但是内存并未释放掉
new (&b) Derived;
b.f(); // which f() invoked?
}

上述两个类的大小相同,因此将Derived对象放在 Base对象中是安全的,但是在最后一句代码中 b.f()调用的是哪一个类的f()。答案是Base::f() 的。

虽然此时b中存储的实际上是一个Derived对象,但是,通过一个对象来调用虚函数,将被静态决议出来,虚函数机制不会被启用。

6.3 临时性对象(Temporary Objects)

何时生成临时对象

程序片段:

1
2
T a, b;
T c = a + b;

编译器更愿意直接调用拷贝构造函数的方式将a+b的值放到c中,这样就不需要临时对象,和它的构造函数和拷贝构造函数的调用了。如果operator +的定义符合NRV优化的条件,那么NRV优化的开启,将使得拷贝构造函数的调用和named object的析构函数都免了。

所以比先声明 c 对象,再进行c = a + b要高效。

临时对象的生命周期

临时性对象在完整表达式尚未评估完全之前,不得被摧毁。临时性对象的摧毁应当作为造成产生这个临时对象的完整表达式的最后一个步骤。

对于下面的程序:

1
2
string s1("hello "), s2("world "), s3("by Adoo");
std::cout << s1 + s2 + s3 << std::endl;

显然保存s1+s2结果的临时对象,如果在与s3进行加法之前析构,将会带来大麻烦。

七、站在对象模型的尖端

C++语言三个著名的扩充性质,它们都会影响C++对象。它们分别是template、exception handling(EH)和runtime type identification(RTTI)。

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 Instantiation)

一个模板只有被使用到,才会被实例化,否则不会被实例化。对于一个实例化后的模板来说,未被调用的成员函数将不会被实例化,只有成员函数被使用时,C++标准才要求实例化他们。其原因,有两点:

  • 空间和时间效率的考虑,如果模板类中有100个成员函数,对某个特定类型只有2个函数会被使用,针对另一个特定类型只会使用3个,那么如果将剩余的195个函数实例化将浪费大量的时间和空间。
  • 使模板有最大的适用性。并不是实例化出来的每个类型都支持所有模板的全部成员函数所需要的运算符。如果只实例化那些真正被使用的成员函数的话,那么原本在编译期有错误的类型也能够得到支持。

可以明确的要求在一个文件中将整个类模板实例化:

1
template class Point3d<float>;

也可以显示指定实例化一个模板类的成员函数:

1
template float Point3d<float>::X() const;

或是针对一个模板函数:

1
2
template Point3d<float> operator+(   
const Point3d<float>&, const Point3d<float>& );

Template的错误报告(Error Reporting within a Template)

所以在一个parsing策略之下,所有语汇(lexing)错误和解析(parsing)错误都会在处理template声明的过程中被标示出来。所有与类型有关的检验,如果牵涉到template参数,都必须延迟到真正的实例化操作(instantiation)发生,才得为之。

目前的编译器,面对一个template声明,在它被一组实际参数实例化之前,只能施行以有限的错误检查。template中那些与语法无关的错误,程序员可能认为十分明显,编译器却让它通过了,只有在特定实例被定义之后,才会发出抱怨。这是目前实现技术上的一个大问题。

模板的错误报告,使用模板并遇到错误的大概都深有体会,那就是一个灾难。

Template中的名称决议(Name Resolution within a Template)

Template之中,对于一个nonmember name 的决议结果,是根据这个name的使用是否与“用以实例化该template的参数类型”有关而决定的。如果其使用互不相关,那么就以“scope of the template declaration”来决定name。如果其使用互有关联,那么就以“scope of the tem plate instantiation”来决定name。

这意味着一个编译器必须保持两个scope contexts:

  1. “scope of the template declaration”,用以专注于一般的template class。
  2. “scope of the template instantiation”,用以专注于特定的实例。

编译器的决议(resolution)算法必须决定哪一个才是适当的scope,然后在其中搜寻适当的name。

第一种情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// scope of the template declaration
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;
};

第二种情况:

1
2
3
4
5
6
7
//scope of the template instantiation  
extern int foo( int );

// ...
ScopeRules< int > sr0;
sr0.invariant();
sr0.type_dependent();

在“scope of the template instantiation ”中两个foo()都声明在此 scope中。sr0.invariant() 中调用的是:

1
extern double foo ( double );

看上去,应该调用:

1
extern int foo( int );

毕竟,_val 的类型是 int 类型,它们才完全匹配。

而 sr0.type_dependent() 中调用的却在我们意料之中,调用的是:

1
extern int foo( int );

诸上所述,看上去或合理或不合理的选择,原因在于:

template 之中, 对于一个非成员名字的决议结果是根据这个 name 的使用是否与“用以实例化该模板的参数类型”有关来决定name。如果其使用互不相干,那么就以“scope of the template declaration”来决定name。如果其使用的互相关联,那么就以“scope of the template instantiation”来决定name。

Member Function的实例化行为(Member Function Instantiation)

对于 template 的支持,最困难的莫过于template function的实例化(instantiation)。目前的编译器提供了两个策略:一个是编译时期策略,程序代码必须在program text file中备妥可用;另一个是链接时期策略,有一些meta-compilation工具可以导引编译器的实例化行为(instantiation)。

7.2 异常处理(Exception Handling)

欲支持exception handling,编译器的主要工作就是找出catch子句,以处理被抛出来的 exception。这多少需要追踪程序堆栈中的每一个函数的目前作用区域(包括追踪函数中local class objects当时的情况)。同时,编译器必须提供某种查询exception objects 的方法,以知道其实际类型(这直接导致某种形式的执行期类型识别,也就是 RTTI)。最后,还需要某种机制用以管理被抛出的object,包括它的产生、存储、可能的析构(如果有相关的destructor)、清理(clean up)以及一般存取。也可能有一个以上的objects同时起作用。一般而言,exception handling机制需要与编译器所产生的数据结构以及执行期的一个exception library紧密合作。

在程序大小和执行速度之间,编译器必须有所抉择:

  • 为了维护执行速度,编译器可以在编译时期建立起用于支持的数据结构。这会使程序的大小发生膨胀,但编译器可以几乎忽略这些结构,直到有个exception被抛出来。
  • 为了维护程序大小,编译器可以在执行期建立起用于支持的数据结构。这会影响程序的执行速度,但意味着编译器只有在必要的时候才建立那些数据结构(并且可以抛弃之)。

Exception Handling

快速检阅C++的exception handling由三个主要的语汇组件构成:

  1. 一个throw子句。它在程序某处发出一个exception。被抛出去的exception可以是内建类型,也可以是使用者自定类型。
  2. 一个或多个catch子句。每一个catch子句都是一个exception handler。它用来表示说,这个子句准备处理某种类型的exception,并且在封闭的大括号区段中提供实际的处理程序
  3. 一个try区段。它被围绕以一系列的叙述句(statements),这些叙述句可能会引发catch子句起作用。

当一个exception被抛出去时,控制权会从函数调用中被释放出来,并寻找一个吻合的catch子句。如果都没有吻合者,那么默认的处理例程terminate()会被调用。当控制权被放弃后,堆栈中的每一个函数调用也就被推离(popped up)。这个程序称为unwinding the stack。在每一个函数被推离堆栈之前,函数的local class objects的destructor会被调用。

支持EH,会使那些拥有member class subobjects或base class subobjects(并且它们也都有constructors)的classes的constructor更复杂。一个class如果被部分构造,其destructor必须只施行于那些已被构造的subobjects和(或)member objects身上。

对Exception Handling的支持

当一个exception发生时,编译系统必须完成以下事情:

  1. 检验发生throw操作的函数。
  2. 决定throw操作是否发生在try区段中。
  3. 若是,编译系统必须把exception type拿来和每一个catch子句进行比较。
  4. 如果比较后吻合,流程控制应该交到catch子句手中。
  5. 如果throw的发生并不在 try区段中,或没有一个catch子句吻合,那么系统必须(a)摧毁所有active local objects,(b)从堆栈中将目前的函数“unwind”掉,(c)进行到程序堆栈的下一个函数中去,然后重复上述步骤 2~5。

决定throw是否发生在一个try区段中

还记得吗,一个函数可以被想象为好几个区域:

  • try区段以外的区域,而且没有active local objects。
  • try区段以外的区域,但有一个(或以上)的active local objects需要析构。
  • try区段以内的区域。

编译器必须标示出以上各区域,并使它们对执行期的exception handling系统有所作用。一个很棒的策略就是构造出program counter-range表格。

回忆一下,program counter内含下一个即将执行的程序指令。好,为了在一个内含try区段的函数中标示出某个区域,可以把program counter的起始值和结束值(或是起始值和范围)存储在一个表格中。

当throw操作发生时,目前的program counter值被拿来与对应的“范围表格”进行比对,以决定目前作用中的区域是否在一个try区段中。如果是,就需要找出相关的catch子句。如果这个exception无法被处理(或者它被再次抛出),目前的这个函数会从程序堆栈中被推出(popped),而program counter会被设定为调用端地址,然后这样的循环再重新开始。

将exception的类型和每一个catch子句的类型做比较

对于每一个被抛出来的exception,编译器必须产生一个类型描述器,对exception的类型进行编码。如果那是一个derived type,编码内容必须包括其所有base class的类型信息。只编进public base class的类型是不够的,因为这个exception可能被一个member function捕捉,而在一个member function的范围(scope)之中,derived class和nonpublic base class之间可以转换。

类型描述器(type descriptor)是必要的,因为真正的exception是在执行期被处理的,其object必须有自己的类型信息。RTTI正是因为支持EH而获得的副产品。

编译器还必须为每一个catch子句产生一个类型描述器。执行期的exception handler会将“被抛出之object的类型描述器”和“每一个catch子句的类型描述器”进行比较,直到找到吻合的一个,或是直到堆栈已经被 “unwind” 而 terminate()已被调用。

每一个函数会产生出一个exception表格,它描述与函数相关的各区域、任何必要的善后处理代码(cleanup code,被local class object destructors调用)以及catch子句的位置(如果某个区域是在try区段之中的话)。

当一个实际对象在程序执行时被抛出,会发生什么事?

当一个exception被抛出时,exception object会被产生出来并通常放置在相同形式的exception数据堆栈中。从throw端传给catch子句的,是exception object的地址、类型描述器(或是一个函数指针,该函数会传回与该exception type有关的类型描述器对象)以及可能会有的 exception object 描述器(如果有人定义它的话)。

异常与内存

异常抛出有可能带来一些问题,比方在一块内存的lock和unlock内存之间,或是在new和delete之间的代码抛出了异常,那么将导致本该进行的unlock或delete操作不能进行。

在函数被出栈之前,先截住异常,在unlock和delete之后再将异常原样抛出。new expression的调用不用包括在try块之内是因为,不论在new operator调用时还是构造函数调用时抛出异常,都会在抛出异常之前释放已分配好的资源,所以不用再调用delete 。

另一个办法是,将这些资源管理的问题,封装在一个类对象中,由析构函数释放资源,这样就不需要对代码进行上面那样的处理——利用函数释放控制权之前会析构所有局部对象的原理。

同样的道理,适用于数组身上,如果在调用构造函数过程中抛出异常,那么之前所有被构造好的元素的析构函数被调用,对于抛出异常的该元素,则遵循关于单个对象构造的原则,然后释放已经分配好的内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void  mumble( void *arena )  
{
Point *p;
p = new Point;
try {
smLock( arena );
// ...
}
catch ( ... ) {
smUnLock( arena );
delete p;
throw;
}
smUnLock( arena );
delete p;
}

7.3 执行期类型识别(Runtime Type Identification,RTTI)

Type-Safe Downcast(保证安全的向下转换操作)

一个type-safe downcast必须在执行期对指针有所查询,看看它是否指向它所展现(表达)之object的真正类型。因此,欲支持type-safe downcast,在object空间和执行时间上都需要一些额外负担:

  • 需要额外的空间以存储类型信息(type information),通常是一个指针,指向某个类型信息节点。
  • 需要额外的时间以决定执行期的类型(runtime type),因为,正如其名所示,这需要在执行期才能决定。

C++的RTTI机制提供了一个安全的downcast设备,但只对那些展现“多态(也就是使用继承和动态绑定)”的类型有效。

在C++中,一个具备多态性质的class(所谓的polymorphic class),正是内含着继承而来(或直接声明)的virtual functions。

从编译器的角度来看,这个策略还有其他优点,就是大量降低额外负担。所有polymorphic classes的objects都维护了一个指针(vptr),指向virtual function table。只要我们把与该class相关的RTTI object 地址放进virtual table 中(通常放在第一个slot),那么额外负担就降低为:每一个class object只多花费一个指针。这一指针只需被设定一次,它是被编译器静态设定的,而非在执行期由 class constructor设定。

Type-Safe Dynamic Cast(保证安全的动态转换)

dynamic_cast运算符可以在执行期决定真正的类型。如果downcast是安全的(也就是说,如果base type pointer指向一个derived class object),这个运算符会传回被适当转换过的指针。如果downcast不是安全的,这个运算符会传回0。

什么是dynamic_cast的真正成本呢?pfct的一个类型描述器会被编译器产生出来。由pt所指向的class object类型描述器必须在执行期通过vptr取得。type_info是C++Standard所定义的类型描述器的class名称,该class中放置着待索求的类型信息。virtual table的第一个slot内含type_info object 的地址;此type_info object与pt所指的class type有关。这两个类型描述器被交给一个runtime library函数,比较之后告诉我们是否吻合。

References不同于Pointers

程序执行中对一个class指针类型施以dynamic_cast 运算符:

  • 如果传回真正的地址,则表示这一object的动态类型被确认了,一些与类型有关的操作现在可以施行于其上。
  • 如果传回0,则表示没有指向任何object,意味着应该以另一种逻辑施行于这个动态类型未确定的object身上。

因此当dynamic_cast运算符施行于一个reference 时,会发生下列事情:

  • 如果reference真正cast到适当的derived class,downcast会被执行而程序可以继续进行。
  • 如果reference并不真正是某一种derived class,那么,由于不能够传回0,因此抛出一个 bad_cast exception。

原因在于指针可以被赋值为0,以表示 no object,但是引用不行。

Typeid运算符

使用 typeid 运算符,就有可能以一个reference达到相同的执行期替代路线(runtime“alternative pathway”)。typeid运算符传回一个const reference,类型为type_info。如果两个type_info objects相等,这个equality运算符就传回true。

typeid 可以返回const type_info&,用以获取类型信息。

虽然RTTI只支持多态类,但typeidtype_info同样可用于内建类型及所有非多态类。与多态类的差别在于,非多态类的type_info对象是静态取得,而多态类的是在执行期获得。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!