您的位置:首页 > 编程语言 > C语言/C++

学习《深入理解C++对象模型》小结

2007-08-18 14:38 691 查看
Chapter 2 构造函数语义学习小结

1、 C++编译器何时会为C++中的类生成缺省的构造函数(Default constructor)?

如果程序员没有定义构造函数,编译器会在下面四种情况为类生成缺省的构造函数:

? 类中聚合的元素有构造函数(可以是程序员自定义的或者编译器生成的);

? 类的基类有构造函数(可以是程序员自定义的或者编译器生成的);

? 类中有虚函数;

? 类虚继承其他类。

对于不符合以上四种情况,C++编译器并不会生成一个构造函数,那类实例化的对象怎么初始化?实际上编译器都是直接分配内存而已,并没有初始化对象。

2、 C++编译器何时会为C++中的类生成缺省的拷贝构造函数(Copy constructor)?

拷贝构造函数的合成也使用于上述四种情况。对于符合上述四种情况拷贝构造函数执行按成员拷贝(memberwise copy),对于不符合上述四种情况执行按位拷贝(bitwise copy)。【这是个人区分memberwise和bitwise,而书中的意思好像是都是memberwise,在memberwise中分为是否进行bitwise】。那些bitwise拷贝带来的后果是什么?对于一般的类型int、double等都不会有问题,但是对于指针类型就会发生两个对象指向同一个指针变量【也就是所谓的浅拷贝】。从语义角度来看,当含有指针数据成员必须提供构造函数。

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

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

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

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

5、 对于NRV的讨论,到底是否必须存在显示的Copy ctor才能实行NRV?

问:在67页,最下面两行:这个程式的第一个版本不能实施NRV最佳化,因为test class缺少一个copy constructor。但是在66页「在编译器层面做最佳化」那一段中所列的码显示,当编译器把xx以__result取代,变成__result.X::X();即default constructor被唤起。唤起default constructor是可以理解的,可是编译器转换后的码并没有使用到copy constructor呀,为什么67页最后两行却说缺少一个copy constructor,就不能实施这个最佳化了呢?

观点1:如同63页与64页「返回值的初始化」这一段,编译器可能将63页下面的X bar()函数定义转换成64页的虚拟码,其中有一行__result.X::X(xx); 这会使用到copy constructor。 转换成64页的码后,65页与66页分述了两种后续可能出现的最佳化动作,其中一种即是66页的编译器层面做最佳化。如此,虽然66页最佳化后的码看起来并不使用到copy constructor,但是这些码是根据像64页那种样子的码最佳化而来的,而若没有copy constructor,根本无法转换成64页那种虚拟码,因为其中有一个呼叫copy constructor的动作。所以,虽然66页经过编译器最佳化的结果省去了__result.X::X(xx);这个copy constructor的呼唤动作(因为根本没有xx了),但若没有明白提供一个copy constructor,却无法让编译器进行这样的最佳化。【第5章,205页最下面一段话:一般而言如果你的设计之中,有许多函式都需要以传值(by value),传回一个local class object....那么提供一个copy constructor,就比较合理--甚至即使default memberwise语意已经足够。它的出现会触发NRV最佳化。然而,就像我在前一个例子中所展现的那样,NRV最佳化后将不再需要唤起copy constructor,因为运算结果已经被直接计算于「将被传回的object」体内了。」】

观点2:Lippman在p67最后一行所言『这个程式的第一个版本不能实施NRV最佳化,因为test class缺少一个copy constructor』,此语错误。如果程式没有explicit copy constructor,编译器会自动为我们做出来(如为trivial,则直接bitwise copy;如为nontrivial,则由编译器为我们合成出一个copy constructor)。因此,有没有explicit copy constructor并不影响 NRV 最佳化的实施。NRV 最佳化主要是由编译器option来决定要不要实施。做了一些实验,判断VC和GCC都没有做到NRV最佳化,而其不做的理由不是因为技术上的困难,是为了避免造成「user defined copy constructor之副作用失效」-所谓副作用是指,例如「在user defined copy constructor中做一个cout出」之类这种「与memberwise copy 无关」的动作。

Chapter 3 成员数据语义学习小结

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),后面的依次类推】。

Chapter 4 成员函数语义学习小结

在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++编译器会保证实例化模板类或者模板函数。

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到这个临时对象)。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: