《深入探索C++对象模型》第三章 Data语意学(The Semantics of Data)

目录

一、Data Member的绑定(The Binding of a Data Member)

二、Data Member的布局(Data Member Layout)

三、Data Member的存取

四、“继承”与Data Member

五、对象成员的效率(Object Member Efficiency)

六、指向Data Members的指针(Pointer to Data Members)

 


先从一个例子开始,假如给定以下class的定义及继承关系:

class X { };
class Y : public virtual X { };
class Z : public virtual X { };
class A : public Y, public Z { };

则分别对class X,class Y,class Z和class A求sizeof()的值是多少?

答案可能不那么直观,因为乍一看这几个类都没有任何成员,所以应该都是空的,但实际上,在作者的编译器上sizeof()的结果分别是:

sizeof(X) == 1;
sizeof(Y) == 8;
sizeof(Z) == 8;
sizeof(A) == 12;

(而侯捷老师在VC++5.0上测试的结果分别为1,4,4,8,这其实是因为新一点的编译器对empty virtual base class做了优化处理)

class X的大小是1的原因是:编译器会在empty class的对象里安插进一个字节的大小,目的是保证这个class的两个对象得以在内存中配置独一无二的地址。

class Y和Z的大小受到三个因素的限制:(1)语言本身所造成的额外负担。虚继承机制会在派生类中安插一个指针,这个指针要么指向virtual base class subobject,要么指向一个表格,表格中放的要么是virtual base class subobject的地址,要么是它的偏移位置。(2)编译器对于特殊情况的优化处理。如果没有对empty virtual base class做优化处理,也会在派生类的固定部分的尾端插入一个多余的字节,因为派生类也是空的;如果对empty virtual base class做了优化处理,这一个多余的字节就不需要插入,原因是一个empty virtual base class 被视为derived class object的开头一部分,所以derived class object相当于拥有了成员,也就不需要那额外的一个字节了,而empty virtual base class的大小又不会算进derived class object里去,所以derived class object的大小就是一个指针的大小。(3)内存对齐的需要。

class A的大小的分析与Y、Z类似。如果没有对empty virtual base class做特殊处理,class A中将包括:被大家共享的唯一的一个class X的实例,大小为1;class Y和class Z去掉class X后的大小,各为4,加起来是9,4字节对齐以后答案就是12。如果对empty virtual base class做了特殊处理,那么class X的1个字节会被拿掉,连带着内存对齐补齐的3字节也被拿掉,答案为8个字节。

一、Data Member的绑定(The Binding of a Data Member)

看下面的代码:

extern float x;

class Point3d
{
public:
    Point3d( float, float, float );
    //问题:被传回和设定的x是哪一个x呢?
    float X() const { return x; }
    void X( float new_x ) const { x = new_x; }
    //......
private:
    float x, y, z;
};

按现在的C++标准,被传回和设定的x就是class中定义的x,而不是外部的x。但是在C++的早期版本,被传回和设定的确实外部的x,这是因为当编译器处理到内联函数时,由于编译器还没看到我们后面写的private定义,就将x解释为了上面定义的x。当然现在这一点已经改了,现在的原则是:一个内联函数在整个class声明没被完全看见之前是不会被评估求值(evaluate)的。

但是内联函数的这一点,对于成员函数的参数列表却不成立,参数列表中的名称还是会在它们第一次遭遇时被适当地决议完成,比如:

typedef int length;

class Point3d
{
public:
    // length 被决议为global,_val被决议为Point3d::_val
    void mumble( length val ) { _val = val; }
    length mumble() { return _val; }
    //......
private:
    typedef float length;
    length _val;
};

所以所有的typedef需要放在class的起始处。

二、Data Member的布局(Data Member Layout)

1. C++标准要求,在同一个access section中,members的排列只需要符合较晚出现的members在class object中有较高的地址这一条件即可,也就是说并不一定非得连续排列,中间也可能出现内存补齐的一些字节,还可能出现编译器安插进object中的一些东西,比如说vptr,虽然编译器一般把vptr放到最前面或者最后面,但是标准并不反对把它放中间。

2. access sections的多寡不会带来额外负担,也就是说写很多private和写一个private是完全一样的。

三、Data Member的存取

1. static members

static data members被编译器提出class之外,并被视为一个global变量,但只在class生命范围之内可见。每一个static data member只有一个实例,存放在程序的data segment之中。

从指令执行的观点来看,这是C++中通过一个指针和通过一个对象来存取member,结论完全相同的唯一一种情况,这是因为member实际上根本不在object之内。

如果取一个static data member的地址,会得到一个指向其数据类型的指针,而不是指向一个class member的指针。(假如x是class X的非静态成员,那么&X::x得到的其实是x的偏移地址,而不是内存中的地址)

2. non-static data members

非静态成员直接放在class object之内,所以除非经由显式的或者隐式的class object,无法直接存储它们。显式的class object自不必说,下面举一个隐式的例子,隐式的一般都是指隐式的this指针,比如:

Point3d
Point3d::translate( const Point3d& pt)
{
    x += pt.x;
    y += pt.y;
    z += pt.z;
}

其实相当于:

//member function的内部转化
Point3d
Point3d::translate ( Point3d* const this, const Point3d& pt )
{
    this->x += pt.x;
    this->y += pt.y;
    this->z += pt.z;
}

想要对一个非静态成员进行存取操作,编译器需要把class object的起始地址加上data member的偏移位置。

3. 通过对象存取和通过指针存取的差别

Point3d origin, *pt = &origin;

origin.x = 1;
pt->x = 1;

当Point3d是一个派生类,并且其继承体系中有一个虚基类,并且被存取的member是一个从虚基类中继承而来的成员时,两种情况就会有重大的差异。因为这时候我们不能确定pt必然指向哪一种class type,因此也就不知道编译时期这个member真正的offset位置,所以这个存取操作必须延迟到执行期,经由一个额外的间接导引,才能够解决。

四、“继承”与Data Member

本部分包括:单一继承且没有虚函数、单一继承有虚函数、多重继承、虚拟继承四种情况。

1. 单一继承且没有虚函数

一般而言,具体继承并不会增加空间或者存取时间上的额外负担。假如我们让Point3d继承自Point2d,那么和分别单独定义Point2d和Point3d相比,可能有以下两个方面的缺点:

(1) 假如某些函数没有定义为inline,则可能产生基类的subobject额外的函数调用开销。比如派生类的构造函数需要调用基类的构造函数,假如基类构造函数不是inline的,这样和直接定义派生类相比,就多了一层函数调用操作。

(2) 把一个class分解为多层,可能膨胀所需要的的空间。这是因为:C++保证,出现在派生类中的基类subobject有其完整原样性。也就是即使基类中存在内存补齐的额外空间,这些空间也会在派生类中完整地体现出来。这是为了保证C++的另一种行为的正确性:基类的指针可以指向派生类的对象,所以当一个这样的指针指向的对象赋值给另一个基类对象时,会执行派生类对象中的基类subobject的逐字节拷贝动作,所以派生类中的基类成分必须完整,否则会产生意料之外的行为。

2. 单一继承有虚函数

令Point3d继承自Point2d,并且Point2d中定义了一组虚函数。对Point2d来说,增加了这些额外负担:

(1) 导入了一个与Point2d相关的虚函数表,用来存放它声明的每一个虚函数的地址。表的大小一般是虚函数的个数,再加上一两个slots来支持runtime type identification

(2) 在每一个class object中导入一个vptr,使每一个object都能找到其对应的虚函数表

(3) 加强构造函数,使构造函数能给vptr设定初值

(4) 加强析构函数,使它能够消除指向class相关虚函数表的vptr

而对Point3d来说:每一个class object中增加了一个额外的vptr(这个vptr继承自Point2d);多了一个Point3d的虚函数表;此外每一个虚函数的调用也比以前复杂了(关于成员函数第四章会讲)

3. 多重继承

单一继承提供了一种“自然多态”形式:假设vptr放在对象的末尾,那么基类和派生类的对象就都是从相同的地址开始的,所以把一个派生类对象指定给基类的指针或者引用,这个操作并不需要编译器去修改地址,它可以自然地发生,并且提供了最佳执行效率。

C++标准并没有规定多重继承的派生类的各个基类对象在内存中的排列顺序,所以在类型转换时,需要编译器偏移指针的值。cfront是按照基类的声明顺序排列的,下面提供书的一个例子:

4. 虚继承

一般的虚继承的实现方法:如果一个class中内含一个或多个virtual base class subobject,那么这个class会被分割为两部分:一个不变区域和一个共享区域,不变区域中的数据,不管后继如何变化,总是拥有固定的offset(从object的开头算起),所以可以直接存取;共享区域表现的就是virtual base class subobject,这部分的数据的位置上会因每次的派生操作发生变化,所以只能被间接存取(比如通过指针存取)。下面介绍编译器实现虚继承的三种方法:

(1) cfront的做法,在每一个派生类对象里面安插一些指针,每个指针指向一个虚基类,要存取继承得来的虚基类成员,可以通过相关指针间接完成。

上述方法有两个主要的缺点:1)一个对象的占用空间会随着虚基类的增加而增加,而我们却希望每一个对象能有固定的负担。2)由于虚拟继承串链的增加,间接存取层次的增加。为了解决第一个缺点,引出了下面两个新方法。

(2) Microsoft编译器引入了虚基类表(这里应该是指Microsoft编译器的早期版本,现在用的应该不是这个方法),表中放置所有的虚基类的指针,而在对象中安插一个指针指向这个表,这样每个对象的额外开销就都变成了一个指针。

(3) 在虚函数表中放置虚基类的偏移值。在某个早期编译器中,虚函数和虚基类(偏移)可以分别通过正值和负值来索引。

值得注意的是,上述每一种方法都是一种实现模型,而不是一种标准,每一种模型都是用来解决“存取shared subobject内的数据所引发的问题”。经由一个非多态的class object来存取一个继承而来的虚基类的成员,可以被优化为一个直接存取操作,因为编译期就可以确定该对象的类型,由此就能得到相应的虚基类的位置。

经由上述讨论,可以得出一个结论:虚基类的最有效的一种运用形式就是:一个抽象的虚基类,不包含任何数据成员。

五、对象成员的效率(Object Member Efficiency)

六、指向Data Members的指针(Pointer to Data Members)

class Point3d{
public:
    virtual ~Point3d();
    //......
protected:
    static Point3d origin;
    float x, y, z;
};

形如&Point3d::z的表达式取到的是z在Point3d对象中的偏移位置。实际实现的时候会给这个偏移位置再加上1,这是为了区分没有指向任何data member的指针和一个指向第一个data member的指针,比如:

float Point3d::*p1 = 0;
float Point3d::*p2 = &Point3d::x;
//Point3d::*的意思是指向Point3d data member的指针类型

假如p2也是0,两者就无法区分了。(但是用VS2013测试发现打印出的偏移位置并没有加1,可能是编译器做了优化。并且只能用printf打印,用cout打印结果与预期不符,原因未知)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值