C++ 对象模型之虚函数表

C++
2020年4月19日 02:22

一个人不应该用猜的方式,或是等待某大师的宣判,才确定『何时提供一个复制构造函数而何时不需要』。

Stanley B.Lippman 《深度探索 C++ 对象模型》

C 语言是一种面向过程语言,我们写一个三维坐标结构体,并打印:

typedef struct point3d {
    float x;
    float y;
    float z;
} Point3D;

// 函数操作
void Point3d_print( const Point3D *pd) {
    printf("(%g, %g, %g)", pd->x, pd->y, pd->z);
}

// 宏定义
#define Point3D_print(pd) printf("(%g, %g, %g)", pd->x, pd->y, pd->z);

C++ 是一种联邦制语言,我们使用其面向对象的部分重写上面的代码:

class Pont3D {
public:
    Point3D( float x = 0.0, float y = 0.0, float x = 0.0)
        : _x(x), _y(y), _z(z) {}
    float x() { return _x; }
    float y() { return _y; }
    float z() { return _z; }
    // ... etc ...
private:
    float _x;
    float _y;
    float _z;
};

inline ostram& operator<<( ostream &os, const Point3D &pt ) {
    os << pt.x() << ", " << pt.y() << ", " << pt.z();
};

或者使用继承的方式,从一维坐标的类派生出二维,进而派生出三维坐标;或者使用模板的方式做进一步的抽象。但是无论哪种方式,都会发现用 C++ 写起来要比 C 复杂得多。有很多人可能会认为,既然写法上复杂了很多,那么程序的执行效率必然会有所下降。但是实际情况是,C++ 的写法并没有增加成本,这和 C++ 的内存布局有关。C++ 在布局和存取时间上的额外负担主要是由 virtual 产生的,主要包括虚继承与虚函数。

C++ 对象模型

假设有一简单的类 Point

class Point {
public:
    Point( float x );
    virtual ~Point();

    float x() const;
    static int PointCount();
private:
    virtual ostream& print( ostream &os ) const;
    float _x;
    static int _point_count;
}

如果要设计一套模型来存储这个类,我们会怎么设计呢?

因为类中每一个成员的类型不同,需要的存储空间也就不同,要解决这个问题,我们可以为每一个数据成员以及函数成员创建一个 slot,这个 slot 中存储的都是成员的指针(图片摘自《深度探索 C++ 对象模型》):

简单对象模型
这就避免了存储的问题,但是所有成员都多了一次查找过程。

第二种方式是将数据成员及函数成员分别存在两个表中,数据成员表存放具体的数据,函数成员表中存放各个成员函数的 slot,slot 中存储函数地址,这样虽然很条理,但是效率更低了。

C++ 使用的对象模型从简单对象模型派生而来,并对内存空间和存取时间做了优化。在这个模型中,非静态数据存放在每个类对象中,静态数据成员、静态及非静态函数成员存放在类对象之外,虚函数采用了如下的方式进行支持:

  • 每个类创建一张虚表 (vtbl),虚表中存放指向虚函数的指针
  • 类对象存储指向虚表的虚指针 (vptr)

虚指针的设定和重置由每一个类对象的构造函数、析构函数和赋值运算符自动完成,此外,虚表的第一个 slot 存放 type_info,用于支持 RTTI 等特性(图片摘自《深度探索 C++ 对象模型》):
C++ 对象模型

这个模型的主要优点在于空间和存取时间的效率,但由于非静态数据成员存储在类对象内部,因此一旦涉及到这部分数据的变化,都需要重新编译。

在了解了 C++ 的内存布局后,我们可以通过指针来获取虚表中的虚函数成员,并通过函数指针调用虚函数:

typedef void(*Fun)(void);
Fun pFun = nullptr;
Base b;
int **vptr = (int**)&b;
for (int i = 0; (Fun)vptr[0][i] != nullptr; ++i ) {
    pFun = (Fun)vptr[0][i];
    pFun();
}

可见,C++ 除了使用虚函数需要通过指针多查找一次,使用类的数据成员时,相对于 C 语言来说并不会增加开销。

有了虚表,就很容易实现多态,只要在构建派生类对象模型时,在虚表中覆盖掉重写的虚函数即可。


参考资料:

  1. C++ 对象的内存布局 —— 陈皓
  2. 深度探索 C++ 对象模型 —— Stanley B.Lippman