跳转至

📔 Chapter03 Data语意学 读书笔记

/*环境:ubuntu 20.04, gcc : 9.4.0*/
class X {};
class Y : public virtual X { };
class Z : public virtual X { };
class A : public Y, public Z {};

int main()
{
    std::cout << "sizeof char = " << sizeof(char) << std::endl; // 输出:1
    std::cout << "sizeof X = " << sizeof(X) << std::endl;       // 输出:1
    std::cout << "sizeof Y = " << sizeof(Y) << std::endl;       // 输出:8
    std::cout << "sizeof Z = " << sizeof(Z) << std::endl;       // 输出:8
    std::cout << "sizeof A = " << sizeof(A) << std::endl;       // 输出:16
    return 0;
}
  • 对于一个空类class X实体,大小为1字节。因为编译器会安插一个char进去,所以存在一个隐藏的 1 byte。

  • C++对象模型尽量从空间优化存取速度优化的考虑来表现 nonstatic data member,并保持和C语言中结构体之间的兼容性,将数据直接放在每一个class object中,对于继承而来的nonstatic data member也是如此。

每个class object的大小正好可以包含其类的 nonstatic data member 所需的大小。对于类来说:

  • 由编译器系统会自动为支持某种语言特性而添加其他的数据成员。
  • 对于data member 和 数据结构的整齐对齐有要求。

3.1 Data Member 的绑定

对于早期的语言规则,一般称为 “成员重写规则”。总的来说,就是在整个类声明之前,不会计算 inline function的主体。但如果 inline 函数在class 声明之后被定义,则会重新计算inline function主体进行求值。

对于member function本身的分析,会在整个类声明出现后才开始,所以在类声明完成后,才会在inline member function绑定data member

在对于member function中的实参列表中的名称,则是直接第一次声明就会被解析处理。所以在 extern 和 嵌套类型之间会出现不直观的绑定。

typedef int length;

class Point3D
{
public:
    // 此时的length为 int类型
    mumber(length val) {
      _val = val;
    }
    // 此时的length为 int类型
    length mumble() {
      return _val;
    }
private:
    // 此时的length为 float类型,因为typedef 会使得之前的声明的typedef失效
    typedef float length;
    length _val;
}

3.2 Data Member 的布局

nonstatic data member 按照它们在每个类对象中声明的顺序设置(任何插入的static data member,例如freeList和chunkSize,都将会被忽略)。

class Point3D {
public:
    // ...
private:
    float x;
    static List<Point3D*> *freeList;
    float y;
    static const int chunkSize = 250;
    float z;
};
  • C++ 标准要求,在同一个access section(public、private、protected等)中,只要将member设置为“在类对象中,较晚出现的member有较高的地址”的规则即可。即不需要连续设置member。

  • member的之间也会因为alignment 而填补一些bytes,或者编译器会生成一些内部使用的data member,用来支持整个对象模型,例如vptr(vptr一般是放在class object的开头)。

  • C++标准允许将编译器将多个access section中的data member自由排列,无需关注出现在class声明中的顺序。

目前的编译器,将多个访问段按照声明的顺序连接成一个连续的块。访问段说明符和访问级的数量不会产生额外的开销。

3.3 Data Member 的存取

3.3.1 静态数据成员

每一个static data member的单个实例,都存放在程序的data segment中,每次程序使用它时,就会被内部转化为对单个extern实例的直接引用。

class Point3D {
public:
    static const int chunkSize = 250;
};

Point3D origin;
*pt = &origin;
// origin.chunkSize = 250;
Point3D::chunkSize = 250;

// pt->chunkSize = 250;
Point3D::chunkSize = 250;

通过一个指针和通过一个对象来存取member的结论完全相同。

若取static data member 的地址,则会得到一个指向其数据类型的指针,而不是一个指向其class memer的指针。

对于每个static data memebr,都会name-mangling来获得独一无二的程序识别码。

name-mangling

  • gcc为了解决编译链接时的二义性问题,多出现在函数的重载问题上。

  • 主要是通过一个固定的命名规则来重新组织源代码中定义的变量名或者函数名,从而确保链接目标文件中符号的唯一性。

3.3.2 非静态数据成员

nonstatic data member直接存放到每个class object中。

对于nonstatic data member的存取操作,编译器需要将class object的起始地址加上data member的偏移量(offset)。

1
2
3
origin._y =  0.0;

&origin._y; // 等价于 &origin + (&Point3D::_y - 1);

对于 -1 操作,指向data member的指针,其offset值总是加上1,便于编译器系统区分出一个指向data member的指针,用以指出class的 “第一个member” 和 “一个指向data member的指针,但没有指向任何member” 两种情况。

由于nonstatic data member 的offset在编译时可获取,即便member属于继承(单一继承或者多重继承)而来的基类子对象也是一样。nonstatic data member 的访问在效能上和C struct member 或者是 nonderived class的member一样。但虚基类的member,存取速度会比较慢。

3.4 继承和数据成员

在C++继承中,派生类对象被表示为其成员与基类成员的拼接。C++标准并没有明确指定派生类和基类的实际顺序。理论上,编译器可以自由地将基类或派生类先放在派生类对象中,实际上,大部份编译器除了虚基类外,基类成员总是先出现。

3.4.1 非多态的继承

设计策略:将一个类派生出一个类(也就是将类进行拆分两层或者多层),使其可以共享数据本身和类方法,将其局部化。一般情况下,具体继承不会增加空间或存取时间上的负担。

  • 优点:
    • 明显区分两个抽象类之间的紧密关系。
  • 缺点:
    • 会重复设计一些相同的操作函数。
    • 拆分类的层次后,有可能会为了 “表现class 体系之抽象化” 而膨胀所需空间。

实例:在32位机器中,每一个 Concrete class object 的大小都是8 bytes。

class Concrete {
public:
    // ...
private:
    int val; // 4bytes
    char c1; // 1 byte
    char c2; // 1 byte
    char c3; // 1 byte
    // alignment 1 byte
};
// 拆分三层结构
class Concrete1 {
public:
    // ...
private:
    int val;
    char bit1;
};

class Concrete2 : public Concrete1 {
public:
    // ...
private:
    char bit2;
};

class Concrete3 : public Concrete2 { // 已经变成16 bytes
public:
    //...
private:
    char bit3;
};

// 除了基类,其余的派生继承,在对齐的情况下,都需要填充空间
cout << "Concrete size = " << sizeof(Concrete)<< endl; // 8 byte 实际:7 bytes,为了alignment需要1byte
cout << "Concrete1 size = " << sizeof(Concrete1)<< endl; // 8 bytes 实际:5 bytes,为了alignment需要3bytes
cout << "Concrete2 size = " << sizeof(Concrete2)<< endl; // 12 bytes 实际:8 bytes,占用1byte, 为了alignment需要3bytes
cout << "Concrete3 size = " << sizeof(Concrete3)<< endl; // 16 bytes 实际:12 bytes,占用1byte,为了alignment需要3bytes

3.4.2 多态的继承

class Point2D {
public:
    Point2D(float x = 0.0, float y = 0.0) : _x(x), _y(y) {};

    // 加上z的保留空间
    virtual float z() {return 0.0;} // 2d点的 z = 0.0

    virtual void z(float) {}
    virtual void  operator+=(const Point2D& rhs) {
        _x = rhs.x();
        _y = rhs.y();
    }
protected:
    float _x, _y;
}

// 通过继承的方式,Point3D的新方式
class Point2D : public Point2D {
public:
    Point3D(float x = 0.0, float y = 0.0) : Point2D(x, y), _z(z) {};
    // 对基类中的虚函数实现
    float z() {return _z;}

    void z(float new2) { _z = new2}
    void operator+=(const Point2D& chs) {
        Point2D::operator += (rhs)
        _z = chs.z();
    }

protected:
    float _x, _y, _z;
}
对于弹性的设计方式,必然也会给 Point2D类 带来空间和存取时间的额外负担:

  • 引入与Point2D关联的 virtual table,用来保存它声明的每个 virtual function的地址。

    • 对于table的大小,一般是声明的virtual function的数量加上额外的1或者2个slots用来支持运行时类型识别。
  • 每个class object都引入一个 vptr。(造成空间负担)

  • 增加consturctor为vptr设置初值,使其指向class所对应的 virtual table。(造成时间负担)

  • 增加destructor将vptr重置,与consturctor所做的操作是相反的。

对于将 vptr 放在类对象的位置

  • 早期在cfront编译器中,被放在类对象的尾端,用以支持对应的继承类型,保证能和C struct能够兼容。
  • 由于C++开始支持虚拟继承和抽象基类,并且OOP的兴起,某些编译器就把 vptr 放在类对象的前端,这对于 “在多重继承之下,通过指向类成员的指针调用 virtual function” 会带来帮助。

3.4.3 多重继承

单一继承提供了一种 “自然多态” 形式,是关于类体系中的 base typederived type 之间的转换。

对于多重继承的问题,主要发生在 derived class objects 和其第二和后继的 base class object 之间的转换。

对一个多重派生对象,将其地址指定给 “最左端 base class的指针”,与单一继承相同,因为二者都指向相同的起始地址,只是增加了地址指定操作的成本而已。

class Point2D {
public:
    Point2D(float x = 0.0, float y = 0.0) : _x(x), _y(y) {};
    // 加上z的保留空间
    virtual float z() {return 0.0;} // 2d点的 z = 0.0
protected:
    float _x, _y;
};

class Point3D : public Point2D {
public:
    Point3D(float x = 0.0, float y = 0.0) : Point2D(x, y), _z(z) {};
    // 对基类中的虚函数实现
    float z() {return _z;}
protected:
    float _z;
};

class Vertex {
public:
    Vertex(float v = 0.0) : _v(v) {}
    virtual void print() {}
protected:
    int _v;
};

class Vertex3D : public Point3D, public Vertex {
public:
    Vertex3D(float mum = 0.0) : mumble(mum) {}
    Vertex3D(float x, float y, float z, float v, float mum) : Point3D(x, y, z), Vertex(v), mumble(mum) {}
protected:
    float mumble;
};

void print_layout_multiple_inheritance() {
    // TODO : update
}

void main() {
    Point2D p2D(1,2);
    Point3D p3D(1,2,3);
    Vertex vtx(1);
    Vertex3D v3D(1,2,3,4,5);
}

C++ Standard 并未明确要求base class 要有特定的排列顺序,在虚拟继承之前,编译器都是根据声明顺序来进行排序。(如果有虚拟继承的出现,编译器提供一种优化技术:如果在第一个base class中没有virtual function,而第二个中有声明,则调整base class的次序,则可以避免在derived class 中少产生一个 vptr

3.4.4 虚拟继承

多重继承带来的副作用:需要支持一种 “shared subobject” 基础的形式。

经典例子:iostream 库的实现

因为都含有 ios subobject,所以通过虚拟继承的方式优化。

对于需要编译器支持虚拟继承,大致的实现方案为:一个类包含了一个或者多个virtual base class subobject,则可以将类分为连两个区域:不变区域共享区域。 - 不变区域:不管派生类后续如何变化,总是会有固定的offset(从object的开头计),这部分数据可以直接被存取。 - 共享区域:主要是指virtual base class,对于共享区域内的数据,会随着位置的每次派生而改动,所以只能通过间接方式的访问共享区域内的成员。

但一般的布局策略:先放下派生类的不变区域,然后再创建共享区域。

访问类的共享区域的实现方式:早期编译器最直接的方式就是在每个derived class object中都插入一个指向每个virtual base class的指针。继承virtual base class成员的访问则是通过关联指针间接访问来实现。但同时也存在两个弊端: - 类的对象为每个virtual base class 都携带一个额外的指针,此时根据继承层次的数量也会造成对应的开销。

  • 继承深度增加,则造成层次增加,就需要通过对应层次的 virtual base class 指针去进行间接访问寻址。

对于两个弊端,也产生了两种主流的解决方案:

  • 使用指针策略

  • 微软引入virtual base class table。每个具有一个或多个 virtual base class 的类对象都有一个指向插入其中的虚基类表中的指针。对应的数据布局如下:

  • 使用 offset(偏移量) 策略

  • Bjarne 的方案中,不是直接放置地址,而是放置virtual base class 在 virtual function table 中的offset(偏移量)。对应的数据布局如下:

一般而言,virtual base class 最有效的一种运用形式:一个抽象的virtual base class,没有任何 data members

3.5 指向data members的指针

C++ standard 允许 vptr 被放在对象中的任何位置,在起始处、在尾端或者在各个members之间,实际上,所有的编译器不是把 vptr 放在对象的开端,就是放在对象的尾部。