C++对象模型

封装的开销
C++类的成员数据直接包含在每个对象内部,而noninline成员函数虽然在类中申明,但不会出现在每个对象内部。

每个noninline的成员函数仅产生一个唯一的函数实体。而inline函数在会为每个使用者身上生成一个函数实体。

因此C++类的封装并未带来任何空间或执行期间的不良,仅仅因为virtual的原因带来一些时间上的额外负担。

(1) virtual function 机制,用以支持一个有效率的”执行期绑定”

(2) virtual base class 机制,用以实现多次出现在继承体系中的base class有一个单一而被共享的实

对象模型
C++中,共有两类数据成员static、nonstatic,三类成员函数:static、nonstatic、virtual

简单对象模型

image 
每个对象是一个时隙(Slot)序列,每个时隙指向一个成员。对象的成员按照成员在类的声明中的顺序分配时隙,每个数据成员和函数成员都分配到一个时隙。

在现实中,这种对象模型没有使用,但其中索引和时隙编号的概念延伸到了C++ 指向成员(pointer-to-member)的概念

表驱动模型

image

所有的数据成员和成员函数各存放到表中,对象的实例包含指向这两个表的指针。成员函数表是时隙序列,每个时隙代表一个成员函数。数据成员表直接存放数据成员。

这种模型也没有使用到C++模型中,但成员函数表支持了是高效运行时解析虚函数的传统实现

C++对象模型

image
nonstatic 数据成员直接在每个对象的内部存放,静态的数据成员、所有的成员函数存放到对象的外部,

虚函数通过如下方法来实现:
  每个类生成一张包含所有指向虚函数的所有指针的表(虚表)
  类的每个实例化对象包含一个指向虚表的指针(vptr),设置、删除和重置vptr都由构造函数、析构函数、拷贝构造函数来自动完成。和每个对象相关的用来支持运行时类型标识的type_info信息也存放在虚表中,通常是该表的第一个时隙。

 

与单继承相同的是所有的虚函数都包含在虚函数表中,所不同的多重继承有多个虚函数表,当子类对父类的虚函数有重写时,子类的函数覆盖父类的函数在对应的虚函数位置,当子类有新的虚函数时,这些虚函数被加在第一个虚函数表的后面。

从多重继承的内存布局,我们可以看到子类新加入的虚函数被加到了第一个基类的虚函数表,所以当dynamic_cast的时候,子类和第一个基类的地址相同,不需要移动指针,但是当dynamic_cast到其他的父类的时候,需要做相应的指针的移动。

 

虚继承,使公共的基类在子类中只有一份,我们看到虚继承在多重继承的基础上多了vbtable来存储到公共基类的偏移。

image

image

 

 

对象差异
c++支持三种programming paradigm。
procedural model程序模型(C那套)
ADT(abstract data type )抽象数据类型模型(基于对象,封装属性和方法)
OO(object-oriented )面向对象模型(封装、继承和多态)

建议在程序内部仅适用一种programming paradigm,否则会引起一系列比较复杂的问题

 

class object内存

       (1)nonstatic data members的总和大小

       (2) 加上任何由于alignment的需要而填补上去的空间

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

 

第二章 构造函数语意学

1.编译器并不是总是合成default constructor (同理copy constructor, assignment),只有在编译器需要的时候才会合成构造函数等。Nontrivial default constructor就是编译器所需要才合成出来的函数(但只满足编译器的需要)。

       (1) 带有default constructor的member class object

编译器按照这些对象在类中的申明顺序插入各个对象的默认构造函数,然后才是程序员的代码。

构造函数生成的时机是在这个构造函数真正被调用的时候,生成的是inline类型的代码,这样可以防止多个编译模块调用类CA的构造函数而生成多个构造函数。

       (2) 带有default constructor的base class

如果提供了构造函数,但唯独没有default constructor ,编译器会在现有的每个构造函数中插入基类的默认构造函数,但不会重新合成default constructor(为何?只有在编译器需要的时候才会合成构造函数)

       (3) 带有一个virtual function的class

Virtual Function要求编译器:构造virtual function table(vtbl),存放类的virtual function地址;在每个类对象中,额外的指针vptr被产生,指向vtbl。编译器对于这样的类的每个构造函数会插入相应的代码,确保在每个对象内部插入vptr。

       (4) 带有一个virtual base class的class

对于Class的每一个构造函数,编译器会安插“允许每个Virtual Base Class的执行期存取操作”,如果你没有声明任何构造函数,编译器会合成一个Default构造函数。

不在上述4个中的而又没有声明任何constructor的class,意味着它们拥有的是implicit trivial default constructors,实际上并不会被合成出来。

2. copy constructor的构建操作

(1)Default Memberwise Initialization (按位拷贝)
拷贝每个内建的或者从基类继承来的数据成员。对于类对象型的成员变量,不会直接拷贝,而是递归地调用该类的Memberwise Initialization。

bitwise copy semantics(位逐次拷贝)是指声明展现了 default copy semantics,因而编译器不需要合成拷贝构造函数出来

(2)不展现bitwise copy semantics的情况: (合成拷贝构造)

       1 当class内含一个member object而后者的class声明了一个copy constructor时

       2 当class继承自一个base class而后者存在有一个copy constructor时 (1和2需要安插Copy constructor到合成中)

       3 当class声明了一个或多个virtual functions时 (有vptr存在,切割情况时Bitwise Copy会将vptr替换)

       4 当class派生自一个继承串链,其中有一个或多个virtual base classes时 (每个从virtual基类得到的子类,必须能准确定位到基类的位置)

3、 什么时间需要程序员提供一个拷贝构造函数?

对于含有指针数据成员必须提供构造函数,那么对于其他情况是否都不需要?从语义上讲,是这样的。但是对于编译器来讲涉及到编译优化中的返回值优化【Named return Value】,这种优化是指至少可以减少一次返回值构造,但NRV的前提是程序员已经定义好拷贝构造函数,这需要根据对效率的需求来决定。

4、 书中的一个示例,为什么在类的构造函数中使用高效的memcpy()或者memset()库函数对对象进行拷贝有时候会发生错误【P73】。

原因是当类中含有vptr或者vbtl时,如果直接使用库函数,会修改这些指针。

 

5. 三种情况使用copy constructor:

1, Class X{…};

X x;

X xx = x; // 明确以一个Object作为另一个的Class Object的初值。

2, extern void foo( X x);

X xx;

Foo( xx); // 不明显的初始化操作。

3, X foo(); // 函数传回一个Class object

 

6. 成员的初始化列表member initialization list的四个理由:

       11.1 当初始化一个 reference member时

       11.2 当初始化一个const member时

       11.3 当调用一个base class的constructor,而它拥有一组参数时

       11.4 当调用一个member class的constructor,而他拥有一组参数时

member initialization list处理可能重新排序,以体现出其声明次序!

 

第三章 data 语意学

1、 C++编译器对类增加那些数据?

?        类没有定义任何数据成员【编译器会为类增加1byte,原因是为了区分类实例化的多个对象都有不同的地址】;

?        含有虚函数【编译器会为类添加一个vptr指针,指向虚函数表,virtual table】;

?        虚继承其他类【编译器会为类添加一个vbtl指针,指向虚基类】。

2、 C++编译器如何处理类的非静态数据成员?

如果我们直接访问类的非静态数据成员的地址,发现它仅仅是数据成员在类中的偏移量,如果要访问某一对象数据成员,那么该数据的地址是对象的地址加上数据成员在类中的偏移量。在P98中的表示是:&original + (&Point3d::_y -1),就是访问的是Point3d实例化的对象original中的数据成员_y,为什么会有一个减一的动作?这个的目的是为了区分指向第一个member的指针和一个指向数据成员,但没有指向任何member【如Point3d::*pM = NULL和Point3d中的第一个数据成员的偏移量不能都是0,目前在Dev-Cpp和VC6.0中都不是这样处理的,他们都是对一个数据成员的空指针减去一,变成0xFFFFFFFF,而编译器对数据成员的偏移量都不变化,直接按照他们在类中的声明顺序】。

3、 C++编译器如何处理多继承时的vptr?

如果一个类含有虚函数或者单继承有虚函数,编译器还是比较容易处理的,只要设置一个vptr就可以访问到对应的函数。但是当类多继承其他类(且都有虚函数),那么编译器必须处理指向派生类对象的指针能够指向多个基类。派生类和第一个基类共享vptr,对于第二个或者以后的基类,这个指向派生对象的指针必须加上一个偏移量来指向对应的类的vptr数据。【第二类的偏移量为sizeof(Base_Class1),后面的依次类推】。

4.Data member的布局(layout),c++ standard要求,在同一个access section(public,protected,private)中,members的排列只需满足“较晚出现的members在class object的较高的地址”,相邻member之间未必连续。

5.Data member的存取

       1 static data members,经由member selection operators(也就是‘.’运算符)对一个static data member进行存取操作只是语法上的一种便宜行事而已。Static data member解决名字冲突的方法叫name-mangling.(Page 97).

       2 nonstatic data member,以‘.’来析取与‘->’ 析取有无不同要视该member以及class对结构而定,‘.’对member data的析取总是高效的,当然排除操作‘.’的是引用对象。

       3 继承与data member,简单继承结构中存取member跟c struct并无分别,这里的重点是c++语言保证出现在derived class中的base class object有其完整原样性!

       4 加上多态后data member数据内存布局会变得复杂,但是存取变量仍旧是直接和快速的

       5 多重继承data member数据内存布局更为复杂,但是对于data member偏移的计算还是由编译器决定,因而仍旧是高效快速的 

       6 虚拟继承,derived class object的指针(或者引用)向一个virtual base class 数据时,对该数据的存取都需要额外的一次指针跳转,这是对数据存取的唯一一处需要跳转后才能存取的地方--------当然这和5都是指有进取性的编译器而言(page 128的数据显示一般编译器未必如此)。

一般,Virtual base最有效的一种运用形式:没有任何数据成员。(那还用这干嘛)

微软编译器实现:Virtual Base Table

 

第四章 function语意学

 

在C++编译器中有一个技术为了支持多态、命名空间等,叫做Name-mangling,就是把一个名字转化为一个编译器可以唯一识别的名字。

1、 C++编译器把成员函数编译成什么样子?

C++编译器把成员函数分为两类,静态和非静态成员函数。假定CExampleClass中有3个函数原型如下:

?        int NormalFun(parameter…);

?        int NormalFunConst(parameter…) const;

?        static int SNormalFun(parameter…);

C++编译器编译后会把这个函数都编译成全局函数,如下:

?        int CExampleClass13NormalFun1p(CExampleClass * const this, parameter…);

?        int CExampleClass13NormalFunConst1p(const CExampleClass * const this, parameter…);

?        int CExampleClass13 SNormalFun1p(parameter…);

2、 C++编译器如何调用成员函数?

C++编译器在调用方面也可以总结为三类调用方式,静态函数(不能为const,virtual等修饰),非静态非虚成员函数,单继承的虚函数,多继承的虚函数。

?        静态成员:直接调用,如CExampleClass::SNormalFun(parameter…)或者根据对象可以调用,编译器把这种调用直接转化为上面的CExampleClass13 SNormalFun1p(…)形式;

?        非静态非虚成员函数,必须通过对象调用,如Obj.NormalFun(…),编译器把这种形式转化为CExampleClass13NormalFun1p(&Obj, parameter…)的形式;

?        对于虚函数的调用,是通过vptr进行的,如ptr->vFun(pararmeter…),编译器将转为为:(*ptr->vptr[index])(ptr,parameter…)形式;

?        对于多继承下虚函数的调用,必须调整后面基类的偏移量。主要有两种方式:第一种形式为(*ptr->vptr[index].addr)(ptr+ptr->vptr[index].offset,parameter…)【这个offset在编译器中生成的是一个负数】,这个设计的目的增加一个结构,保证派生类同时override多个基类的虚函数都能指向同一个函数;第二种形式是使用thunk技术,vptr中对应的index存放的是简单虚函数的地址或者是指向一个相关的thunk(用于调整this指针);

?        C++编译器对函数指针的翻译:对于多继承下一个函数指针翻译,(pClass.*pfm)()被转化为pfm.index < 0 ? (*pfm.fadd)(&pClass + pmf.offset) : (*pClass.vptr[pfm.index].faddr) (&pClass + pClass.vptr[pfm.index].offset)【index小于0表示该函数不是虚函数】。

3、 C++编译器把vptr放在类的什么位置?

C++标准认为可以放在任何位置,可以在类的头部,目前VC和DEV-Cpp都是如此,为什么?如果把vptr放在尾部,其实是可以直接兼容C语言中的struct结构,但是C++是为了节省空间,便于操纵vptr没有这样做。如何节约空间,当有继承的时候,放在头部时,派生类是可以共享基类的vptr。

4、 C++编译器中的那些操作会增加代码?

?        宏展开:宏是一定会被展开的,这一定会增加代码量;

?        Inline函数:如果编译器决定把该函数Inline,那么也会增加代码,其中涉及到增加参数、局部变量和代码,特别是对于在一个表达式中又多次inline函数调用,则inline函数的局部变量会被扩展多次,然后合成一个scope;

?        Deconstructor函数:因为C++保证资源获得即初始化(RAII),所以如果在源代码中又多个出口,编译器都会在出口点增加析构变量的操作;

?        异常,C++为了异常的try,catch处理,必须增加代码;

?        Template:当模板函数或者模板类被使用的时候,C++编译器会保证实例化模板类或者模板函数。


1, virtual member functions,一个class只会有一个virtual table,table内含active virtual functions的地址,包括

       1 这个class所定义的函数实体,会改写(overriding)一个可能存在的base class virtual function 函数实体

       2 继承base class的函数实体,在derived class决定不改写virtual functions才出现的情况。

       3 一个pure_virtual_called函数实体,它既可以扮演pure virtual functions的空间保卫者,也可以做执行期异常处理函数

2, 多继承下的virtual functions,对于多重继承情况,一种可能的情况是这样的:每个base class都将有一个virtual table,this指针基于第一个base class的this。每个virtual table中有整个class的virtual functions的入口,在执行期可以根据具体的指针类型在derived object this上进行偏移。***sun编译器的处理是这样的,将多个virtual table连锁为一个,在主要表格名称上加一个offset来获取次要表格的指针。

image

3. 虚拟继承下的virtual functions,在上述的主表中加入offset to virtual base的slot。由于derived object和base object的起始部分不再像“非虚拟的单一继承”情况那样一致。两者之间的转化就需要调整this指针,至于在虚拟继承的情况下要消除thunks,一般而言被证明为一项高难度技术。---作者的建议:不要在一个virtual base class中声明 nonstatic data members。如果这么做你会距离复杂的深渊越走越近,终不可拔。

image

4. 函数的效能,多重继承和虚拟继承带来一些意想不到的额外操作,特别是对于由编译器生成的nontrival functions可能会在不知不觉中影响程序。

5. 指向member function的指针。

       1 对于nonstatic member functions的地址,如果该函数是nonvirtual,则得到它在内存中的真正的地址,不过它并不完全,它必须绑定到object的地址上才能调用。

       2 支持指向virtual member functions之指针,地址结构本来只需要一个内存地址,现在则扩展到需要一个内存地址和一个slot偏移,并使之能区分。

       3 在多重继承下,指向member functions的指针, 为了让指向member function的指针同时支持多重继承和虚拟继承,stroustrup设计了如下的结构体做为member function指针

Struct

{

       Int delta;        //表示this指针的offset值

       Int index;        //带有virtual table索引(不能和faddr一起有效)

       Union{

       Ptrtofunc faddr; //nonvirtual member function地址(不能和index一起有效)

       Int v_offset;   //存放virtual(或多重继承中的第二或后继的)base class的vptr位置

       };

};

这项技术也被Microsoft所优化(page 179)

6. Member functions之指针的效率,除了多继承和virtual继承长生this指针的偏移之外,五类指针的效率基本一致(见表page182)

7. Inline functions,inline只是请求而没有must-inline的含义,真正inline的展开上要了解参数和临时变量产生的影响。

 

Chapter 5 构造、析构和拷贝语义学习小结

1、 C++编译器是怎么样实现虚继承的构造函数?

假定Derived继承于Base1和Base2,Base1和Base2继承于Base,那么Derived的构造函数和析构函数编译器是如何生成?

Derived7DerivedV(Derived * const this, bool _most_derived)

{

       if( _most_derived != false ) this->Base::Base();

       this->Base1::Base1(false);

       this->Base2::Base2(false);

       this->vptr = _vbtl_derived; //设置vptr

       this->vptr_Base = _vbtl_Base_derived; //设置虚基类指针

       //user code

       return this;

}

Derived7DectorV(Derived * const this, bool _most_derived)

{

       this->vptr = _vbtl_derived;

       //user code

       this->Base1::Base1(false);

       this->Base2::Base2(false);

       if( _most_derived != false ) this->Base::Base();

}

注:在VC中就是这么实现的,在DEV_Cpp中的实现与此不同,它是产生两个版本的constructor,一个是设定vptr,并调用虚基类,另外一个是不调用虚基类也不设置vptr。

注:【P234中的译者加了译注,实际上是不对的,因为译者没有考虑多继承的情况。译者的理解对于是单继承的情况是正确的,对于多继承必须考虑到先设置vptr,因为可能是一个Base2的指针指向Derived对象的,如果不先设置则不能正确的调用Derived的析构函数】。

注:【后来在网上看到一篇文章《<深度探索C++对象模型>>(简体版)中的蛇足》,作者viper,http://blog.csdn.net/Viper/。作者文中描述的第二点,不过我是认为侯先生加的译注是错误的,而不是太理论化。侯先生加的译注第三点的意思应该是指正确的设置基类的vptr,其实在调用基类的析构函数中都会设置的。在http://dev.csdn.net/article/10/10874.shtm有关于这篇文章的讨论,很有意思的。】

2、 C++编译器把成员编译后结果是什么样子?

成员主要指的是静态数据、静态成员函数、非静态数据、非静态成员函数和全局数据和heap数据。

Class

Data

Static Data

Static Fun

Function

Virtual Fun

Global Data

Static Data

Fun(this,…)

Static Fun(…)

Vptr

Vbcb

Data

编译后的结果,不考虑Name-mangling

全局可见

.Data

全局

可见

类对象可见,局部和heap对象数据

3、 C++如何处理全局对象、静态对象?

对于全局变量,针对特定平台的C++编译器的一种处理方法,增加两个Sections,分别为.init和.fini,处理全局对象的构造和析构。【全局对象要求在main函数之前就存在】。.init section主要完成的是调用对象的构造函数,为了保证一个文件中的所有的全局对象都能够初始化,一般会为每个文件生成一个_sti(),该函数负责初始化该文件中所有的全局对象。全局对象的析构是在main函数结束之前完成【.fini中析构】

【全局对象要求在main函数之前完成初始化,如果在执行全局对象的构造函数时,发生异常,那么C++编译器将直接调用terminate()函数,main函数将不会执行。VC6.0和DEV-Cpp都是如此】

静态对象如果是全局的,那么初始化的过程和上面的过程是一致的。如果是静态局部对象,他的初始化是在该函数第一次执行的时候才完成初始化。C++编译器怎么知道该函数是第一次执行?C++编译器设置一个全局的指针,如果没有初始化该指针为NULL,如果初始化则该指针为静态对象的地址,当完成初始化的时候改变指针的状态就能区分。

全局静态对象的析构和全局对象的析构一样。局部静态对象的析构也需要根据指针是NULL还是对象的地址来判断是否析构。【对于局部静态对象的处理VC6.0和DEV-Cpp都是通过一个byte存放标志位来完成】。

Chapter6 执行期语义学习小结

1、 New[]的学习和讨论。

C++编译器如何完成New[]?New operator实际上完成两步操作,第一:根据对象类型分配内存【如调用free来完成】,调用构造函数初始化对象【对于New[]构造函数限定为default ctor或者带有构造函数的参数都有缺省值】。对于New来说可以一次完成,但是对于New[]来说必须借助一个新的函数来完成【原因很简单:可能存在异常,那么必须析构已经完成构造的对象,异常可能发生在任何时候】。一般会把New[]修改为什么样子然后调用?一般封装为:vec_new (pVoid ptrArray, int elemCount, int objSize, pVoid ctor, pVoid dtor)【当ptrArray不为0,表示placement operator new语义】。New[]存放的数组的长度一般在真正存储对象地址的前4个Byte中【VC和GCC都是如此】。

讨论:在网上《Const的思考一文》中的一个例子:

class A

{

public:

A(int i=0):test[2]({1,2}) {} //你认为行吗?

private:

const int test[2];

};

vc6下编译通不过,为什么呢?

观点1:编译器堆初始化列表的操作是在构造函数之内,显式调用可用代码之前,初始化的次序依据数据声明的次序。初始化时机应该没有什么问题,那么就只有是编译器对数组做了什么手脚!其实做什么手脚,我也不知道,我只好对他进行猜测:编译器搜索到test发现是一个非静态的数组,于是,为他分配内存空间,这里需要注意了,它应该是一下分配完,并非先分配test[0],然后利用初始化列表初始化,再分配test[1],这就导致数组的初始化实际上是赋值!然而,常量不允许赋值,所以无法通过。

观点2:认为上一个观点错误【我第一次看到也是上面的解释,汗先】,C++标准有一个规定,不允许无序对象在类内部初始化,数组显然是一个无序的,所以这样的初始化是错误的!对于他,只能在类的外部进行初始化,如果想让它通过,只需要声明为静态的,然后初始化。

2、 临时对象的讨论。

C++中有很一部分工作是为程序员的代码添加一些临时对象完成语义/语法上的要求。例如:使用转换函数,不同对象之间的赋值(包括一些返回值和目标对象不一致),还有一些是程序员没有明确的指定运算的结果等等。

但是C++编译器在一些情况下生成临时对象会带来一些问题【效率降低或者语义的复杂】。例如:在一个复合语句中有临时对象,C++编译器一定会生成一些判断代码,为什么?要判断何时处理对象析构;临时对象的析构一般来说是语句结束以后,有两种例外,在生成临时对象用来初始化另外的对象,那么必须等待初始化结束后临时对象才能析构,还有一些情况是C++生成的临时对象的作用域等同于目标对象的作用域啦,C++编译器生成的临时对象初始化reference对象,析构必须是在临时对象作用域和reference作用域取小者才能析构。

Chapter7 站在对象的顶端学习小结

1、 异常中try、catch是如何实现?

对于异常的处理,构造program counter-range。对于try block来说,把一个函数的try block的起始位置和结束位置保存在上述表格中,当发生异常时,当前的program counter(也就是程序执行的位置)和program counter-range进行比较,以判断出是否在try block中,如果是,就要找到对应的catch,否则当前的函数会从程序的栈(ESP,EBP等寄存器信息)中弹出,并从新设置program counter为调用的地址,然后继续上述的判断过程。

对于抛出的异常对象,编译器产生一个类型描述符,对异常的类型产生编码。编译器还必须为catch子句产生类型描述符,执行期的异常处理模块则会比较抛出的对象的类型描述符和catch子句的类型描述符,找到合适的catch或者最后到terminate()处理。

2、 为什么向下转换(downcast)中对于Pointer和reference的处理不一致?

对于downcast来说(dyanmaic_cast<type>(Object)),对于这两种的处理分别是:

?        对于Pointer来说,当转换成功时,成功的返回派生类对象的指针,当发生转化错误时候,返回0(也就是NULL);

?        对于reference来说,当转换成功时,成功的返回派生类对象,当发生转化错误时候,抛出一个bad_cast exception(为什么不是0?很简单,对于reference,0会被转换成临时对象,然后reference到这个临时对象)。

posted @ 2009-06-20 12:40  辛勤耕耘  阅读(725)  评论(0)    收藏  举报