前言
C++ 内存模型所聚焦的问题在于一个 C++ 对象在内存中是如何编排的,各种类内数据及方法,是如何存储在内存中的,以及模型是如何适配继承体系的。本文有大量例程进行验证和说明,需要花一定时间阅读,也推荐读者在自己的机器上进行试验和验证。
本文中程序所用编译器为 clang 14.0.0,在 64 位系统下
数据成员
对于数据来说,一个 C++ 对象,仅有非静态的数据成员会在对象实例中占据内存,其余都不会储存在对象实例中,另外还需要考虑内存对齐需要填充的的情况。以下假设内存以 32 位对齐,即 4 字节对齐。
静态成员储存在静态存储区
一个典型的 C++ 对象
数据在对象实例中按声明顺序排列:
// 内存模型:
// | mem1 |
// | mem2 | padding |
// | mem3 |
class A {
public:
int member1 = 1;
protected:
char member2 = 2;
private:
int member3 = 3;
static int s_member;
};
int main() {
cout << sizeof(A) << endl; // 结果:12字节
A test;
void* ptr_base = &test;
int* ptr_mem1 = (int*)ptr_base;
char* ptr_mem2 = (char*)((int*)ptr_base + 1);
int* ptr_mem3 = (int*)ptr_base + 2;
cout << *ptr_mem1 << (int)*ptr_mem2 << *ptr_mem3 << endl;
// print: 123
return 0;
}
继承中的内存对齐
虽然是跨类型,但如果能合并产生更高效的内存模型,则会合并,例如:
class A {
short mem1;
};
// 内存模型:
// ====A=========B====
// | mem1 | mem2 |
class B : A {
short mem2;
};
sizeof(B); // 结果:4字节
单继承
在单继承体系下,数据成员分布和单一对象没有什么区别,仅仅是按继承顺序拓展。
class A {
public:
int member1 = 1;
protected:
char member2 = 2;
private:
int member3 = 3;
static int s_member;
};
// 内存模型:
// ==========A==========
// | mem1 |
// | mem2 | padding |
// | mem3 |
// ==========B==========
// | mem4 | padding |
class B : A {
public:
char member4 = 4;
};
int main() {
cout << sizeof(B) << endl; // 结果:16字节
B test;
void* ptr_base = &test;
char* ptr_mem4 = (char*)((int*)ptr_base + 3);
cout << (int)*ptr_mem4 << endl;
// print: 4
return 0;
}
多继承
在多继承体系下,仍然遵顼按顺序拓展的原则,多个基类按照声明顺序依次拓展。
class A {
public:
int member1 = 1;
protected:
char member2 = 2;
private:
int member3 = 3;
static int s_member;
};
class B {
public:
char member4 = 4;
};
// 内存模型:
// ==========B==========
// | mem4 | padding |
// ==========A==========
// | mem1 |
// | mem2 | padding |
// | mem3 |
// ==========C==========
// | mem5 |
class C : B, A {
public:
int member5 = 5;
};
int main() {
cout << sizeof(C) << endl; // 结果:20字节
C test;
void* ptr_base = &test;
char* ptr_mem4 = (char*)ptr_base;
int* ptr_mem5 = (int*)ptr_base + 4;
cout << (int)*ptr_mem4 << *ptr_mem5 << endl;
// print: 45
return 0;
}
虚继承
对于如下继承结构,如果不采用虚继承,则类型 D
中将包含两个类型 A
的实例,此时将无法区分两个类型 A
的实例。虚继承保证了继承体系中同一个类型只会存在一个实例,其内存模型也将有所不同。
class A {};
class B : virtual A {};
class C : virtual A {};
class D : B, C {};
此时就无法静态地确定各类型内存该如何分布,可以考虑类型 B
、C
的内存模型,如果静态考虑,那么 B
、C
应该一致。但此时引入 D
同时继承 B
和 C
,这时必然就包含两份 A
的实例,如果需要保证只有一份实例,那么总有一方,要么 B
要么 C
的内存模型需要更改,那么就无法静态确定内存分布了,即无法通过基址偏移确定各类型的内存位置。
为了解决这一问题,就需要引入虚基类表指针,通过虚基类表定位基类实例 A
,虽然这会带来一定的开销(访问虚基类需要查表)。具体是通过偏移量来定位虚基类,对于每个基类 B
和 C
的内部,都有一个对应的虚基类表,储存对应的偏移量,根据 B
或 C
的基址(B
或 C
类内的 this
指针),偏移得到虚基类的位置,从而在 B
或 C
中操作虚基类实例 A
。
内存对齐在 32 位和 64 位地址有所不同,以下假设地址为 64 位,方便在现代计算机上验证。示例程序不关注虚基类表内是如何编排各类信息的,主要关注一个类型实例的内存占用,关于虚表的具体实现,可以参照附录。
// 以下图示,一行代表8个字节
class A {
public:
int a_member = 1;
};
// 内存模型:64位地址
// ==========B===========
// | vbptr |
// =====B====|=====A=====
// | b_m | a_m |
class B : virtual A {
public:
int b_member = 2;
void b_func() {
a_member = 123;
}
};
// 内存模型:64位地址
// ==========C===========
// | vbptr |
// =====C====|=====A=====
// | c_m | a_m |
class C : virtual A {
public:
int c_member = 3;
void c_func() {
a_member = 456;
}
};
// 内存模型:64位地址
// ==========D==========
// ==========B==========
// | b_vbptr |
// | b_m | padding |
// ==========C==========
// | c_vbptr |
// =====C====|====D=====
// | c_m | d_m |
// ==========A==========
// | a_m | padding |
// ==========D==========
class D : B, C {
int d_member = 4;
};
int main() {
cout << sizeof(A) << endl; // 结果:4字节
cout << sizeof(B) << endl; // 结果:16字节
cout << sizeof(C) << endl; // 结果:16字节
cout << sizeof(D) << endl; // 结果:40字节
D test;
void* base_ptr = &test;
// 一个int*的偏移量是4字节,对应图示的半格
int* b_member = (int*)base_ptr + 2;
int* c_member = (int*)base_ptr + 6;
int* d_member = (int*)base_ptr + 7;
int* a_member = (int*)base_ptr + 8;
cout << "a_member = " << *a_member
<< "\nb_member = " << *b_member
<< "\nc_member = " << *c_member
<< "\nd_member = " << *d_member << endl;
return 0;
// print:
// a_member = 1
// b_member = 2
// c_member = 3
// d_member = 4
}
特例
一个没有数据成员的对象需要占据 1 字节,但如果被继承,就还原成 0 字节。虽然没有数据成员的空对象可以不占据内存,但空对象也是对象,也应该能够生成类型实例,且能够区分不同的对象实例,所以至少需要 1 个字节,让对象拥有一个实际的地址用于区分。
class A {};
class B : A {
int member;
};
int main() {
cout << sizeof(A) << endl; // 结果:1字节
cout << sizeof(B) << endl; // 结果:4字节
A a1, a2;
cout << (&a1 != &a2) << endl; // true
}
成员函数
非虚函数不占用对象实例内存
函数储存在代码区,并不占用对象实例内存,非静态函数会隐式传入对象地址 this
,所以示例中,可以通过 nullptr
调用函数,函数可以成功调用,但前提是该函数没有访问成员变量。因为成员变量通过 this
的地址通过偏移访问得到,此时如果访问,则会产生非法访问,导致未定义行为。
class A {
public:
int member;
void func() {
cout << "func(): " << this << endl;
}
static void s_func() {
cout << "s_func()" << endl;
}
};
int main() {
cout << sizeof(A) << endl; // 结果:4字节
// 另一种验证方法
A test;
test.func();
test.s_func();
A* ptr = static_cast<A*>(nullptr);
ptr->func();
ptr->s_func();
// print:
// func(): 0x7fffc5be8368
// s_func()
// func(): 0
// s_func()
}
虚函数:单继承
关于 C++ 的多态就不再赘述了,简单来说就是调用接口(虚函数)一致,但执行的函数不同。
为了实现多态,一个派生类可以视为基类调用,如何知道该调用基类方法还是父类方法,就需要引入虚函数表,虚函数表里记录了类型所有的虚函数地址。当一个派生类被视为基类调用一个虚函数,则会查询派生类里的虚函数表,找到对应的虚函数调用,以此来满足多态。
所以为了满足多态,任何声明了虚函数的类都包含一个指向虚函数表的地址,且一个类最多只有一个虚函数表指针。
// 64位下指针为8字节
using int64 = long int;
// 内存模型:
// ==========A==========
// | vptr |
// | a_member |
class A {
public:
int64 a_member = 1;
virtual void func() {}
};
// 内存模型:
// ==========B==========
// | vptr |
// | a_member |
// | b_member |
class B : A {
public:
int64 b_member = 2;
virtual void func() override {}
};
int main() {
cout << sizeof(A) << endl; // 结果:16字节
cout << sizeof(B) << endl; // 结果:24字节
B b;
void* base_ptr = &b;
int64* a_member = (int64*)base_ptr + 1;
int64* b_member = (int64*)base_ptr + 2;
cout << *a_member << endl;
cout << *b_member << endl;
return 0;
}
虚函数:多继承
多继承情况复杂一些,由于虚函数表必须放在基址上(标准规定所有虚表都放在对象地址的第一个位置上),所以有可能违背布局按照声明顺序排列的规则。
using int64 = long int;
class A {
int64 a_member = 1;
};
class B {
int64 b_member = 2;
virtual void func() {}
};
// 内存模型:
// ==========C==========
// ==========B==========
// | b_vptr |
// | b_member |
// ==========A==========
// | a_member |
// | c_member |
class C : B, A {};
// 内存布局与C一致
class D : A, B {};
int main() {
D d;
C c;
void* c_base_ptr = &c;
int64* c_a_member = (int64*)c_base_ptr + 2;
int64* c_b_member = (int64*)c_base_ptr + 1;
void* d_base_ptr = &d;
int64* d_a_member = (int64*)d_base_ptr + 2;
int64* d_b_member = (int64*)d_base_ptr + 1;
cout << (*c_a_member == *d_a_member) << endl; // true
cout << (*c_b_member == *d_b_member) << endl; // true
return 0;
}
当多个基类存在虚函数时,派生类中可能存在多个虚表指针,这样才能满足各个基类的多态。我们都知道派生类可以完全视为基类看待,为了满足这一特性,在多继承中就必须保证每个基类的完整性,所以每个基类都有自己的虚表。
考虑以下继承关系,实际对象是 C
但完全可以看作是类型 A
或 B
,只是其基址不同而已,从内存的视角看,就是类型 A
或 B
,调用虚函数仍然需要查表,以此来实现多态。
using int64 = long int;
class A {
public:
int64 a_member = 1;
virtual void a_func() {}
};
class B {
public:
int64 b_member = 2;
virtual void b_func() {}
};
// 内存模型:
// ==========C========== <- C* begin
// ==========A========== <- A* begin
// | a_vptr |
// | a_member |
// ==========A========== <- A* end
// ==========B========== <- B* begin
// | b_vptr |
// | b_member |
// ==========B========== <- B* end
// | c_member |
// ==========C========== <- C* end
class C : public A, public B {
public:
int64 c_member = 3;
void a_func() override {}
void b_func() override {}
};
int main() {
C c;
void* base_ptr = &c;
int64* a_member = (int64*)base_ptr + 1;
int64* b_member = (int64*)base_ptr + 3;
int64* c_member = (int64*)base_ptr + 4;
cout << *a_member << endl;
cout << *b_member << endl;
cout << *c_member << endl;
return 0;
}
虚函数:虚继承
虚继承对于虚函数表来说并没有什么不同,内存布局参照数据成员的虚继承,但需要注意的是数据成员中的虚基表和虚函数表,统一为虚表,所以一个类无论有虚继承还是存在虚函数,至多只有一个虚表。
using int64 = long int;
class A {
public:
int64 a_member = 1;
virtual void a_func() {}
};
class B : public virtual A {
public:
int64 b_member = 2;
virtual void b_func() {}
};
class C : public virtual A {
public:
int64 c_member = 3;
virtual void c_func() {}
};
// 内存模型:
// ==========D========== <- D* begin
// ==========B========== <- B* begin
// | b_vptr |
// | b_member |
// ==========B========== <- B* end
// ==========C========== <- C* begin
// | c_vptr |
// | c_member |
// ==========C========== <- C* end
// | d_member |
// ==========A========== <- A* begin
// | a_vptr |
// | a_member |
// ==========A========== <- A* end
// ==========D========== <- D* end
class D : public B, public C {
public:
int64 d_member = 4;
void a_func() override {}
void b_func() override {}
void c_func() override {}
};
int main() {
cout << sizeof(D) << endl;
D d;
void* base_ptr = &d;
int64* a_member = (int64*)base_ptr + 6;
int64* b_member = (int64*)base_ptr + 1;
int64* c_member = (int64*)base_ptr + 3;
int64* d_member = (int64*)base_ptr + 4;
cout << *a_member << endl;
cout << *b_member << endl;
cout << *c_member << endl;
cout << *d_member << endl;
return 0;
}
纯虚函数的继承
这部分并没有什么特殊的例外,纯虚函数是一个接口,只是不能被实例化而已,当被继承后,和继承带有虚函数的普通类没什么不同。
类型裁切
类型裁切是另一个理解 C++ 内存模型的角度,派生类可以发生裁切从而适配基类,这里适配可以是复制移动,也可以仅仅是视为基类。结合 虚函数:多继承 这一节内存模型的理解,类型裁切本质上也只是派生类可以完全替代基类的一种概念延申。
class Base {
public:
Base() : base(0) {
cout << "Base()" << endl;
}
Base(const Base& val) {
cout << "Base copy" << endl;
}
Base& operator=(const Base& val) {
cout << "Base operator= copy" << endl;
return *this;
}
Base(Base&& val) {
cout << "Base move" << endl;
}
Base& operator=(Base&& val) {
cout << "Base operator= move" << endl;
return *this;
}
virtual void func() {
cout << "Base::func()" << endl;
}
int base;
};
class Derived : public Base {
public:
Derived() : derived(0) {
cout << "Derived()" << endl;
}
Derived(const Derived&) = delete;
Derived& operator=(const Derived&) = delete;
Derived(Derived&&) = delete;
Derived& operator=(Derived&&) = delete;
void func() override {
cout << "Derived::func()" << endl;
}
int derived;
};
// 派生类调用该函数会产生适配
// 适配有两种,复制和移动无法体现多态,视为基类(指针或引用)可以体现多态
void shrink(Base b, Base& ref, Base* ptr) {
b.func(); // Base::func()
ref.func(); // Derived::func()
ptr->func(); // Derived::func()
}
int main() {
Base b;
Derived dev;
// 发生裁切,Derived 被裁切为基类 Base
b = dev; // 调用拷贝赋值
b.func(); // 调用 Base::func()
b = std::move(dev); // 调用移动赋值
Base copy(dev);
Base move(std::move(dev));
// Base 需要支持拷贝构造,Derived 可以不支持,因为拷贝只发生在 Base 类
shrink(dev, dev, &dev);
}
附录:虚表结构
综上,虚表中包含了虚函数地址和虚基类的偏移量,C++ 还为虚表多加了一个类型元数据的表项。
所以简单说,虚表中有以下数据:2 和 3 一定存在一个,否则就不会构造出虚表
- 类型元数据(一定存在)
- 虚函数地址
- 虚基类的偏移量
那么在内存中,虚表是如何编排的呢?
我们拿两个典型的继承结构来管中窥豹:
- 只有虚基表
class A {
public:
int64 a_member;
};
class B : virtual A {
public:
int64 b_member;
};
class C : virtual A {
public:
int64 c_member;
};
// 内存模型:
// =========top=========
// | b_vptr | 0
// | b_member | 8
// | c_vptr | 16
// | c_member | 24
// | d_member | 32
// ========base_a=======
// | a_member | 40
// =========end========= 48
//
// 虚表内容:
// ======b_vtable======
// | offset to base | [b_vptr -> base_a]: 40
// | offset to top | [b_vptr -> top]: 0
// | typeinfo data |
// | virtual function | <- b_vptr
// ======c_vtable======
// | offset to base | [c_vptr -> base_a]: 24
// | offset to top | [c_vptr -> top]: -16
// | typeinfo data |
// | virtual function | <- c_vptr
class D : public B, public C {
public:
int64 d_member;
};
int main() {
D d;
B* b = &d;
void* b_vtable = (void*)*(int64*)b;
int64* b_offset_base = ((int64*)b_vtable - 3);
int64* b_offset_top = ((int64*)b_vtable - 2);
int64* b_meta_data = ((int64*)b_vtable - 1);
cout << "b offset to base: " << *b_offset_base << endl;
cout << "b offset to top: " << *b_offset_top << endl;
cout << "b meta data: " << *b_meta_data << endl;
// print:
// b offset to base: 40
// b offset to top: 0
// b meta data: 4202856
C* c = &d;
void* c_vtable = (void*)*(int64*)c;
int64* c_offset_base = ((int64*)c_vtable - 3);
int64* c_offset_top = ((int64*)c_vtable - 2);
int64* c_meta_data = ((int64*)c_vtable - 1);
cout << "c offset to base: " << *c_offset_base << endl;
cout << "c offset to top: " << *c_offset_top << endl;
cout << "c meta data: " << *c_meta_data << endl;
// print:
// c offset to base: 24
// c offset to top: -16
// c meta data: 4202856 两者类型原信息是一致的,因为实际类型都是D
return 0;
}
- 只有虚函数表
class A {
public:
int64 a_member = 1;
virtual void func() {}
virtual void func2() {}
};
// 内存模型:
// | vptr |
// | a_member |
// | b_member |
// 虚表内容:
// =======vtable=======
// | offset to base | 无意义
// | offset to top | 无意义
// | typeinfo data |
// | func() | <- vptr
// | func2() |
class B : A {
public:
int64 b_member = 2;
void func() override {
cout << "func" << endl;
}
void func2() override {
cout << "func2" << endl;
}
};
int main() {
B b;
void* vtable = (void*)*(int64*)&b;
auto func = (*(void(*)())(*(int64*)vtable));
func(); // print: func
auto func2 = (*(void(*)())(*((int64*)vtable + 1)));
func2(); // print: func2
return 0;
}
以上两种情况就可以大致看出,虚表中各个内容是如何编排的,虚基表存储在虚表指针上方,虚函数表存储在虚表指针下方,存储顺序按基类中函数的声明顺序决定。