目录

《深度探索C++对象模型》读书笔记(一)

文章是对《深度探索C++对象模型》第一章“关于对象”的总结。

C面向过程, 数据和操作分离;C++面向对象,支持数据和操作的封装,是兼容C的新型语言。

布局成本

C++是否产生额外的布局成本与面向对象的实现方式有关:

  • 抽象数据类型(ADT,仅封装)

    非继承且无virtual成员函数的自定义类型,布局与C结构体一样,并未增加成本。

  • 类层次结构(封装&继承&多态)

    布局和存取时间上的overhead主要来自于virtual机制,包括

    • 虚函数(virtual function),实现运行时动态绑定 runtime binding
    • 虚基类(virtual base class),继承体系中base class仅有一个共享的实例

    此外,多重继承也会产生overhead,发生于派生类与非第一基类的类型转换。

对象模型

C++对象模型先后经历几个设计方案,可以看出其演化过程。

  • 简单对象模型

    一个对象包含一系列slot,每个slot指向一个数据或函数成员(无论静态与否),对象大小即N个指针之和。

    设计初衷是尽量降低C++编译器的设计复杂度,弊端是内存空间占用较大和执行效率偏低。

  • 表格驱动模型

    在简单对象模型基础上,对数据成员和函数成员进行分类和组织, 对象包含2个slot,分别指向数据成员表(内含实际数据)和函数成员表(内含函数地址)。

    这个模型没有应用于真正的C++编译器,但是函数成员表成为虚函数的实现方案。

  • 现代C++对象模型

    在简单对象模型基础上,内存空间和存取时间进行优化,非静态数据成语在对象内,静态数据成员在对象外;函数成员均置于对象外。

    对于虚函数的支持:

    1. 类的虚函数的地址存放在一个表,即虚函数表(vtbl)
    2. 类的每个对象使用一个指针指向虚函数表,即虚函数表指针(vptr),其设定/重置由类的构造函数/虚构函数/赋值运算符自动完成。类的type_info对象(用于支持runtime type identification,RTTI)的 地址一般放在虚函数表的第一个slot。

内存布局

同一访问区段access section的数据成员在内存布局上遵循声明顺序;多个访问区段的排列顺序取决于编译器实现。

基类与派生类的数据成员在内存布局上的排列顺序也取决于编译器实现。

我在本地(x86_64 Linux 4.19.152-1-MANJARO / GCC 10.2.0)做了测试,不包括类继承,结果如下:

  • 同一访问区段的数据成员内存布局遵循声明顺序
  • 多个访问区段的内存布局遵循声明顺序
  • 虚函数表指针位于对象头部 -类继承的

测试代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class T { 
    friend void test();

public:
    int a;
    int b;

protected:
    int c;

private:
    int d;

public:
    int e;
    int f;
    
private:
    virtual void func() {}
};

void test() {
    printf("address offset of T::a == %d\n", &T::a);
    printf("address offset of T::b == %d\n", &T::b);
    printf("address offset of T::c == %d\n", &T::c);
    printf("address offset of T::d == %d\n", &T::d);
    printf("address offset of T::e == %d\n", &T::e);
    printf("address offset of T::f == %d\n", &T::f);
}

关于struct

C使用struct是C实现数据抽象,C++使用class实现数据和操作的封装。为了兼容C,C++保留了struct关键字而且在用法上几乎等同于class,除了默认访问级别的差异(struct默认public,class默认private)。

个人理解,C++程序尽量使用class关键字,仅在定义符合C数据抽象理念(不包含函数成员)的类型时使用struct

特殊用法的struct在改用class实现时可能遇到陷阱,比如存储变长数据的结构体

1
2
3
4
struct Message {
    int size;
    char buf[0];
};

对于下面几种情况,需要考虑到编译器在数据成员内存布局上带来的不确定性:

  • 包含多个访问区段( 区段可能影响内存布局)
  • 从另一个类派生而来(继承而来的数据成员可能影响内存布局)
  • 存在虚函数成员(虚函数表指针位于对象头部还是尾部,可能影响内存布局)

编程范式

  • 过程模型(procedural model)

  • 抽象数据类型模型(abstract data type model)

    相对于OO,这种模型也称为OB(object-based)。

    优点是函数调用操作在编译期解析完成,执行速度更快;对象无virtual机制,内存空间更紧凑。

    缺点是类型设计缺乏弹性。

  • 面向对象模型(object-oriented model)

    类型设计更具弹性,但执行效率不及OB模型。

选择OO还是OB,往往是对弹性和效率的取舍。