Inside C++ Object Model: The Semantics of Data

温星华
2023-12-01

数据的语义(The Semantics of Data)

        C++有意思的一点是,标准从来不要求具体的C++实现,不同的编译器版本可以有不同的底层实现。这一点从虚继承这一点就可以看出来,比如下面的这些类:

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

cout << "size of X is " << sizeof(X) << endl; // 1
cout << "size of Y is " << sizeof(Y) << endl; // 8
cout << "size of Z is " << sizeof(Z) << endl; // 8
cout << "size of A is " << sizeof(A) << endl; // 16

其中,类X虽然是一个空类,但是其大小却是1个字节。这是因为为了确保每个类对象都有一个独一无二的地址,每个对象就必需有大小。编译器会在空类中插入一个char类型的成员,使其大小变为1字节。 类Y和类Z虚继承了X,所以其内部会有一个指向基类对象的指针,所以其大小为8字节(64位机器)。然而,有的c++实现可能会将空类X中编译器插入的char类型继承下去,这样的话,考虑字节对齐,类Y和类Z的大小就是16个字节了。而类A同时继承了Y和Z,所以其内部有两个指针,其大小的话就是16字节。

        从以上就可以看出,C++的标准定义了虚继承,但是并没有规定其具体的实现方式,所以不同的C++实现可以有不同的实现。所以,当要探讨一个C++类的内存空间时需要考虑多方面的因素:语言自身的支持;编译器的优化;字节对齐。

数据成员的绑定(The Binding of a Data Member)

        对于下面这个类,GetX和SetX两个函数中访问到的x是该类的成员变量还是全局变量x呢?在现在的C++的实现中,对于GetX,其绑定的类中的成员x;而对于SetX,其绑定的是全局变量x。这在现在并不难回答,但是在早起的C++实现中,这两个函数绑定的都是全局变量x,这有点出乎人的意料。正是由于这种原因,在早起的C++中有两种防御式的编程思想。

extern float x;

class Point3D
{
public:
    float GetX() const {return x;}
    void SetX(float newX) const { x = newX;}
private:
    float x; float y; float z;
};

        第一种:为保证数据的正确绑定,将所有的数据成员声明在类的最前面。这种的代码能够确保GetX中绑定的是类中的成员x,而SetX中绑定的是全局变量x。

extern float x;

class Point3D
{
private:
    float x; float y; float z;
public:
    float GetX() const {return x;}
    void SetX(float newX) const { x = newX;}
};

        第二种:将所有的内联函数放到类的声明之外。对于类外的内联函数,只有当看到整个类的声明时才会进行数据绑定。这条规则被称为“成员重写规则“。在后来的C++标准中,又增加了一些成员解析规则来补充“成员重写规则”。加了这些规则之后,即使是类内部函数,其解析被延迟到整个类声明完成之后。

extern float x;

class Point3D
{
public:
    float GetX() const;
    void SetX(float newX) const { x = newX;}
private:
    float x; float y; float z;
};

inline float Point3D::GetX()
{
    return x;
}

         虽然现在对于函数体内的绑定不会再有问题,但是对于函数的参数来说,其绑定可能会产生错误。如以下的代码,函数的参数是原地解析的,所以参数的类型会被解析为int,而非float。但是这种写法并不能通过编译,在编译器就会暴露错误。这种写法也可以采用防御性编程来避免,即将嵌套类型生命在类的最前面,这样在解析成员函数的入参类型时就不会发生错误绑定。

typedef int length;

class Point
{
public:
    length GetX(){return x}; // x会绑定到Point::x
    void SetX(length newX) {x = newX;} // length 会绑定到int,而非flaot
private:
    typedef float length;
    length x;
};

        数据成员的布局(Data Member Layout)

        对于下面这个类,非静态成员x、y、z在内存中的顺序与其声明的顺序一致。而静态成员则被存储在程序段,独立与具体类对象。

class Point3D
{
public:
    float x;
    static List<Point3d*> *freeList;
    float y;
    static const int chunkSize = 250;
    float z;
};

C++的标准只要求了在一个分区内(public, private, protected)后声明的成员具有更高的地址,并没有要求数据成员连续分布,比如当需要考虑字节对齐时,类对象的内存空间中,数据成员之间会有一些空白的字节。

        此外,编译器可能会在类对象的内存空间中插入一些数据,比如说虚函数表指针。一开始,虚表指针被放置在所有显式声明的类对象的后面;最近,编译器通常会把虚表指针放在类内存的起始地址。C++标准并没有要求虚表指针必需放在类对象内存的开头还是结尾,哪怕将其放在数据成员之间也可以。

        对于不同的分区,比如public, private, protected,C++的标准并没有要求它们在内存中的相对顺序,这一切都是由编译器自己来决定的。在具体的实现中,通常会将不同分区的数据成员放在一个连续的内存块中,访问级别的限制不会产生多余的内存开销。这也就意味着,在多个分区内声明多个成员与在一个分区内声明多个成员,所产生的内存消耗是一样的。

数据成员的访问(Access of Data member)

        对于static成员来说,由于其分布于数据段,独立于一个具体类对象的内存空间,所以其地址在编译阶段就是可以确定的。无论是通过类对象还是类对象的指针,对于static成员的访问在编译器就是可以确定的。

        对于非静态成员呢?其在相对于一个类对象起始地址的位移也是在编译器就确定的。因此访问一个非静态成员的效率与C中struct的效率是一致的,与非派生类的访问效率也是一样的。但是当考虑到虚继承时,情况会有点复杂。当有虚继承发生时,访问一个基类中的成员是需要通过虚基类指针来获得要访问的对象的内存地址。

 

 类似资料:

相关阅读

相关文章

相关问答