在visual C++ 6.0中测试如下代码:
得出的结果也许会令你毫无头绪。
下面一一阐释原因:
(1)对于一个class X这样的空的class,由于需要使得这个class的两个objects得以在内存中配置独一无二的地址,故编译器会在其中安插进一个char。因而class X的大小为1。
(2)由于class Y虚拟继承于class X,而在derived class中,会包含指向visual base class subobject的指针(4 bytes),而由于需要区分这个class的不同对象,因而virtual base class X subobject的1 bytes也出现在class Y中(1 bytes),此外由于Alignment的限制,class Y必须填补3bytes(3 bytes),这样一来,class Y的大小为8。
需要注意的是,由于Empty virtual base class已经成为C++ OO设计的一个特有术语,它提供一个virtual interface,没有定义任何数据。visual C++ 6.0的编译器将一个empty virtual base class视为derived class object最开头的一部分,因而省去了其后的1 bytes,自然也不存在后面Alignment的问题,故实际的执行结果为4。
(3)不管它在class继承体系中出现了多少次,一个virtual base class subobject只会在derived class中存在一份实体。因此,class A的大小有以下几点决定:(a)被大家共享的唯一一个class X实体(1 byte);(b)Base class Y的大小,减去“因virtual base class X而配置”的大小,结果是4 bytes。Base class Z的算法亦同。(8bytes)(c)classs A的alignment数量,前述总和为9 bytes,需要填补3 bytes,结果是12 bytes。
考虑到visual C++ 6.0对empty virtual base class所做的处理,class X实体的那1 byte将被拿掉,于是额外的3 bytes填补额也不必了,故实际的执行结果为8。
不管是自身class的还是继承于virtual或nonvirtual base class的nonstatic data members,其都是直接存放在每个class object之中的。至于static data members,则被放置在程序的一个global data segment中,不会影响个别的class object的大小,并永远只存在一份实体。
***Data Member的绑定***
早期C++的两种防御性程序设计风格的由来:
(1)把所有的data members放在class声明起头处,以确保正确的绑定:
这个风格是为了防止以下现象的发生:
由于member function的argument list中的名称会在它们第一次遭遇时被适当地决议完成,因而,对于上述程序片段,length的类型在两个member function中都被决议为global typedef,当后续再有length的nested typedef声明出现时,C++ Standard就把稍早的绑定标示为非法。
(2)把所有的inline functions,不管大小都放在class声明之外:
这个风格的大意就是“一个inline函数实体,在整个class声明未被完全看见之前,是不会被评估求值的”,即便用户将inline函数也在class声明中,对该member function的分析也会到整个class声明都出现了才开始。
***Data Member的布局***
同一个access section中的nonstatic data member在class object中的排列顺序和其被声明的顺序一致,而多个access sections中的data members可以自由排列。(虽然当前没有任何编译器会这么做)
编译器还可能会合成一些内部使用的data members(例如vptr,编译器会把它安插在每一个“内含virtual function之class”的object内),以支持整个对象模型。
***Data Member的存取***
(1)Static Data Members
需要注意以下几点:
(a)每一个static data member只有一个实体,存放在程序的data segment之中,每次程序取用static member,就会被内部转化为对该唯一的extern实体的直接参考操作。
(b)若取一个static data member的地址,会得到一个指向其数据类型的指针,而不是一个指向其class member的指针,因为static member并不内含在一个class object之中。
&Point3d::chunkSize会获得类型如下的内存地址:const int*
(c)如果有两个classes,每一个都声明了一个static member freeList,那么编译器会采用name-mangling对每一个static data member编码,以获得一个独一无二的程序识别代码。
(2)Nonstatic Data Members
以两种方法存取x坐标,像这样:
“从origin存取”和“从pt存取”有什么重大的差异吗?
答案是“当Point3d是一个derived class,而在其继承结构中有一个virtual base class,并且并存取的member(如本例的x)是一个从该virtual base class继承而来的member时,就会有重大的差异”。这时候我们不能够说pt必然指向哪一种 class type(因此我们也就不知道编译期间这个member真正的offset位置),所以这个存取操作必须延迟到执行期,经由一个额外的简洁导引,才能够解决。但如果使用origin,就不会有这些问题,其类型无疑是Point3d class,而即使它继承自virtual base class,members的offset位置也在编译时期就固定了。
***继承与Data Member***
(1)只要继承不要多态(Inheritance without Polymorphism)
让我们从一个具体的class开始:
每一个Concrete class object的大小都是8 bytes,细分如下:(a)val占用4 bytes;(b)c1、c2、c3各占用1 byte;(c)alignment需要1 byte。
现在假设,经过某些分析之后,我们决定采用一个更逻辑的表达方式,把Concrete分裂为三层结构:
现在Concrete3 object的大小为16 bytes,细分如下:(a)Concrete1内含两个members:val和bit1,加起来是5 bytes,再填补3 bytes,故一个Concrete1 object实际用掉8 bytes;(b)需要注意的是,Concrete2的bit2实际上是被放在填补空间之后的,于是一个Concrete2 object的大小变成12 bytes;(c)依次类推,一个Concrete3 object的大小为16 bytes。
为什么不采用那样的布局(int占用4 bytes,bit1、bit2、bit3各占用1 byte,填补1 byte)?
下面举一个简单的例子:
pc1_1实际指向一个Concrete2 object,而复制内容限定在其Concrete subobject,如果将derived class members和Concrete1 subobject捆绑在一起,去除填补空间,上述语意就无法保留了。在pc1_1将其Concrete1 subobject的内容复制给pc1_2时,同时将其bit2的值也复制给了pc1_1。
(2)加上多态(Adding Polymorphism)
为了以多态的方式处理2d或3d坐标点,我们需要在继承关系中提供virtual function接口。改动过的class声明如下:
virtual function给Point2d带来的额外负担:(a)导入一个和Point2d有关的virtual table,用来存放它声明的每一个virtual function的地址;(b)在每一个class object中导入一个vptr;(c)加强constructor和destructor,使它们能设置和抹消vptr。
自此,你就可以把operator+=运用到一个Point3d对象和一个Point2d对象身上了。
(3)多重继承(Multiple Inheritance)
请看以下的多重继承关系:
对一个多重继承对象,将其地址指定给“第一个base class的指针”,情况将和单一继承时相同,因为二者都指向相同的起始地址,需付出的成本只有地址的指定操作而已。至于第二个或后继的base class的地址指定操作,则需要将地址修改过,加上(或减去,如果downcast的话)介于中间的base class subobjects的大小。
(4)虚拟继承(Virtual Inheritance)
class如果内含一个或多个virtual base class subobject,将被分隔为两部分:一个不变局部和一个共享局部。不变局部中的数据,不管后继如何衍化,总是拥有固定的offset,所以这一部分数据可以被直接存取。至于共享局部,所表现的就是virtual base class subobject。这一部分的数据,其位置会因为每次的派生操作而变化,所以它们只可以被间接存取。
以下均以下述程序片段为例:
间接存取主要有以下三种主流策略:
(a)在每一个derived class object中安插一些指针,每个指针指向一个virtual base class。要存取继承得来的virtual base class members,可以使用相关指针间接完成。
由于虚拟继承串链得加长,导致间接存取层次的增加。然而理想上我们希望有固定的存取时间,不因为虚拟衍化的深度而改变。具体的做法是经由拷贝操作取得所有的nested virtual base class指针,放到derived class object之中。
(b)在(a)的基础上,为了解决每一个对象必须针对每一个virtual base class背负一个额外的指针的问题,Micorsoft编译器引入所谓的virtual base class table。
每一个class object如果有一个或多个virtual base classes,就会由编译器安插一个指针,指向virtual base class table。这样一来,就可以保证class object有固定的负担,不因为其virtual base classes的数目而有所变化。
(c)在(a)的基础上,同样为了解决(b)中面临的问题,Foundation项目采取的做法是在virtual function table中放置virtual base class的offset。
新近的Sun编译器采取这样的索引方法,若为正值,就索引到virtual functions,若为负值,则索引到virtual base class offsets。
小结:一般而言,virtual base class最有效的一种运用方式就是:一个抽象的virtual base class,没有任何data members。
***对象成员的效率***
如果没有把优化开关打开就很难猜测一个程序的效率表现,因为程序代码潜在性地受到专家所谓的与特定编译器有关的奇行怪癖。由于members被连续储存于derived class object中,并且其offset在编译时期就已知了,故单一继承不会影响效率。对于多重继承,这一点应该也是相同的。虚拟继承的效率令人失望。
***指向Data Members的指针***
如果你去取class中某个data member的地址时,得到的都是data member在class object中的实际偏移量加1。为什么要这么做呢?主要是为了区分一个“没有指向任何data member”的指针和一个指向“的第一个data member”的指针。
考虑这样的例子:
为了区分p1和p2每一个真正的member offset值都被加上1。因此,无论编译器或使用者都必须记住,在真正使用该值以指出一个member之前,请先减掉1。
正确区分& Point3d::z和&origin.z:取一个nonstatic data member的地址将会得到它在class中的offset,取一个绑定于真正class object身上的data member的地址将会得到该member在内存中的真正地址。
在多重继承之下,若要将第二个(或后继)base class的指针和一个与derived class object绑定之member结合起来那么将会因为需要加入offset值而变得相当复杂。
也就是说pd->*dmp将存取到Base1::val1,为解决这个问题,当bmp被作为func1()的第一个参数时,它的值必须因介入的Base1 class的大小而调整:
***非静态成员函数(Nonstatic Member Functions)***
C++的设计准则之一就是:nonstatic member function至少必须和一般的nonmember function有相同的效率。也就是说,如果我们要在以下两个函数之间作选择:
那么选择member function不应该带来什么额外负担。因为编译器内部已将“member函数实体”转化为对等的“nonmember函数实体”。下面是magnitude()的一个nonmember定义:
现在,对该函数的每一个调用操作也都必须转换:
对于class中的memeber,只需在member的名称中加上class名称,即可形成独一无二的命名。但由于member function可以被重载化,所以需要更广泛的mangling手法,以提供绝对独一无二的名称。其中一种做法就是将它们的参数链表中各参数的类型也编码进去。
上述的mangling手法可在链接时期检查出任何不正确的调用操作,但由于编码时未考虑返回类型,故如果返回类型声明错误,就无法检查出来。
***虚拟成员函数(Virtual Member Functions)***
对于那些不支持多态的对象,经由一个class object调用一个virtual function,这种操作应该总是被编译器像对待一般的nonstatic member function一样地加以决议:
***静态成员函数(Static Member Functions)***
在引入static member functions之前,C++要求所有的member functions都必须经由该class的object来调用。而实际上,如果没有任何一个nonstatic data members被直接存取,事实上就没有必要通过一个class object来调用一个member function。
这样一来便产生了一个矛盾:一方面,将static data member声明为nonpublic是一种好的习惯,但这也要求其必须提供一个或多个member functions来存取该member;另一方面,虽然你可以不靠class object来存取一个static member,但其存取函数却得绑定于class object之上。
static member functions正是在这种情形下应运而生的。
编译器的开发者针对static member functions,分别从编译层面和语言层面对其进行了支持:
(1)编译层面:当class设计者希望支持“没有class object存在”的情况时,可把0强制转型为一个class指针,因而提供出一个this指针实体:
(2)语言层面:static member function的最大特点是没有this指针,如果取一个static member function的地址,获得的将是其在内存中的位置,其地址类型并不是一个“指向class member function的指针”,而是一个“nonmember函数指针”:
static member function经常被用作回调(callback)函数。
***虚拟成员函数(Virtual Member Functions)***
对于像ptr->z()的调用操作将需要ptr在执行期的某些相关信息,为了使得其能在执行期顺利高效地找到并调用z()的适当实体,我们考虑往对象中添加一些额外信息。
(1)一个字符串或数字,表示class的类型;
(2)一个指针,指向某表格,表格中带有程序的virtual functions的执行期地址;
在C++中,virtual functions可在编译时期获知,由于程序执行时,表格的大小和内容都不会改变,所以该表格的建构和存取皆可由编译器完全掌握,不需要执行期的任何介入。
(3)为了找到表格,每一个class object被安插上一个由编译器内部产生的指针,指向该表格;
(4)为了找到函数地址,每一个virtual function被指派一个表格索引值。
一个class只会有一个virtual table,其中内含其对应的class object中所有active virtual functions函数实体的地址,具体包括:
(a)这个class所定义的函数实体
它会改写一个可能存在的base class virtual function函数实体。若base class中不存在相应的函数,则会在derived class的virtual table增加相应的slot。
(b)继承自base class的函数实体
这是在derived class决定不改写virtual function时才会出现的情况。具体来说,base class中的函数实体的地址会被拷贝到derived class的virtual table相对应的slot之中。
(c)pure_virtual_called函数实体
对于这样的式子:
运用了上述手法后,虽然我不知道哪一个z()函数实体会被调用,但却知道每一个z()函数都被放在slot 4(这里假设base class中z()是第四个声明的virtual function)。
***多重继承下的Virtual Functions***
在多重继承中支持virtual functions,其复杂度围绕在第二个及后继的base classes身上,以及“必须在执行期调整this指针”这一点。
多重继承到来的问题:
(1)经由指向“第二或后继之base class”的指针(或reference)来调用derived class virtual function,该调用操作连带的“必要的this指针调整”操作,必须在执行期完成;
以下面的继承体系为例:
对于下面一行: 总而言之,inline函数中的局部变量,再加上有副作用的参数,可能会导致大量临时性对象的产生。特别是如果它以单一表达式被扩展多次的话。新的Derived对象的地址必须调整,以指向其Base2 subobject。
会被内部转化为:
如果没有这样的调整,指针的任何“非多态运用”都将失败:
当程序员要删除pbase2所指的对象时:
指针必须被再一次调整,以求再一次指向Derived对象的起始处。然而上述的offset加法却不能够在编译时期直接设定,因为pbase2所指的真正对象只有在执行期才能确定。
自此,我们明白了在多重继承下所面临的独特问题:经由指向“第二或后继之base class”的指针(或reference)来调用derived class virtual function,该调用操作所连带的“必要的this指针调整”操作,必须在执行期完成。有两种方法来解决这个问题:
(a)将virtual table加大,每一个virtual table slot不再只是一个指针,而是一个聚合体,内含可能的offset以及地址。这样一来,virtual function的调用操作发生改变:
这个做法的缺点是,它相当于连带处罚了所有的virtual function调用操作,不管它们是否需要offset的调整。
(b)利用所谓的thunk(一小段assembly码),其做了以下两方面工作:(1)以适当的offset值调整this指针;(2)跳到virtual function去。
Thunk技术允许virtual table slot继续内含一个简单的指针,slot中的地址可以直接指向virtual function,也可以指向一个相关的thunk。于是,对于那些不需要调整this指针的virtual function而言,也就不需要承载效率上的额外负担。
(2)由于两种不同的可能:(a)经由derived class(或第一个base class)调用;(b)经由第二个(或其后继)base class调用,同一函数在virtual table中可能需要多笔对应的slot;
虽然两个delete操作导致相同的Derived destructor,但它们需要两个不同的virtual table slots:
(a)pbase1不需要调整this指针,其virtual table slot需放置真正的destructor地址
(b)pbase2需要调整this指针,其virtual table slot需要相关的thunk地址
具体的解决方法是:
在多重继承下,一个derived class内含n-1个额外的virtual tables,n表示其上一层base classes的数目。按此手法,Derived将内含以下两个tables:vtbl_Derived和vtbl_Base2_Derived。
(3)允许一个virtual function的返回值类型有所变化,可能是base type,可能是publicly derived type,这一点可以通过Derived::clone()函数实体来说明。
当运行pb1->clone()时,pb1会被调整指向Derived对象的起始地址,于是clone()的Derived版会被调用:它会传回一个指针,指向一个新的Derived对象;该对象的地址在被指定给pb2之前,必须先经过调整,以指向Base2 subobject。
当函数被认为“足够小”的时候,Sun编译器会提供一个所谓的“split functions”技术:以相同算法产生出两个函数,其中第二个在返回之前,为指针加上必要的offset,于是无论通过Base1指针或Derived指针调用函数,都不需要调整返回值;而通过Base2指针所调用的,是另一个函数。
***虚拟继承下的Virtual Functions***
其内部机制实在太过诡异迷离,故在此略过。唯一的建议是:不要在一个virtual base class中声明nonstatic data members。
***函数的效能***
由于nonmember、static member和nonstatic member函数都被转化为完全相同的形式,故三者的效率安全相同。virtual member的效率明显低于前三者,其原因有两个方面:(a)构造函数中对vptr的设定操作;(b)偏移差值模型。
***指向Member Function的指针***
取一个nonstatic member function的地址,如果该函数是nonvirtual,则得到的结果是它在内存中真正的地址。
我们可以这样定义并初始化该指针:
想调用它,可以这么做:
“指向Virtual Member Functions”之指针将会带来新的问题,请注意下面的程序片段:
其中,pmf是一个指向member function的指针,被设值为Point::z()(一个virtual function)的地址,ptr则被指向一个Point3d对象。
如果我们直接经由ptr调用z():
但如果我们经由pmf间接调用z():
也就是说,虚拟机制仍然能够在使用“指向member function之指针”的情况下运行,但问题是如何实现呢?
对一个nonstatic member function取其地址,将获得该函数在内存中的地址;而对一个virtual member function取其地址,所能获得的只是virtual function在其相关之virtual table中的索引值。因此通过pmf来调用z(),会被内部转化为以下形式:
但是我们如何来判断传给pmf的函数指针指向的是内存地址还是virtual table中的索引值呢?例如以下两个函数都可指定给pmf:
cfront 2.0是通过判断该值的大小进行判断的(这种实现技巧必须假设继承体系中最多只有128个virtual functions)。
为了让指向member functions的指针也能够支持多重继承和虚拟继承,Stroustrup设计了下面一个结构体:
其中,index表示virtual table索引,faddr表示nonvirtual member function地址(当index不指向virtual table时,被设为-1)。
在该模型之下,以下调用操作会被转化为:
对于如下的函数调用:
会被转化成:
***Inline Functions***
在inline扩展期间,每一个形式参数都会被对应的实际参数取代。但是需要注意的是,这种取代并不是简单的一一取代(因为这将导致对于实际参数的多次求值操作),而通常都需要引入临时性对象。换句话说,如果实际参数是一个常量表达式,我们可以在替换之前先完成其求值操作;后继的inline替换,就可以把常量直接绑上去。
举个例子,假设我们有以下简单的inline函数:
对于以下三个inline函数调用:
会分别被扩展为:
inline函数中的局部变量,也会导致大量临时性对象的产生。
则以下表达式:
将被转化为: