读【深度探索C++对象模型】【上】

【书籍信息】

深度探索C++对象模型【Inside The C++ Object Model】 侯捷【Lippman】 华中科技大学出版社:2001

 

【总体概况】

本书主要是描述编译器(和链接器)对C++对象模型的处理。详述了面向对象中继承、封装、多态等等重要内容在编译阶段的处理。分析了各种实现的优缺点,并且展示了如何使用“分析-实现-分析...”(个人定义)这种以实践而不是主观臆断为基础的研究手段。很多深入而细致的分析是我们从别的书中看不到的(也可能是我太孤陋寡闻了),有些具体的内容可能会有些过时(毕竟这本书写了有一阵了),但是它包含的架构设计方法和分析问题的手段会令人有终身受益的感觉。

本书的作者和译者都可谓是大牌了,写作人有足够的经验,译者有足够的细心和能力,但好像这本书的影响力不是很大。也许是大多数人觉得此书描述的内容在平时编程中无法用到。但个人感觉本书描述的内容和思想,为我们写出健壮和高效的代码打下了基础。建议每个有C++面向对象编程经验(甚至是别的语言开发)的人阅读一下。

 

【引言】

 我打算从基本的C++对象模型开始,首先介绍C++对象模型对面向对象中很多新的元素的处理。比如成员函数、数据、构造和析构函数等等。为了有逐步深入的效果,这部分内容通常不涉及虚继承。关于虚继承的内容,会放到本文的最后来说。

接下来会写一些在执行期的一些特点,最后是异常、RTTI等新增内容。基本的脉络和原书一样,只是在细节上有所调整。希望能帮助大家快速的了解一下C++对象模型的一些相关内容,如果想细致了解,强烈建议阅读此书。

笔记中加入了很多个人的理解,如果有错误,请指正。谢谢。

 

【C++对象模型】

所有的所谓的面向对象,都是在程序语言一级的。对于编译器而言,它会将所有面向对象的内容处理成和面向过程的程序一样。

考虑下面这个类,猜想一下编译器是如何把这些面向对象的内容翻译过来的。

 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;
};

书中,描述了三种方案。一是简单对象模型,它会在非配的空间上依次保存对象的所有数据和函数入口地址;二是表格驱动模式,对象中保留数据和指向一个表格的指针,表格中存放函数入口信息。第一种方法逻辑简单,但时间和空间(个人感觉特别是空间,有100个同类的对象就需要重复保存100次所有函数地址)复杂度都比较高;第二种方法空间利用和灵活的都很好,但访问函数的时间开销增加(加了一层)。所以在很多编译器中,都采用了第三种折衷的方案,就是所谓的C++对象模型。

如下图所示,编译器区别对待数据,普通函数,虚函数,静态函数等元素,在时间、空间、灵活性中寻求一个平衡点。书中所有的后续内容都是基于该模型的,而我们有必要了解编译器在建立这个模型中做的很多事情,有利于我们写出更好的代码。

 

【数据处理】

编译器在处理C++对象中的数据时,考虑了与C的兼容性和存取速度。通常一个非静态数据被放在对象空间的开始,而静态数据被放置在一个全局数据段中,并保证在调用之前被初始化。

非静态数据按照权限集中放置,并保证较晚出现的放置在较高的地址。数据与数据之间通常是一个挨一个放置,但出于齐位的需求,在数据中间可能会被插入一些补空的数据。整个对象的大小基本等价与数据大小的总和(和齐位需求的数据)和为了保证虚函数机制引入的指针。

在一般的单继承体系中(即不考虑虚继承,下同),子对象的数据是挨着父对象数据存放的(在高址),而且父对象数据的存放不会被子对象影响(连用于补齐的数据也保持原样)。上面描述的是不引入虚函数的前提下,如果引入,虚表指针通常放置在对象数据的前端(低址)或尾端(高址)。两种方式各有其好处(放在前端有利于对象的向上转型,而放在尾端对数据地址的处理比较简单),其实现依具体编译器而定。

依照上述的存放方式,考虑如下一段代码:

class A
{
public:
 int a;
    double b;
};

class B : public A
{
public:
    float t;
}

A *a = new B();

它在堆中建立了一个B类型对象,相当于依次存放a, b, t三个数据在堆中。a指针指向B类型对象的首地址,但只能合法操作属于它的a和b数据。可以看到这种存放机制,可以很方便的实现向上的转型。(依照这个例子想象一下引入虚函数,虚标指针放置在前后会产生的不同问题。)

而如果引入多继承,问题还是类似与单继承,只是在基类数据的存放上,需要保证某个编译器知道的顺序。这样也能可以很方便的实现向上转型。 比如:

class A
{
public:
    int a;
}

class B
{
public:
    double b;
}

class C :
    public A, public B
{
public:
    float c;
}

C *c = new C();
A *a = c;
B *b = c;

 

同样的,堆中放置一个C类型对象,依次包括a, b, c三个数据。在转型成为A类型指针时,指针指向开始地址(即int a的位置),可以操作a。在转型成为B类型指针是,指针指向double b的位置,可以操作b。

从上面的叙述可以看出:其实,封装、继承(普通继承)等面向对象机制的引入,只是增加了编译器的负担,并不会影响到数据的存取的效率。从书中实际测试来看,情况也确实如此,对对象中的数据操作和对非对象中的数据操作,速度基本一致。

 

【函数处理】
C++面向对象模型中的函数可以视为有三类组成:一是非静态成员函数;二是静态成员函数;三是虚函数。

考虑类型A中有这样一个非静态成员函数void Test()。这个函数经过编译器处理后,就会变成如下的格式:extern Test__xxAxx(register A* const this)。也就是编译器做了两件事。一是为函数添加了一个参数,该参数为该类型的一个常指针,这样在函数中就可以使用该类型对象的数据了;二是为函数取了个独一无二的名字,该技术被称为Name Mangling。简单的可以认为是一个数据方程。输入是函数名、类型名等相关因素,输出了一个独一无二的函数名。这样当我们调用obj.Test()的时候,就相当于在写Test_xxAxx(&obj),所谓面向对象的内容被抹干净了。

在非静态成员函数中,还有一种情况就是内联函数。众所周知,内联函数不算是函数,它会在所有调用该内联函数处展开该函数的代码(而不是一个调用)。通常我们会把少量代码的函数设置为inline。编译器可以忽视你的请求(书上说会变成一个static的函数,不解ing...),同样编译器也可能把一个不是inline的函数提升为inline。而inline带来的好处和坏处一样明显。好处就是效率的提升,坏处就是代码的膨胀和临时变量的堆积。所以使用时要仔细考虑,不要泛滥使用内联函数(不要太依靠编译器了)。

静态成员函数的转换更为简单。只是被做了一个简单的name mangling。因为静态函数并不能调用类型中对象的非静态数据,所以它不需要传入一个对象指针。因此,静态函数可以视为类拥有的东西,它只能够操作属于类的数据(静态数据)。

虚函数的转换从前面的那副对象模型图中可以略见一斑。在每一个有虚函数的对象中(不管是继承至基类还是自己定义的)都会被安插一个被称为vptr的指针,该指针指向一个被称为vtbl的表格。vtbl被分成若干个等大的slot,第一个slot放置了关于对象类型的信息,其他每一个slot中都放置了一个虚函数的入口地址。地址的具体函数可能不同,但继承树中同一个(指名字和参数都相同)的虚函数会被固定放置在某个slot中。也就是如果前面所述的A中那个函数Test()是虚函数,obj->Test()的调用可能就被转换成(*obj->vptr[1])(obj)。不难看出,这相当于一个函数指针的调用。由此可知,虚函数之所以可以表现出执行期的变化,是因为它有两个固定的内容。一是虽然对象类型不同,但它们肯定都有一个虚指针指向虚表;二是虽然具体的函数地址不知道,但是它在虚表中的位置是固定的。

这里所述的虚函数的实现机制,只符合单继承模型,在多继承和虚继承的情况下,会有很大的不同。

最后来看下效率。书中比较了友元(相当于普通调用)、内联、非静态成员、静态成员、单继承虚函数、多继承虚函数和虚拟继承虚函数的效率。抛开多继承不说,虚函数的引入,确实带来了少量的性能损失(数据显示10%左右),而除内联外其他调用方式效率一致。内联的效率出奇的好,高出了近百倍。事后作者发现,这种提升不只是内联本身带来的,而是伴随着内联的for循环代码调整带来的(函数的调用就很难判断了),可以看出,正确使用内联可以出乎意料的提升效率(很多编译器的优化手段都可以使用了)。

posted on 2007-05-09 01:10  duguguiyu  阅读(2069)  评论(0编辑  收藏  举报

导航