C++ 继承中的内存布局
前言
对于 C++ 程序员而言,了解所使用编程语言的实现方式具有重要意义。首先,这能消除对语言的神秘感,让我们不再对编译器的工作感到困惑。更重要的是,在调试代码和使用语言高级特性时,我们能更有把握。当需要提升代码效率时,这些知识也能发挥重要作用。
本文致力于解答以下问题:
- 类的内存布局是怎样的?
- 如何访问成员变量?
- 如何访问成员函数?
- 所谓的“调整块”(adjuster thunk)是怎么回事?
- 使用以下机制时,开销如何:
- 单继承、多重继承、虚继承
- 虚函数调用
- 强制转换到基类,或者强制转换到虚基类
- 异常处理
我们将按照以下顺序进行探讨:首先,依次考察 C 兼容的结构(struct)的布局、单继承、多重继承以及虚继承;接着,讨论成员变量和成员函数的访问,包括虚函数的情况;然后,研究构造函数、析构函数以及特殊的赋值操作符成员函数的工作原理,还有数组的动态构造和销毁;最后,简要介绍对异常处理的支持。
对于每个语言特性,我们将简要介绍其背后的设计动机、自身的语义(本文并非“C++ 入门”,请读者对此有清晰认识),以及在微软的 VC++ 中的实现方式。需要注意区分抽象的 C++ 语言语义与其特定实现,微软之外的其他 C++ 厂商可能提供完全不同的实现,我们偶尔也会将 VC++ 的实现与其他实现进行比较。
类布局
本节将探讨不同继承方式所导致的不同内存布局。
C 结构(struct)
由于 C++ 基于 C,因此 C++ “基本上” 兼容 C。具体而言,C++ 规范在 “结构” 上采用了与 C 相同的简单内存布局原则:成员变量按照声明顺序排列,并根据具体实现规定的对齐原则在内存地址上对齐。所有的 C/C++ 厂商都确保其 C/C++ 编译器对有效的 C 结构采用完全相同的布局。以下是一个简单的 C 结构示例:
struct A {
char c;
int i;
};
从上述代码可知,A 在内存中占用 8 个字节。按照声明成员的顺序,前 4 个字节包含一个字符(实际占用 1 个字节,3 个字节用于补齐对齐),后 4 个字节包含一个整数。A 的指针指向字符开始的字节处。
有 C++ 特征的 C 结构
C++ 本质上是面向对象的语言,包含继承、封装和多态等特性,并非简单的 C 语言扩展。原始的 C 结构经过改造,成为面向对象世界的基石——类。除了成员变量,C++ 类还可以封装成员函数和其他元素。有趣的是,除非为了实现虚函数和虚继承引入隐藏成员变量,否则 C++ 类实例的大小完全取决于类及其基类的成员变量,成员函数基本不影响类实例的大小。
以下是一个具有 C++ 特征的 C 结构示例:
struct B {
public:
int bm1;
protected:
int bm2;
private:
int bm3;
static int bsm;
void bf();
static void bsf();
typedef void* bpv;
struct N { };
};
需要注意的是,C++ 标准委员会并未限制由 “public/protected/private” 关键字分隔的各段在实现时的先后顺序,因此不同编译器实现的内存布局可能不同。在 VC++ 中,成员变量总是按照声明顺序排列。另外,静态成员 static int bsm 不占用类实例的内存空间,因为它存放在程序的数据段中。
单继承
C++ 提供继承机制的目的是在不同类型之间提取共性。例如,科学家对物种进行分类,形成种、属、纲等层次结构,使我们能够将具有特定性质的事物归入合适的分类层次。在 C++ 中,继承语法简单,只需在子类后加上 “:base” 即可。以下是一个单继承的示例:
struct C {
int c1;
void cf();
};
struct D : C {
int d1;
void df();
};
派生类需要保留基类的所有属性和行为,因此每个派生类实例都包含一份完整的基类实例数据。在单继承类层次中,新的派生类通常将自己的成员变量添加到基类成员变量之后。在上述示例中,D 继承自 C,D 的实例包含 C 的数据和 D 自身的数据。大多数知名的 C++ 厂商采用基类成员在前的内存安排,这样可以保证派生类中基类对象的地址恰好是派生类对象地址的第一个字节,获取基类指针时无需计算偏移量。
多重继承
在大多数情况下,单继承已经足够,但 C++ 为了提供更多灵活性,还支持多重继承。例如,在组织模型中,有经理类(负责分配任务)和工人类(负责执行任务),对于一线经理类,既需要从上级经理那里领取任务并执行,又需要向下级工人分配任务,单继承无法很好地表达这种关系。多重继承可以解决这个问题,示例如下:
struct Manager { ... };
struct Worker { ... };
struct MiddleManager : Manager, Worker { ... };
以下是一个具体的多重继承示例:
struct E {
int e1;
void ef();
};
struct F : C, E {
int f1;
void ff();
};
在多重继承中,派生类实例会拷贝每个基类的所有数据。与单继承不同的是,内嵌的多个基类对象指针不可能都与派生类对象指针相同。例如,在 F 中,C 对象指针与 F 对象指针相同,但 E 对象指针与 F 对象指针不同,这种偏移量会导致少量的调用开销。VC++ 按照基类的声明顺序先排列基类实例数据,最后排列派生类数据,派生类数据本身也按照声明顺序布局。
虚继承
回到一线经理类的例子,如果经理类和工人类都继承自雇员类,不做特殊处理的话,一线经理类的实例将包含两个雇员类实例,这会导致实例生成时的开销增加,还可能造成数据不一致。为了解决这个问题,C++ 引入了虚继承机制,通过在指定基类时加上 virtual 关键字来实现。示例如下:
struct Employee { ... };
struct Manager : virtual Employee { ... };
struct Worker : virtual Employee { ... };
struct MiddleManager : Manager, Worker { ... };
使用虚继承会带来更大的实现开销和调用开销。在单继承和多重继承中,内嵌基类实例地址与派生类实例地址之间的偏移量是固定的,但在虚继承中,派生类地址和其虚基类地址之间的偏移量通常是不固定的。VC++ 为每个继承自虚基类的类实例增加一个隐藏的 “虚基类表指针”(vbptr)成员变量,该变量指向一个全类共享的偏移量表,表中记录了 “虚基类表指针” 与虚基类之间的偏移量。
以下是虚继承的示例代码:
struct G : virtual C {
int g1;
void gf();
};
struct H : virtual C {
int h1;
void hf();
};
struct I : G, H {
int i1;
void _if();
};
通过观察上述示例,我们可以得出 VC++ 虚继承下内存布局的结论:
- 首先排列非虚继承的基类实例。
- 有虚基类时,为每个基类增加一个隐藏的 vbptr,除非已经从非虚继承的类那里继承了一个 vbptr。
- 排列派生类的新数据成员。
- 在实例最后,排列每个虚基类的一个实例。
这种布局使得虚基类的位置随派生类的不同而变化,但非虚基类的偏移量固定不变。
成员变量
在了解类布局后,我们来探讨不同继承方式下访问成员变量的开销。
无继承情况
当没有任何继承关系时,访问成员变量与 C 语言的情况相同,只需从指向对象的指针加上一定的偏移量即可。例如:
C* pc;
pc->c1; // *(pc + dCc1);
这里的 pc 是指向 C 的指针,访问 C 的成员变量 c1,只需在 pc 上加上固定的偏移量 dCc1(C 指针地址与其 c1 成员变量之间的偏移量),再获取该指针的内容。
单继承情况
在单继承中,派生类实例与其基类实例之间的偏移量为常数 0,可以直接利用基类指针和基类成员之间的偏移量关系,简化计算。例如:
D* pd;
pd->c1; // *(pd + dDC + dCc1); // *(pd + dDc1);
pd->d1; // *(pd + dDd1);
访问基类成员 c1 时,由于 dDC 恒定为 0,可直接计算 C 对象地址与 c1 之间的偏移;访问派生类成员 d1 时,直接计算偏移量。
多重继承情况
在多重继承中,虽然派生类与某个基类之间的偏移量可能不为 0,但该偏移量是常数,访问成员变量时的计算可以简化,开销仍然不大。例如:
F* pf;
pf->c1; // *(pf + dFC + dCc1); // *(pf + dFc1);
pf->e1; // *(pf + dFE + dEe1); // *(pf + dFe1);
pf->f1; // *(pf + dFf1);
访问 C 类成员 c1 时,F 对象与内嵌 C 对象的相对偏移为 0,可直接计算 F 和 c1 的偏移;访问 E 类成员 e1 时,F 对象与内嵌 E 对象的相对偏移是常数,F 和 e1 之间的偏移计算也可简化;访问 F 自己的成员 f1 时,直接计算偏移量。
虚继承情况
当类有虚基类时,访问非虚基类成员仍然是计算固定偏移量的问题,但访问虚基类成员变量的开销会增大,需要经过以下步骤才能获得成员变量的地址:
- 获取 “虚基类表指针”。
- 获取虚基类表中某一表项的内容。
- 把内容中指出的偏移量加到 “虚基类表指针” 的地址上。
例如:
I* pi;
pi->c1; // *(pi + dIGvbptr + (*(pi + dIGvbptr))[1] + dCc1);
pi->g1; // *(pi + dIG + dGg1); // *(pi + dIg1);
pi->h1; // *(pi + dIH + dHh1); // *(pi + dIh1);
pi->i1; // *(pi + dIi1);
I i;
i.c1; // *(&i + IdIC + dCc1); // *(&i + IdIc1);
当通过指针访问虚基类成员时,需要进行复杂的计算;但如果直接通过对象实例访问,派生类的布局可以在编译期间静态获得,偏移量也可在编译时计算,无需根据虚基类表的表项间接计算。对于多层虚基类成员变量的访问,VC++ 在虚基类表中增加了额外项,保存了从派生类到各层虚基类的偏移量,优化了访问过程。
强制转化
如果没有虚基类的问题,将一个指针强制转化为另一个类型的指针代价并不高。如果两个指针之间存在 “基类 - 派生类” 关系,编译器只需在两者之间加上或减去一个偏移量(该量通常为 0)即可。例如:
F* pf;
(C*)pf; // (C*)(pf ? pf + dFC : 0); // (C*)pf;
(E*)pf; // (E*)(pf ? pf + dFE : 0);
C 和 E 是 F 的基类,将 F 的指针 pf 转化为 C* 或 E*,只需加上相应的偏移量。转化为 C* 时,由于 F 和 C 之间的偏移量为 0,无需计算;转化为 E* 时,需加上非 0 的偏移常量 dFE。C++ 规范要求 NULL 指针在强制转化后依然为 NULL,因此 VC++ 在做强制转化运算前会检查指针是否为 NULL,但该检查仅在指针显式或隐式转化为相关类型指针时进行,在派生类对象中调用基类方法,派生类指针在后台转化为基类的 Const “this” 指针时,无需进行检查。
当继承关系中存在虚基类时,强制转化的开销会增大,与访问虚基类成员变量的开销相当。例如:
I* pi;
(G*)pi; // (G*)pi;
(H*)pi; // (H*)(pi ? pi + dIH : 0);
(C*)pi; // (C*)(pi ? (pi + dIGvbptr + (*(pi + dIGvbptr))[1]) : 0);
强制转化 pi 为 G* 时,由于 G* 和 I* 的地址相同,无需计算;转化为 H* 时,只需考虑一个常量偏移;转化为 C* 时,需要进行复杂的计算,与访问虚基类成员变量的开销相同。为了避免每次访问虚基类成员都计算虚基类地址的开销,建议先将派生类指针强制转化为虚基类指针,然后使用虚基类指针访问成员变量。
成员函数
C++ 成员函数是类范围内的成员,每个非静态的成员函数都会接受一个特殊的隐藏参数——this 指针,类型为 X* const。该指针在后台初始化为指向成员函数工作的对象,在成员函数体内,成员变量的访问通过计算与 this 指针的偏移来实现。
以下是一个包含非虚成员函数和虚成员函数的类示例:
struct P {
int p1;
void pf();
virtual void pvf();
};
虚成员函数会使对象实例占用更多内存空间,因为需要虚函数表指针。而声明非虚成员函数不会增加对象实例的内存开销。