C++ 对象模型
参考资料:《深度探索C++对象模型》 && 《C++新经典:对象模型》
graph TB
B[inherit]
C[this adjust]
D[&Class::Mem offset]
E[vptr size+base size+align]
F[Obj ptr cmp]
G[ctor/assign/move]
H[virtual Derive]
I[vbptr vbtable]
J[vcall/thunk]
K[ctor static bind]
L[RTTI]
M[Slice]
N[virtual Tech]
O[Diff Class Diff vtable]
B-->C
B-->D
N-->E
C-->F
B-->H
H-->I
N-->J
G-->K
N-->L
G-->M
C-->E
C-->J
N-->I
B-->N
N-->O
Tools
- 内存对齐原则
- binutils ,linux 下的二进制工具(可参考《程序员的自我修养》 ),比如:
nm
,显示 obj 中的变量与函数签名c++filt
,将 C++ 函数签名转换为普通的函数形式:__ZN2VAC1Ev
–>VA::VA()
C++ vs C
C++ 在布局以及存取时间上主要的额外负担是由 virtual 引起的。data members 直接内含在每一个class object 之中,就像 C struct 的情况一样;member funcs 虽然含在 class 的声明之内,却不出现在 object 之中。每一个 non-inline member func tion 只会诞生一个函数实例。下面代码片段展示了 C++ 代码和 C 代码之间转换的一种可能的方式,其中 class X 定义了一个 copy constructor、一个 virtual destructor 和一个 virtual function foo
X foobar() {
X xx;
X *px = new X;
xx.foo(); // foo() 是 virtual function
px->foo();
delete px;
return xx;
};
// 可能的内部转换结果,使用 C 模拟上面 C++ 代码的行为
void foobar(X &_result) {
_result.X::X(); // 构造 _result
px = _new(sizeof(X)); // X *px = new X;
if (px != nullptr)
px->X::X();
foo(&_result); // xx.foo()
(*px->vtbl[2])(p x); // px->foo()
if (px != nullptr) { // delete px
(px->vtbl[1])(px); // destructor
delete (px);
}
};
对象
this 指针调整
this 指针调整一般出现在多重继承中。单继承场景下也有可能,比如父类没有虚函数但子类有,下文会细述
在 C++ 继承体系中,根据访问不同的父类成员变量或者是成员函数,同一个实例对象会出现不同的基址,这种现象叫做 this 指针基址调整
this 调整的原因
对于编译器而言,C++ 类成员函数一般是以普通函数的方式实现的,和常规方法的区别是编译器会在这些函数中插入额外一个参数 this(如果不使用类成员变量,this 会被忽略),函数内部通过 this 和成员变量偏移量(例如 &VA::_n
)访问类成员变量
继承体系下同一块内存可能存放着不同对象的数据,有多种方法可以获得成员变量的位置:
- 不做 this 调整,所有函数在获得 this 之后需要判断数据存储的结构然后提取数据。这就比较繁琐,因为不同继承场景下内存分布不同,故提取过程也不同
- 做 this 调整,成员函数只需要在编译的时候确定变量相对于 this 的偏移即可,效率比上面的方法要高很多(和常规 C 函数效率相同)。这也是绝大部分 C++ 编译器的实现方式
this && 成员偏移调整
因为分析方法类似,下面的描述没有细分是否包含虚函数。调整原则主要围绕 this 指针/成员偏移量这两点进行
- 单继承场景下子类与父类基地址相同
- 多继承场景下子类和第一个继承的父类基地址相同,其他父类地址与当前类基地址的偏移量是前置所有父类的 sizeof 之和
基于上面的方式,成员偏移量在编译时即可确认:
- 单继承场景下父类成员靠前,父类自身的成员函数不需要做任何处理即可访问父类的数据。子类成员偏移量在编译时加上父类的 sizeof 值即可
- 多继承场景下子类成员放在所有父类成员之后,那么子类成员的访问直接加上所有父类的 sizeof 值即可
成员变量指针与偏移量
成员变量指针的声明方式示例:
#include <iostream>
class Base {
public:
void hi() { std::cout << "hi" << std::endl; }
int _n{0};
};
int main() {
Base b;
// data member pointer
int Base::*b_mem_ptr = &Base::_n;
std::cout << b._n << std::endl; // 0
b.*b_mem_ptr = 1;
std::cout << b._n << std::endl; // 1
// function member pointer
void (Base::*b_mem_f_ptr)(void) = &Base::hi;
(b.*b_mem_f_ptr)(); // hi
}
成员偏移量
可以使用宏实现成员变量偏移的提取:#define OFFSET(C,m) int64_t( &((C*)(0))->m )
从编译器的实现角度来看,类的成员变量指针存储的是成员变量相对于对象起始的偏移量,如下代码所示(本文所有代码均在 x64 环境下编译与运行):
#include <iostream>
class BaseA {
public:
virtual ~BaseA() {}
int _a;
};
class BaseB {
public:
virtual ~BaseB() {}
int _b;
};
class VA : public BaseA, public BaseB{
public:
// VA::_a: 8, VA::_b: 8, BaseA::_a: 8, BaseB::_b: 8, _c: 28, _n: 32 , sizeof(VA): 40
virtual void print() { // 不要使用 cout,cout 全部输出 1
printf("VA::_a: %d, VA::_b: %d, BaseA::_a: %d, BaseB::_b: %d, _c: %d, _n: %d , sizeof(VA): %d\n", &VA::_a, &VA::_b, &BaseA::_a, &BaseB::_b, &VA::_c, &VA::_n, sizeof(VA));
}
private:
int _c{0}, _n{1};
};
继承场景下成员变量指针的偏移量是在父类的基础上进行计算的,所以父类成员的偏移量和子类没有任何关系,这里又涉及到了 this 指针调整到话题。假设父类没有虚函数,但子类有,那么内存布局可参考下面代码片段:
#include <iostream>
class BaseA {
public:
int _a;
};
class BaseB {
public:
int _b;
};
class VA : public BaseA, public BaseB{
public:
virtual ~VA() { }
int _c{0}, _n{1};
};
int main() {
VA va;
BaseA* pba = &va; // this adjust
BaseB* pbb = &va; // this adjust
VA* pva = &va;
// 140732844886064/140732844886072/140732844886076
std::cout << int64_t(pva) << "/" << int64_t(pba) << "/" << int64_t(pbb) << std::endl;
}
多重继承下指针的比较
参考 C++ Common Knowledge item 28 开篇:
In C++, an object can have multiple, valid addresses, and pointer comparison is not a question about addresses. It’s a question about object identity
在多继承场景下指针之间的相等比较的是:指针是否指向相同的对象,而不是比较指针的二进制值。如下代码片段所示,直接比较有继承关系的对象指针和直接比较指针之间的值,其结果不同。这里需要注意一个前置条件:多重继承场景下,子类和第一个继承的类(下面的 A),其对象地址相同,其他类(比如 B)地址需要加上前置继承类大小(比如 B 的地址是 addr(A) + sizeof(A) + 内存对齐开销)
// Apple clang version 12.0.0 (clang-1200.0.32.27)
// Target: x86_64-apple-darwin19.6.0
#include <iostream>
class A { int a; };
class B { double b; };
class C : public A, public B { };
template<typename T> void* vp(T* p) { return (void*)p; }
int main() {
C c;
B *cpb = &c;
A *cpa = &c;
// 4, 8, 16
std::cout << sizeof(A) << ", " << sizeof(B) << ", " << sizeof(C) << std::endl;
// 0x7ffee19d97e8, 0x7ffee19d97e8, 1
std::cout << &c << ", " << cpa << ", " << ((&c == cpa) ? 1 : 0) << std::endl;
// 0x7ffee2bd97e8, 0x7ffee2bd97f0, 1
std::cout << &c << ", " << cpb << ", " << ((&c == cpb) ? 1 : 0) << std::endl;
// 0x7ffee2bd97e8, 0x7ffee2bd97f0, 0
std::cout << &c << ", " << cpb << ", " << (vp(&c) == vp(cpb) ? 1 : 0) << std::endl;
}
默认函数的生成机制
默认构造的生成机制
编译器生成默认构造函数的前提是确认你不需要自行管理对象的构造,原则是你没有定义自己的构造函数
默认构造函数也不是一定会出现在编译后的二进制文件中,因为不需要的时候编译器也不会生成默认构造。下面有几种场景,编译器不得不生成默认构造:
- 成员变量中有默认构造,编译器需要去调用这个构造函数,不得已生成一个默认构造
- 父类有默认构造函数
- 类中有虚函数,有虚函数必然有 vptr,虚函数对象在生成时需要初始化 vptr,故必须有默认构造
- 存在虚基类
- 使用了 C++11 以后存在的就地初始化方法。就地初始化最先执行
默认拷贝构造/赋值的生成机制
类内不存在拷贝构造与赋值的情况下,编译器一般直接使用 memcpy 的方式递归的(有内部类)实现数据的拷贝
编译器生成默认拷贝构造的前提是没发现当前类代码中有自定义的拷贝构造行为。生成默认拷贝与赋值的场景:
- 类成员包含拷贝构造
- 父类有拷贝构造
- 类包含虚函数,此时编译器需要初始化 vptr
- 包含虚基类
默认移动构造/赋值生成机制
编译器生成默认移动函数的前提是确定你没有自己管理资源的痕迹,原则是类没有定义任何拷贝构造/拷贝赋值/析构函数
在上面基础上,内部成员变量均可移动,才会生成默认移动函数
成员函数(无 vptr)
在不存在虚函数的前提下,C++ 成员函数在编译器内部的实现和普通 C 函数相比只多了一个编译器自动插入的 this 指针变量,用于告知当前函数对象在内存中的位置,此时 C++ 的效率和 C 一致。这是静态绑定的场景
虚机制
虚函数指针一般位于对象的起始位置,不同类有着不同的虚函数表,即使一个类基成父类之后未做任何新增与修改,其两者的虚函数表也是不同的。虚函数表在编译时即已确定,在存在虚内存机制的机器上,虚函数表地址是唯一且确定的
虚基类
虚基类的实现方式和虚函数非常相似,有虚基类的对象包含了**虚基类指针(vbptr)和虚基类表(vbtable)**这两个概念,注意这里多了一个 base 的概念,和 vptr 有一定的区别。不同编译器对虚基类的实现差别要比虚函数的实现差别要大
虚基类的详细信息这里不做介绍,有兴趣可以看看相关资料
继承之后
对象切片
子类对象赋值给父类时有时会丢失子类的一些信息,这是对象切片的一个现象。子类向父类赋值时不会修改父类对象的 vptr
多继承内存布局
多重继承后对象数据分布如下图所示:
多重继承场景,父类的虚函表数指针和成员变量依次分布在一块连续的内存中(可能有对齐损耗)。从子类对象转换成不同父类的指针,编译器自动完成 this 调整
多个父类存在虚函数的前提下,多重继承存在多个 vptr。子类和第一个父类均使用第一个 vptr
成员函数(存在 vptr)
静态 && 动态 绑定
-
不要重写父类非虚函数,避免误用
-
不要修改父类默认参数
-
子类虚函数表与父类虚函数表布局相同,子类中新的虚函数,排在父类虚函数表已有数据之后。注意:子类和父类虚函数表不是同一个,即使子类未新增任何数据
-
子类与第一个基类共用 vptr,其他父类有各自的 vptr
-
thunk,多重继承场景下虚函数表中可能包含编译器插入的特殊函数指针,比如多重继承下各父类的 vtable,编译器会插入用于析构的特殊函数。虚函数表的额外信息还有比如 RTTI 对象指针等等
存在 vptr 的对象,其函数绑定分为两种:静态绑定和动态绑定。动态绑定只出现在引用和指针场景下(其实都是指针)。动态绑定的行为等价于:(*p_obj->vptr[n])(...)
,例如如下代码片段(x64 架构):
#include <iostream>
class Base {
public:
virtual void hi() { std::cout << "Base say Hi" << std::endl; }
};
class VA : public Base {
public:
virtual void hi() override { std::cout << "VA say Hi" << std::endl; }
};
typedef void (*void_fun)(void);
int main() {
VA va;
int64_t *vptr_ptr = reinterpret_cast<int64_t*>(&va); // vptr pointer
int64_t *vtable_ptr = reinterpret_cast<int64_t*>(*vptr_ptr); // vtable pointer
void_fun*mem_1th = (void_fun*)vtable_ptr; // first void member fun
(*mem_1th)(); // VA say Hi
}
上面代码片段有个前置条件:成员函数不使用成员变量,编译器不需要传递 this 指针,所以此类函数参数为 void
上面的代码片段也没有考虑 this 调整等问题,真实的虚函数调用要比上面的示例要复杂得多,比如 vs2019 使用 vcall 实现虚函数的调用
虚析构函数的重要性
- 单继承场景下对象析构时的对象切片与内存泄漏
- 多继承场景下的对象切片与服务崩溃(因为 this 指针的调整,delete 的指针不是 new 获得的)与内存泄漏
静态成员函数
静态成员函数不需要 this 指针,所以静态成员函数不能添加 const/virtual 等属性
vcall in vs2019
VS2019 中虚函数的调用通过编译器内置的汇编代码片段 vcall 实现。vcall 有多项功能,比如 this 调整,虚函数查找等等
RTTI
RTTI 是运行时类型检查的缩写(Run Time Type Identification),RTTI 与虚机制息息相关,RTTI 的信息一般使用虚函数表保存。如上图图所示,RTTI 信息指针一般保存在虚函数表中,在第一个虚函数指针之上
对象构造
不要在构造函数中调用虚函数
C++ 中先构造父类然后构造子类,构造父类时子类数据还没有初始化,所以在父类构造函数中调用的函数永远都是父类自己的且构造函数内的函数调用都是静态绑定,即使调用虚函数,如下代码片段所示:
#include <iostream>
class Base {
public:
Base() { hi(); }
virtual void hi() { std::cout << "Base say Hi" << std::endl; }
};
class VA : public Base {
public:
VA() { hi(); }
virtual void hi() override { std::cout << "VA say Hi" << std::endl; }
};
int main() {
VA va;
// Base say Hi
// VA say Hi
}
临时对象的生命周期
- VA+VB 产生的临时对象会即时析构
2. 使用 const 引用可以延长此类临时变量的生命周期:
const VA& va = VA{};