0%

C++(一)对象模型

原图

对象模式

在C++中,有两种class data members:static和nonstatic,以及三种class member functions:static、nonstatic和virtual。已知下面这个class Point声明:

1
2
3
4
5
6
7
8
9
10
11
12
class Point {
public:
Point(float xval);
virtual ~Point();
float x() const
static int PointCount();
protected:
virtual ostream&
print(ostream &os) const;
float _x;
static int _point_count;
};

这个class Point在机器中将会被怎么样表现呢?也就是说,我们如何模塑(modeling)出各种data members和function members呢?

C++对象模型(The C++ Object Model)

Stroustrup当初设计的C++对象模型是从简单对象模型派生而来的,并对内存空间和存取时间做了优化。在此模型中:

  • Nonstatic datamembers被配置于每一个class object之内
  • static data members则被存放在所有的class object之外(data段或bss段)
  • Static和nonstatic function members也被放在所有的class object之外(代码段)
  • Virtual functions则以两个步骤支持之:
    • 每一个class产生出一堆指向virtual functions的指针,放在表格之中。这个表格被称为virtual table(vtbl)
    • 每一个class object被添加了一个指针,指向相关的virtual table。通常这个指针被称为vptr。vptr的设定(setting)和重置(resetting)都由每一个class的constructor、destructor和copy assignment运算符自动完成。每一个class所关联的type_info object(用以支持:runtime type identifcation,RTTI)也经由virtual table被指出来,通常是放在表格的第一个slot处

加上继承(Adding Inheritance)

单一继承:

1
2
3
class Library_materials {...}
class Book : public Library_materials {..}
class Rental_book : public Book {..}

多重继承:

1
2
3
4
//原本的(更早于标准版的)iostream实现方式
class iostream:
public istream,
public ostream {..}

甚至,继承关系也可以指定为虚拟(virtual,也就是共享的意思):

1
2
class istream : virtual public ios {...}
class ostream : virtual public ios {...}

在虚拟继承的情况下,base class不管在继承串链中被派生(derived)多少次,永远只会存在一个实体(称为subobject)。例如iostream之中就只有virtual ios base class的一个实体。

base class table被产生出来时,表格中的每一个slot内含一个相关的base class地址,这很像virtual table内含每一个virtual function的地址一样。每一个class object内含一个bptr,它会被初始化,指向其base class table。

  • 缺点:主要缺点是由于间接性而导致的空间和存取时间上的额外负担,
  • 优点:
    • 在每一个class object中对于继承都有一致的表现方式:每一个class object都应该在某个固定位置上安放一个base table指针,与base classes的大小或数目无关
    • 不需要改变class objects本身,就可以放大、缩小、或更改base class table

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

  • 其nonstatic data members的总和大小
  • 加上任何由于alignment的需求而填补(padding)上去的空间
  • 加上为了支持virtual而由内部产生的任何额外负担(overhead)

构造函数语义学

Default Constructor的建构操作

C++ Standard【ISO-C++95】的Section12.1这么说: 对于class X,如果没有任何user-declared constructor,那么会有一个default constructor被暗中(implicitly)声明出来。一个被暗中声明出来的default constructor将是一个trivial(浅薄而无能,没啥用的)constructor。

C++ Standard然后开始叙述在什么样的情况下这个implicit default constructor会被视为trivial。一个nontrivial default constructor在ARM的术语中就是编译器所需要的那种,必要的话会由编译器合成出来。下面分别讨论nontrivial default constructor的四种情况。

带有Default Constructor的Member Class Object

如果一个class没有任何constructor,但它内含一个member object,而后者有default constructor,那么这个class的implicit default constructor就是“nontrivial'”,编译器需要为此class合成出一个default constructor。

  • 在C++各个不同的编译模块中,编译器如何避免合成出多个default constructor呢?解决方法是把合成的default constructor、copy constructor、destructor、assignment copy operator都以inline方式完成。一个inline函数有静态链接(static linkage),不会被档案以外者看到。如果函数太复杂,不适合做成inline,就会合成出一个explicit non-inline static实体
  • “如果class A内含一个或一个以上的member class objects,那么class A的每一个constructor必须调用每一个member classes的default constructor”。编译器会扩张已存在的constructors,在其中安插一些代码,使得user code在被执行之前,先调用必要的default constructors
  • 如果有多个class member objects都要求constructor初始化操作,将如何呢?C++语言要求以“member objects在class中的声明次序”来调用各个constructors

带有Default Constructor的Base Class

类似的道理,如果一个没有任何constructors的class派生自一个“带有default constructor'”的base class,那么这个derived class的default constructor会被视为nontrivial,并因此需要被合成出来。它将调用上一层base classes的default constructor(根据它们的声明次序)。

如果设计者提供多个constructors,但其中都没有default constructor呢?编译器会扩张现有的每一个constructors,将“用以调用所有必要之default constructors”的程序代码加进去。它不会合成一个新的default constructor,这是因为其它“由user所提供的constructors”存在的缘故。

如果同时亦存在着“带有default constructors”的member class objects,那些default constructor也会被调用-—在所有base class constructor都被调用之后。

带有一个Virtual Function的Class

  • class声明(或继承)一个virtual function
  • class派生自一个继承串链,其中有一个或更多的virtual base classes

不管哪一种情况,由于缺乏由user声明的constructors,编译器会详细记录合成一个default constructor的必要信息。

  • 一个virtual function table(在cfront中被称为vtbl)会被编译器产生出来,内放class的virtual functions地址
  • 在每一个class object中,一个额外的pointer member(也就是vptr)会被编译器合成出来,内含相关的class vtbl的地址

带有一个Virtual Base Class的Class

Virtual base class的实现法在不同的编译器之间有极大的差异。然而,每一种实现法的共通点在于必须使virtual base class在其每一个derived class object中的位置,能够于执行期准备妥当。例如下面这段程序代码中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class X {public:int i;};
class A : public virtual X {public:int j;};
class B : public virtual X {public:double d;};
class C : public A,public B {public:int k;};

//无法在编译时期决定(resolve)出pa->X::i的位置
void foo(const A *pa) { pa->i = 1024;};

main()
{
foo(new A);
foo(new C);
//...
}

原先cfront的做法是靠“在derived class object的每一个virtual base classes中安插一个指针”完成。所有“经由reference或pointer来存取一个virtual base class”的操作都可以通过相关指针完成。foo()可以被改写如下,以符合这样的实现策略:

1
2
//可能的编译器转变操作
void foo(const A*pa) {pa->__vbcX->i = 1024;}

其中__vbcX表示编译器所产生的指针,指向virtual base class X。正如你所臆测的那样,__vbcX(或编译器所做出的某个什么东西)是在class object建构期间被完成的。对于class所定义的每一个constructor,编译器会安插那些“允许每一个virtual base class的执行期存取操作”的码。如果class没有声明任何constructors,编译器必须为它合成一个default constructor。

Copy Constructor的建构操作

有三种情况,会以一个object的内容作为另一个class object的初值。最明显的一种情况当然就是对一个object做明确的初始化操作,另两种情况是当object被当作参数交给某个函数以及当函数传回一个class object时。

Default Memberwise Initialization

如果class没有提供一个explicit copy constructor又当如何?当class object以“相同class的另一个object”作为初值时,其内部是以所谓的default memberwise initializatior手法完成的,也就是把每一个内建的或派生的datamember(例如一个指针或数目组)的值,从某个object拷贝一份到另一个object身上。不过它并不会拷贝其中的member class object,而是以递归的方式施行memberwise initializaon。

如果一个String object被声明为另一个class的member,像这样:

1
2
3
4
5
6
7
8
class Word
{
public:
//……没有explicit copy constructorprivate:
private:
int _occurs;
string _word;//译注:String object成为class Word的一个member!
};

那么一个Word object的default memberwise initialization会拷贝其内建的member _occurs,然后再于String member object_word身上递归实施memberwise initialization

就像default constructor一样,C++ Standard上说,如果class没有声明一个copy constructor,就会有隐含的声明(implicitly declared)或隐含的定义(implicitlydefined)出现。和以前一样,C++Standard把copy constructor区分为trivial和nontrivial两种。只有nontrivial的实体才会被合成于程序之中。决定一个copy constructor是否为trivial的标准在于class是否展现出所谓的“bitwise copysemantics'”。

Bitwise Copy Semantics(位逐次拷贝)

在下面的程序片段中:

1
2
3
4
5
6
7
8
9
#include "Word.h"

Word noun("book");

void foo()
{
Word verb = noun;
//...
}

很明显verb是根据noun来初始化。但是在尚未看过class Word的声明之前,我们不可能预测这个初始化操作的程序行为。如果class Word的设计者定义了一个copy constructor,verb的初始化操作会调用它。但如果该class没有定义explicit copy constructor,那么是否会有一个编译器合成的实体被调用呢?这就得视该class是否展现"bitwise copy semantics'”而定。举个例子,已知下面的class Word声明:

1
2
3
4
5
6
7
8
9
10
11
//以下声明展现了bitwise copy semantic
class Word
{
public:
Word(const char*)
~Word() {delete[] str;}
//...
private:
int cnt;
char *str;
};

这种情况下并不需要合成出一个default copy constructor,因为上述声明展现了“default copy semantics”,而verb的初始化操作也就不需要以一个函数调用收场。

一般来说,如果你的class 仅包含了POD(Plain Object Data)这样的,是展现出了Bitwise Copy Semantics,即编译器在内部可以一个字节一个字节的拷贝(如memcpy)也不会出现问题。

然而,如果class Word是这样声明:

1
2
3
4
5
6
7
8
9
10
11
//以下声明并未展现出bitwise copy semantics
class Word
{
public:
Word(const String&)
~Word();
//...
private:
int cnt;
String str;
};

其中String声明了一个explicit copy constructor:

1
2
3
4
5
6
7
8
class String
{
public:
String(const char* );
String(const String&);
~String();
//...
};

在这个情况下,编译器必须合成出一个copy constructor以便调用member class String object的copy constructor:

1
2
3
4
5
6
7
//一个被合成出来的copy constructor
//C++伪码
inline Word::Word(const Word& wd)
{
str.String::String(wd.str);
cnt = wd.cnt;
}

有一点很值得注意:在这被合成出来的copy constructor中,如整数、指针、数组等等的nonclass members也都会被复制,正如我们所期待的一样。

不要Bitwise Copy Semantics!

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

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

前两种情况中,编译器必须将member或base class的“copy constructors调用操作”安插到被合成的copy constructor中。前一节class Word的“合成而得的copy constructor”正足以说明情况1,2。情况3和4有点复杂,是我接下来要讨论的题目。

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

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

很显然,如果编译器对于每一个新产生的class object的vptr不能成功而正确地设好其初值,将导致可怕的后果。因此,当编译器导入一个vptr到class之中时,该class就不再展现bitwise semantics了。现在,编译器需要合成出一个copy constructor,以求将vptr适当地初始化。

yogi会被default Bear constructor初始化。而在constructor中,yogi的vptr被设定指向Bear class的virtual table(靠编译器安插的码完成)。因此,把yogi的vptr值拷贝给winnie的vptr是安全的。

合成出来的ZooAnimal copy constructor会明确设定object的vptr指向ZooAnimal class的virtual table,而不是直接从右手边的class object中将其vptr现值拷贝过来。

Data语意学

类对象大小受三个因素影响

  • virtual base和virtual function带来的vptr影响
  • EBO(Empty Base class Optimize)空基类优化处理,EBC(Empty Base Class)占用一个字节,其他含有数据成员的从EBC派生的派生类,只会算自己数据成员的大小,不受EBC一字节的影响
  • alignment 字节对齐

Nonstatic data members

  • Nonstatic data members在class object中的排列顺序将和其被声明顺序一样,任何中间介入的static data members都不会被放进布局之中
  • 每一个nonstatic data member的偏移量在编译时即可获知,不管其有多么复杂的派生,都是一样的。通过对象存取一个nonstatic data member,其效率和存取一个C struct member是一样的
  • 从对象存取obj.x和指针存取pt->x有和差异? 当继承链中有虚基类时,查找虚基类的成员变量时延迟到了执行期,根据virtual class offset查找到虚基类的部分,效率稍低

静态成员变量 static data members

  • 存放在程序的data segment之中
  • 通过指针和对象来存取member,完全一样,不管继承或者是虚拟继承得来,全局也只存在唯一一个实例
  • 静态常量成员可以在类定义时直接初始化,而普通静态常量成员只能在.o编译单元的全局范围内初始化

“迷承”与Data Member

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

1
2
3
4
5
6
7
8
9
10
class Concrete
{
public:
//...
private:
int val;
char c1;
char c2;
char c3;
};

在一部32位机器中,每一个Concrete class object的大小都是8 bytes,细分如下:

  • val占用4 bytes
  • c1、c2和c3各占用1 bytes
  • alignment(调整到word边界)需要1 bytes

现在假设,经过某些分析之后,我们决定了一个更逻辑的表达方式,把Concrete分裂为三层结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Concrete1
{
public:
//.…
private:
int val;
char bit1;
};
class Concrete2 : public Concrete1
{
public:
//..
private:
char bit2;
};
class Concrete3 : public Concrete2
{
public:
//...
private:
char bit3;
};

Concrete1内含两个members:val和bit1,加起来是5 bytes。而一个Concrete.object实际用掉8 bytes,包括填补用的3 bytes,以使object能够符合一部机器的word边界。不论是C或C++都是这样。一般而言,边界调整(alignment)是由处理器(processor)来决定的。

Concrete2的bit2实际上被放在填补空间所用的3 bytes之后。于是其大小变成12 bytes,不是8 bytes。其中有6 bytes浪费在填补空间上。相同的道理使得Concrete3 object的大小是16 bytes,其中9 bytes用于填补空间。

多重继承(Multiple Inheritance)

多重继承既不像单一继承,也不容易模塑出其模型。例如,考虑下面这个多重继承所获得的class Vertex3d:

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
26
27
28
29
class Point2d
{
public:
//...(译注:拥有virtual接口。所以Point2d对象之中会有Vptr)
protected:
float _x,_y;
};

class Point3d : public Point2d
{
public:
protected:
float _z;
};

class vertex
{
public:
//...(译注:拥有virtual接口。所以Point2d对象之中会有Vptr)
protected:
Vertex *next;
};

class Vertex3d : public Point3d ,public vertex
{
public:
protected:
float mumble;
};

其继承关系如下:

内存布局如下:

虚拟继承(Vitual Inheritance)

一般的实现方法如下所述。Class如果内含一个或多个virtual base class subobjects,将被分割为两部分:一个不变局部和一个共享局部。不变局部中的数据,不管后继如何衍化,总是拥有固定的offset(从object的开头算起),所以这一部分数据可以被直接存取。至于共享局部,所表现的就是virtual base class subobject。这一部分的数据,其位置会因为每次的派生操作而有变化,所以它们只可以被间接存取。各家编译器实现技术之间的差异就在于间接存取的方法不同。下面是Vertex3d虚拟继承的层次结构:

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
26
class Point2d
{
public:
//...(译注:拥有virtual接口。所以Point2d对象之中会有Vptr)
protected:
float _x,_y;
};
class Point3d : public vitrual Point2d
{
public:
protected:
float _z;
};
class vertex : public vitrual Point2d
{
public:
//,·.(译注:拥有virtual接口.所以Vertex对象之中会有vptr)
protected:
Vertex *next;
};
class Vertex3d:: public Point3d ,public vertex
{
public:
protected:
float mumble;
};

其继承关系如下:

内存布局如下:

Function语意学

C++的设计准则之一:nostatic member function 至少必须和一般的nonmember function有相同的效率

  • 改写函数原型,在参数中增加this指针
  • 对每一个"nonstatic data member的存取操作"改为由this指针来存取
  • 将member function重写为一个外部函数,经过"mangling"处理

覆盖(override)、重写(overload)、隐藏(hide)的区别

  • 重载是指不同的函数使用相同的函数名,但是函数的参数个数或类型不同。调用的时候根据函数的参数来区别不同的函数。
  • 覆盖(也叫重写)是指在派生类中重新对基类中的虚函数(注意是虚函数)重新实现。即函数名和参数都一样,只是函数的实现体不一样
  • 隐藏是指派生类中的函数把基类中相同名字的函数屏蔽掉了。隐藏与另外两个概念表面上看来很像,很难区分,其实他们的关键区别就是在多态的实现上

Virtual Member Functions(虚拟成员函数)

我们已经看过了virtual function的一般实现模型:每一个class有一个virtual table,内含该class之中有作用的virtual function的地址,然后每个object有一个vptr,指向virtual table的所在。在这一节中,我要走访一组可能的设计,然后根据单一继承、多重继承和虚拟继承等各种情况,从细部上探究这个模型、为了支持virtual function机制,必须首先能够对于多态对象有某种形式的“执行期类型判断法(runtime type resolution)”。也就是说,以下的调用操作将需要ptr在执行期的某些相关信息,

1
ptr->z();

如此一来才能够找到并调用z()的适当实体。或许最直接了当但是成本最高的解决方法就是把必要的信息加在ptr身上。在这样的策略之下,一个指针(或是一个reference)含有两项信息:

  • 它所参考到的对象的地址
  • 对象类型的某种编码,或是某个结构的地址

这个方法带来两个问题

  • 第一,它明显增加了空间负担,即使程序并不使用多态(polymorphism)
  • 第二,它打断了与C程序间的链接兼容性。如果这份额外信息不能够和指针放在一起,下一个可以考虑的地方就是把它放在对象本身。

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

下一个明显的问题是,什么样的额外信息是我们需要存储起来的?也就是说,如果我有这样的调用:

1
ptr->z();

其中z()是一个virtual function,那么什么信息才能让我们在执行期调用正确的z()实体?我需要知道:

  • ptr所指对象的真实类型。这可使我们选择正确的z()实体
  • z()实体位置,以便我能够调用它

那么,我如何有足够的知识在编译时期设定virtual function的调用呢?

  • 一般而言,我并不知道ptr所指对象的真正类型,然而我知道,经由ptr可以存取到该对象的virtual table。虽然我不知道哪一个z()函数实体会被调用,但我知道每一个z()函数地址都被放在slot4。这些信息使得编译器可以将该调用转化为:
1
*ptr->vptr[4]) (ptr );

在这个转化中,ptr表示编译器所安的指针,指向virtual table;4表示z()被赋值的slot编号(关联到Point体系的virtual table)唯一个在执行期才能知道的东西是:slot4所指的到底是哪一个z()函数实体?

在一个单一继承体系中,virtual function机制的行为十分良好,不但有效率而且很容易塑造出模型来。但是在多重继承和虚拟继承之中,对virtual functions的支持就没有那么美好了。

多重继承下的Virtual Functions

在多重继承中支持virtual functions,其复杂度围绕在第二个及后继的base classes身上,以及“必须在执行期调整this指针”这一点。以下面的class体系为例:

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
26
27
28
29
30
31
32
//class体系,用来描述多重继承(MI)情况下支持virtual function时的复杂度
class Base1
{
public:
Base1();
virtual ~Base1();
virtual void speakclearly();
virtual Base1 *clone() const;
protected:
float data_Base1;
};

class Base2
{
public:
Base2();
virtual ~Base2();
virtual void mumble ();
virtual Base2 *clone()const;
protected:
float data_Base2;
};

class Derived : public Base1,public Base2
{
public:
Derived();
virtual ~Derived();
virtual Derived *clone ()const;
protected:
float data_Derived;
};

“Derived支持virtual functions”的困难度,统统落在Base2 subobject身上。有三个问题需要解决,以此例而言分别是

  • virtual destructor
  • 被继承下来的Base2:mumble()
  • 一组clone()函数实体

让我依次解决每一个问题

第一种情况:
我把一个从heap中配置而得的Derived对象的地址,指定给一个Base2指针:

1
Base2 *pbase2 = new Derived;

新的Derived对象的地址必须调整,以指向其Base2 subobject。编译时期会产生以下代码:

1
2
3
//转移以支持第二个base class
Derived *temp = new Derived;
Base2 *pbase2 = temp ? temp + sizeof ( Base1 ) : 0;

如果没有这样的调整,指针的任何“非多态运用”(像下面那样)都将失败:

1
2
//即使pbase2被指定一个Derived对象,这也应该没有问题
pbase2->data_Base2;

当程序员要删除pbase2所指的对象时:

1
2
3
4
//必须首先调用正确的virtual destructor函数实体
//然后施行delete运算符.
//pbase2可能需要调整,以指出完整对象的起始点
delete pbase2;

指针必须被再一次调整,以求再一次指向Derived对象的起始处(推测它还指向Derived对象)。然而上述的offset加法却不能够在编译时期直接设定,因为pbase2所指的真正对象只有在执行期才能确定。

一般规则是,经由指向“第二或后继之base class”的指针(或reference)来调用derived class virtual function。泽注就像本例的:

1
2
Base2 *pbase2 = new Derived;
delete pbase2; //invoke derived class's destructor (virtual)

该调用操作所连带的“必要的this指针调整”操作,必须在执行期完成。也就是说,offset的大小,以及把offset加到this指针上头的那一小段程序代码必须由编译器在某个地方插人。问题是,在哪个地方?

offset
Bjarne原先实施于cfront编译器中的方法是将virtual table加大,使它容纳此处所需的this指针,调整相关事物。每一个virtual table slot,不再只是一个指针,而是一个聚合体,内含可能的offset以及地址。于是virtual function的调用操作由:

1
(*pbase2->vptr [1])(pbase2 )

改变为:

1
2
(*pbase2->vptr[1].faddr)
pbase2 + pbase2->vptr[1].offset )

其中faddr内含virtual function地址,offset内含this指针调整值。

这个做法的缺点是,它相当于连带处罚了所有的virtual function调用操作。不管它们是否需要offset的调整我所谓的处罚,包括offset的额外存取及其加法,以及每一个virtual table slot的大小改变。

比较有效率的解决方法是利用所谓的thunk。Thunk技术初次引进到编译器技术中,我相信是为了支持ALGOL独一无二的pass-by-name语意。所谓thunk是一小段assembly码,用来以适当的offset值调整this指针,跳到virtual function去。例如,经由一个Base?指针调用Derived destructor,其相关的thunk可能看起来是这个样子:

1
2
3
4
//虚拟C++码
pbase2_dtor_thunk:
this += sizeof (base1)
Derived::~Derived (this )

Bjarne并不是不知道thunk技术,问题是thunk只有以assembly码完成才有效率可言。由于cfront使用C作为其程序代码产生语言,所以无法提供一个有效率的thunk编译器。 Thunk技术允许virtual table slot继续内含一个简单的指针,因此多重继承不需要任何空间上的额外负担。Slots中的地址可以直接指向virtual function,也可以指向一个相关的thunk(如果需要调整this指针的话)。于是,对于那些不需要调整this指针的virtual function(相信大部分是如此,虽然我手上没有数据)而言,也就不需承载效率上的额外负担。 调整this指针的第二个额外负担就是,由于两种不同的可能:

  • 经由derived class调用,经由第二个base class调用,同一函数在virtual table中可能需要多笔对应的slots。例如:
1
2
3
4
Base1 *pbase1 = new Derived;
Base2 *pbase2 = new Derived;
delete pbase1;
delete pbase2;

虽然两个delete操作导致相同的Derived destructor,但它们需要两个不同的virtual table slots:

  • pbase1不需要调整this指针(因为Base1是最左端base class之故,它已经指向Derived对象的起始处)。其virtual table slot需放置真正的destructor地址
  • pbase2需要调整this指针。其virtual table slot需要相关的thunk地址

在多重继承之下,一个derived class内含n-1个额外的virtual tables,n表示其上一层base classes的数目(因此,单一继承将不会有额外的virtual tables)。对于本例之Derived而言,会有两个virtual tables被编译器产生出来:

  • 一个主要实体,与Basel(最左端base class)共享
  • 一个次要实体,与Base2(第二个base class)有关

针对每一个virtual tables,Derived对象中有对应的vptr。图4.2说明了这一点。vptrs将在constructor(s)中被设立初值(经由编译器所产生出来的码)

用以支持“一个class拥有多个virtual tables'”的传统方法是,将每一个tables以外部对象的形式产生出来,并给予独一无二的名称。例如,Derived所关联的两个tables可能有这样的名称:

1
2
vtbl Derived;  //主要表格
vtbl Base2_Derived; //次要表格

于是当你将一个Derived对象地址指定给一个Base1指针或Derived指针时,被处理的virtual table是主要表格vtbl Derived。而当你将一个Derived对象地址指定给一个Base2指针时,被处理的virtual table是次要表格vtbl Base2 Derived。

由于执行期链接器(runtime linkers)的降临,符号名称的链接可能变得非常缓慢。为了调节执行期链接器的效率,Sun编译器将多个virtual tables连锁为一个;指向次要表格的指针,可由主要表格名称加上一个offset获得。在这样的策略下,每一个class只有一个具名的virtual table。“对于许多Sun项目程序代码而言,速度的提升十分明显。

稍早我曾写道,有三种情况,第二或后继的base class会影响对virtual functions的支持。第一种情况是,通过一个“指向第二个base class”的指针,调用derived class virtual function。例如:

1
2
3
4
Base2 *ptr = new Derived;
//调用Derived::~Derived
//ptr必须被向后调整sizeof(Basel)个bytes
delete ptr;

从图4.2之中,你可以看到这个调用操作的重点:ptr指向Derived对象中的Base2 subobject;为了能够正确执行,ptr必须调整指向Derived对象的起始处。

第二种情况:
是第一种情况的变化,通过一个“指向derived class”的指针,调用第二个base class中一个继承而来的virtual function。在此情况下,derived class指针必须再次调整,以指向第二个base subobject。例如:

1
2
3
4
Derived *pder = new Derived;
//调用Base2::mumble()
//per必须被向前调整sizeof(zaael)个bytes
pder->munble();

第三种情况:
发生于一个语言扩充性质之下:允许一个virtual function的返回值类型有所变化,可能是base type,也可能是publicly derived type。这一点可以通过Derivea:clone()函数实体来说明。clone函数的Derived版本传回一个Derived class指针,默默地改写了它的两个base class函数实体。当我们通过“指向第二个base class'”的指针来调用clone()时,this指针的offset问题于是诞生:

1
2
3
4
Base2 *pb1 = new Derived;
//调用Derived*Derived::clone()
//返回值必须被调整,以指向Base2 subobject
Base2 *pb2 = pb1->clone();

当进行pb1->clone()时,pb1会被调整指向Derived对象的起始地址,于是clone()的Derived版会被调用:它会传回一个指针,指向一个新的Derived对象;该对象的地址在被指定给pb2之前,必须先经过调整,以指向Base2 subobject。

当函数被认为“足够小”的时候,Sun编译器会提供一个所谓的“splitfunctions”技术:以相同算法产生出两个函数,其中第二个在返回之前,为指针加上必要的offset。于是不论通过Base1指针或Derived指针调用函数,都不需要调整返回值;而通过Base2指针所调用的,是另一个函数。

如果函数并不小,“split function”策略会给予此函数中的多个进入点(entrypoints)中的一个。每一个进人点需要三个指令,然而OO程序员都会尽量使用小规模的virtual function将操作“局部化”。通常,virtual function的平均大小是8行。

函数如果支持多重进入点,就可以不必有许多“thunks'”。如IBM就是把thunk搂抱在真正被调用的virtual function中。函数一开始先调整this指针,然后才执行程序员所写的函数码:至于不需调整的函数调用操作,就直接进人的部分。

虚拟继承下的Virtual Functions

两者之间的转换也就需要调整this指针。至于在虚拟继承的情况下要消除thunks,一般而言已经被证明是一项高难度技术。

当一个virtual base class从另一个virtual base class派生而来,并且两者都支持virtual functions和nonstatic data members时,编译器对于virtual base class的支持简直就像进了迷宫一样。我的建议是:

不要在一个virtual base class中声明nonstatic data members

静态成员函数 static member functions

  • 不能访问非静态成员
  • 不能声明为const、volatile或virtual
  • 参数没有this
  • 可以不用对象访问,直接 类名::静态成员函数访问

vtable虚函数表一定是在编译期间获知的,其函数的个数、位置和地址是固定不变的,完全由编译器掌控,执行期间不允许任何修改

vtable的内容:

  • virtual class offset(有虚基类才有)
  • topoffset
  • typeinfo
  • 继承基类所声明的虚函数实例,或者是覆盖(override)基类的虚函数
  • 新的虚函数(或者是纯虚函数占位)

执行期虚函数调用步骤

  • 通过vptr找到vtbl
  • 通过thunk技术以及topoffset调整this指针(因为成员函数里面可能调用了成员变量)
  • 通过virtual class offset找到虚基类共享部分的成员
  • 执行vtbl中对应slot的函数
  • 多重继承中,一个派生类会有n-1个额外的vtbl(也可能有n或者n以上个vtbl,看是否有虚基类),它与第一父类共享vtbl,会修改其他父类的vtbl

最后:Inline对编译器只是请求,并非命令。inline中的局部变量+有表达式参数-->大量临时变量-->程序规模暴涨

执行期语意学

  • 尽量推迟变量定义,避免不必要的构造和析构(虽然C++编译器会尽量保证在调用变量的时候才进行构造,推迟变量定义会使得代码好阅读)
  • 全局类变量在编译期间被放置于data段中并被置为0
    • GOOGLE C++编程规范:禁止使用class类型的静态或全局变量,只允许使用POD型静态变量(Plain Old Data)和原生数据类型。因为它们会导致很难发现的bug和不确定的构造和析构函数调用顺序
    • 解决:改成在static函数中,产生局部static对象
  • 如果有表达式产生了临时对象,那么应该对完整表达式求值结束之后才摧毁这些创建的临时对象。有两个例外:
    • 该临时对象被refer为另外一个对象的引用;
    • 该对象作为另一对象的一部分被使用,而另一对象还没有被释放。

站在对象模型的尖端

  • 对于RTTI的支持,在vtbl中增加一个type_info的slot
  • dynamic_cast比static_cast要花费更多的性能(检查RTTI释放匹配、指针offset等),但是安全性更好
  • 对引用施加dynamic_cast:1)成功;或2)抛出bad_cast异常;对指针施加:1)成功;2)返回0指针
  • 使用typeid()进行判断,合法之后再进行dynamic_cast,这样就能够避免对引用操作导致的bad_cast异常: if(typeid(rt) == typeid(rt2)) …。但是如果rt和rt2本身就是合法兼容的话,就会损失了一次typeid的操作性能

参考文献

https://github.com/zfengzhen/Blog/blob/master/article/%E3%80%8A%E6%B7%B1%E5%85%A5%E6%8E%A2%E7%B4%A2C%2B%2B%E5%AF%B9%E8%B1%A1%E6%A8%A1%E5%9E%8B%E3%80%8B%E8%AF%BB%E4%B9%A6%E7%AC%94%E8%AE%B0.md
《深入探索C++对象模型》