《深度探索C++对象模型》读书笔记第三章:Data语意学

《深度探索C++对象模型》读书笔记第三章Data语意学

空类的大小(sizeof)

一个空类的大小通常为1,是因为编译器为其安插了一个char,以便这个类的任意两个object能够在内存中配置独一无二的地址。
一个类的大小通常与机器和编译期有关,受以下三个因素的影响:
1. 语言本身的额外负担
比如支持虚函数,或者虚继承。
2. 编译器对于特殊情况的优化处理
某些编译器会对empty virtual base class提供特殊的支持。
3. Alignment的限制(对齐原则)
内存对齐使得它们能够更有效地在内存中存取,Alignment指将数值调整至某数的整数倍,一般32位机器Alignment为4bytes.
举个例子:

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

我使用codeblocks(g++4.9.2)以及VS2017得到的结果均为:

sizeof(X)=1,sizeof(Y)=sizeof(Z)=4,sizeof(A)=8;

这两种编译器的策略是,一个empty virtual base class被视为derived class object最开头的部分,也就节省了derived class object开始的1bytes(用虚基类的1bytes代替本身的1bytes),(本身也就有了member)而且也不需要3bytes的补充(Alignment),因此在此模型下,Y和Z的大小都是4.
具体内存模型如下:
内存模型
一个virtual base class subobject只会在derived class只中存在一份实例,class A的大小由以下几点决定:
- 共享的虚基类X的实例,大小为1bytes;
- class Y的大小减去因配置 class X的大小,结果是4bytes,同理class Z的大小也是4bytes;
- class A自己的大小0bytes
- Alignment:4+4+1+3=12;
所以sizeof(A)=12,这是没有做特殊处理,如果做了特殊处理,Alignment的3bytes也不需要,因此sizeof(A)=8.

一.Data Member的绑定

早期C++两种防御性程序设计风格:
1. 把所有的data member放在class声明起头

class Point3d{
    //将class声明起头处放data member
    float x,y,z;
public:
    //etc
}

2.把所有的inline function放在class的声明处

class Point3d{
public:
    //class的声明处放inline function
    Point3d();
    float X() const;
}

这个古老的规则被称为”member rewriting rule”,大意是:一个inline函数实体,在整个class声明未被完全看见之前,是不会被评估求值的。但是如果一个inline函数在声明之后被立即定义,那么还是会被评估求值。
但是对于member function的argument list并不为真。考虑nested(嵌套的) typedef:

#include <iostream>
using namespace std;
typedef float length;
class Point3d {
public:
    //length被决议为global
    //_val被决议为Point3d::_val
    void mumble(length val) { _val = val; }
    length mumble() {
        cout << typeid(_val).name() << endl;
        return _val;
    }
private:
    typedef int length;
    length _val;
};
int main()
{
    Point3d p;
    p.mumble(); //i(int)
    cout << typeid(p.mumble()).name();  //f(float)
    return 0;
}

所以,总是把”nested type声明”放在class的起始处,确保绑定的正确性。

二.Data Member的布局

静态成员变量存储在data segment,和class object无关。
C++标准要求:较晚出现(声明的)member在class object中有较高的位置,不一定得连续排列,例如边界调整(alignment)就可能在member和member之间填补一些bytes.
还有vptr,传统上会放在member的最后,也有反正该class object最前端的。所以member的排列顺序视编译器而定。
目前,各家编译器都是一个以上的access section连锁在一起,依照声明的顺序成为一个连续区块

三.Data member的存取

假设有这样一个类:

class Point{
private:
    float _x;
    float _y;
    static int count;
};
Point::count = 0;
Point origin, *pt = &origin;

Member分为static member和nonstatic member;存取方式:通过指针(引用)存取和通过对象存取。

static member存取

static member可视为一个global变量,但是只在class生命周期内可见。static member只有一个实例放置在data segment。因此每次取用static member时,在内部会被转换为对唯一的extern实例的操作。例如:

origin.count = 2;
//Point::count = 2;//直接className::static_member
pt->count = 2;
//Point::count = 2; //直接className::static_member

通过指针,引用,对象调用行为都是一致的。
对于复杂的继承结构,行为和上述是一样的。
通过函数调用呢?例如:

foobar().count = 2; //c++标准要求foobar()必须被求值,evaluated 
//下面是可能的转化:
(void) foobar();
Point::count = 2;

取一个static member的地址,会得到指向该数据类型的指针,而不是指向class member的指针。例如:

&Point::count;//得到的类型是:const int* 而非Point::int*

如果两个classes每一个都声明了一个static member freeList,都存放在data segment,就会导致名字冲突,解决办法就是name-mangling,得到独一无二的名称(后面会详细讲解name-mangling)。

Nonstatic data members

非静态成员都是直接存放在每一个class object中,所以只有经过class object(或者*this)才能进行存取。
对一个非静态成员变量的存取操作,编译器需要把class object的地址加上data member的偏移量(offset),例如:

&origin._y = 0.0;
//地址&origin._y将等于
&origin + (&Point::_y - 1)

注意减1的操作,因为&Point::_y总是等于offset+1,所以要减1。那为什么&Point::_y总是等于offset+1呢?这是为了区分”指向class第一个member“和”指向class 的member,但是该class没有任何member“这两种情况。
offset是在编译期就确定了的,但是如果存取的是一个从virtual base class继承下来的member,使用指针或者引用存取,由于不知道该指针或者引用的动态类型,因此存取操作必须延迟到运行期,经过一个额外的导引才能解决。如果使用的是class object存取,那member的offset编译期就确定了。

四.继承与Data member

最简单不含继承的类:

class Point2d {
private:
    float x, y;
}pt2d;
class Point3d {
private:
    float x, y, z;
}pt3d;

内存模型如下:
内存

加上多态

我们打算处理一个坐标点,不管它是Point2d还是Point3d,于是声明虚函数接口:

class Point2d {
private:
    float _x, _y;
public:
    Point2d(float x = 0.0, float y = 0.0) :
        _x(x), _y(y) {}
    virtual float z() { return 0.0; }
    virtual void operator+=(const Point2d& rhs) {
        _x += rhs._x;
        _y += rhs._y;
    }
};
class Point3d :public Point2d {
private:
    float _z;
public:
    Point3d(float x = 0.0, float y = 0.0, float z = 0.0) :
        Point(x, y), _z(z) {}
    virtual float z() { return z; }
    void z(float newZ) { _z = newZ; }
    virtual void operator+=(const Point2d& rhs) {
        Point2d::operator+=(rhs);
        _z += rhs.z();
    }
};

加入虚函数的额外负担:
1. 导入虚表,用来存放声明的每一个虚函数,再加上首位的一个slots(支持RTTI)。
2. 每个class object导入一个vptr,提供执行期的链接,使得每一个class object都能找到虚函数表。
3. 加强constructor,使之能够为vptr设定初值,让其指向虚表。
4. 加强destructor,使之能够抹消指向虚表的指针。

这个时候,operator+=就能作用在一个Point3d和一个Point2d身上。

Point2d p2d(2.1, 2.2);
Point3d p3d(3.1, 3.2, 3.3);
p3d += p2d; //(5.2, 5.4, 3.3)

vptr可以放在class object的前端,这种代价是丧失了C语言的兼容性;所以vptr通常放在放在class object的后端,这种内存模型如下:
后段
在Point2d subobject中的__vptr__Point2d指向的虚函数表和Point2d内存模型中的一般是不一样的。具体见第四章

多重继承

假设有这样的类继承关系:
多继承
类的声明如下:

class Point2d {
private:
    float _x, _y;
};
class Point3d :public Point2d {
private:
    float _z;
};
class Vertex {
private:
    Vertex* next;
};
class Vertex3d : public Point3d, public Vertex {
private:
    float mumble;
};
Vertex3d v3d;
Vectex3d* pv3d;
Vertex *pv;
Point2d *p2d;
Point3d *p3d;

指定操作pv = &v3d;内部转化可能是:

pv = (Vectex*)(((char*)&v3d) + sizeof(Point3d));

指定操作pv = pv3d;内部转化可能是:

pv = (Vectex*)(((char*)pv3d) + sizeof(Point3d));

由于pv3d可能为空指针0,因此需要一个条件测试:

pv = pv3d ? 
    (Vectex*)(((char*)pv3d) + sizeof(Point3d))
    : 0;

内存模型如下:
多重继承
多重继承的条件下可能存在多个vptr,因此可能存在多个虚函数表。

虚拟继承

考虑经典的一个例子就是iostream library:

class ios{};
class istream : public virtual ios {};
class ostream : public virtual ios {};
class iosteam : public istream, public ostream {};

实现虚拟继承的挑战在于:istream和ostream中各自都要维护一个ios subobject,但是要折叠成iostream维护的单一的ios subobject,并且还可以保存base class和derived class的指针和引用直接的多态操作
一般的解决方法:将subobject分为两个部分:一个不变区域和一个共享区域。不变区域中的数据,总是具有固定的offset,可以直接被存取;共享区域则是放置的virtual base class subobject的部分,每次派生后的位置会发生变化,因此需要间接存取。
各家编译器实现技术的差异就在于如何实现间接存取。考虑下面的例子:
虚拟机成

class Point2d {
private:
    float _x, _y;
};
class Point3d :public virtual Point2d {
private:
    float _z;
};
class Vertex : public virtual Point2d{
private:
    Vertex* next;
};
class Vertex3d : public Point3d, public Vertex {
private:
    float mumble;
};

一般的策略是先安排不变区域,再建立共享区域。如何存取class的共享部分呢?cfront的做法是:在每一个derived class object中安排一些指针,每一个指针指向virtual base class。例如:

void Point3d::operator+=(const Point3d& rhs) {
    _x += rhs._x;
    _y += rhs._y;
    _z += rhs._z;
}
//在cfront的策略下,函数体可能内部转化为:
    __vbcPoint2d->_x += rhs.__vbcPoint2d->_x;
    __vbcPoint2d->_y += rhs.__vbcPoint2d->_y;
    _z += rhs._z;
//__vbcPoint2d就是指向virtual base class(共享部分)的指针

另外,派生类和基类的转换:

Vectex3d *pv3d;
Point2d *p2d = pv3d;
//在cfront的策略下,函数体可能内部转化为:
Point2d *p2d = pv3d ? pv3d.__vbcPoint2d : 0;

cfront的这种策略的缺点(问题)在于:
1. 每一个对象中都必须存在一个额外的指针来指向virtual base class,额外负担。
2. 如果继承链加长,会导致简介存取层次的增加。然而我们希望存取时间固定,不随继承链的长度而变化。

第二个问题的解决办法:使用空间换时间,将拷贝所取得的nested(嵌套的) virtual base class指针放到derived class object之中。
内存模型如下:
模型

第一个问题的解决有两种办法:
- 第一种是Microsoft编译期引入的所谓的virtual base class table,每一个class object如果有一个或者多个virtual base class,就由编译期安插一个指针指向virtual base class table
- 第二种也是Bjarne比较喜欢的方法,在vbtl中,将索引分为正值和负值,如果是负值,则索引到virtual base class offsets,如果是正值,则正常索引到virtual functions。

这里写图片描述
在class Point3d内存模型中,__vptr__Point3d[-1] = 8;在在class Vertex内存模型中,__vptr__Point3d[-1] = 8;在class Vectex3d内存模型中,__vptr__Point3d[-1] = 20;

因此之前的operator+=操作可能转化为:

(this + __vptr__Point3d[-1])->_x +=
    (rhs + __vptr__Point3d[-1])->_x;
(this + __vptr__Point3d[-1])->_y +=
    (rhs + __vptr__Point3d[-1])->_y;  
_z += rhs._z;
//this指针最开始指向头部,this+8便指向Point2d subobject

之前的派生类和基类的转换则可能转化为:

Point2d *p2d = pv3d ? pv3d->__vptr__Point3d[-1];

五.对象成员的效率

单一继承不会影响测试效率, 多重继承也不会,但是虚拟继承的效率令人失望。

六.指向Data Members的指针

考虑下面的class Point3d声明:

//#include "stdafx.h"
#include <iostream>
#include <stdio.h>
using namespace std;
class Point3d {
public:
    virtual ~Point3d() {}
    void print1() {
        cout << "&Point3d::x = " << &Point3d::x << endl;
        cout << "&Point3d::y = " << &Point3d::y << endl;
        cout << "&Point3d::z = " << &Point3d::z << endl;
    }
    void print2() {
        printf("&Point3d::x = %d\n", &Point3d::x);
        printf("&Point3d::y = %d\n", &Point3d::y);
        printf("&Point3d::z = %d\n", &Point3d::z);
    }
private:
    static Point3d origin;
    float x;
    float y;
    float z;
};
int main()
{
    Point3d p;
    p.print1();
    p.print2();
    return 0;
}

上面这段代码,在codeblocks(g++4.9.2)输出的是

&Point3d::x = 1
&Point3d::y = 1
&Point3d::z = 1
&Point3d::x = 4
&Point3d::y = 8
&Point3d::z = 12

在VS2017中输出的却是:

&Point3d::x = 1
&Point3d::y = 1
&Point3d::z = 1
&Point3d::x = 8
&Point3d::y = 12
&Point3d::z = 16

C++允许vptr放在对象的任何位置,但是实际情况大多数不是放在头部,就是放在尾部。
取一个坐标成员的地址代表了什么?例如:

&Point3d::z;

代表了z在class object中的偏移量(offset).
上面的输出例子有两个疑问:
1. 为什么使用cout输出的都是1;
2. 对于&Point3d::x的print,为什么g++输出的是4,但是VS输出的却是8?

除去这两个疑问,我们可以得到结论:无论是g++还是MVC++,vptr均在对象的开头。若vptr在对象的尾部,则print输出的应该是0,4,8(g++)或者0,8,12(MVC++)。
另外,上述输出结果是编译器做了特殊处理,如果不做特殊处理,取data member的地址应该在返回值上加1,即本例的print输出加1,应该是5,9,13(g++)或者9,13,17(MVC++),那为什么要加1呢?
原因在于分辨一个“没有指向任何data member的指针”和“指向第一个data member的指针”,考虑:

float Point3d::*p1 = 0;
float Point3d::*p2 = &Point3d::x;
if(p1 == p2) {  //如何分辨这两个指针
    //dosomething
}

为了分辨这两个指针,所以为每一个member offset加1.
另外,如何辨别取一个class object身上的data member的地址和上述的区别,即

&Point3d::z;    //类型是float Point3d::*
&origin.z;      //类型是float*

&origin.z;取到的是z在内存中的真正地址,若用origin.z的地址减去Point3d::z的地址(偏移量),再加1,最终得到的就是origin的实际地址。实际无法这么做,因为两种类型不一样,无法做减法,即使使用转型。

指向Member指针的效率问题

主要参考自侯捷老师的《深度探索C++对象模型》第三章 data语意学

猜你喜欢

转载自blog.csdn.net/qq_25467397/article/details/80430304