类的内存布局
从简单到复杂吧,多态,虚继承、菱形继承……
成员变量
成员变量是顺序存储的,在内存(看实例化的时候被开辟在堆区还是栈区,只是位置不同,但是内存布局一致)空间会开辟一段连续内存,里面只放非静态数据成员,同时,也会进行一些内存对齐的操作
那么,静态成员变量 —— 自然是存放在全局 / 静态数据段
另外的,权限修饰的关键字并不会影响内存布局,只会影响编译期的检查。
class Simple {
public:
int a; // 4 字节
private:
char b; // 1 字节
double c; // 8 字节
};那么看似应当占用 13 个字节,实际上会被对齐到 16 个字节 (看 aligning 策略吧)
成员函数
如果把所有成员函数都存储在存变量的那段连续内存中,显然太笨了 —— 成员函数和其他普通的函数一样,都是存放在代码段里,然后在调用的时候 ——
Logger lg;
lg.log();
---------
Logger_log(&lg);也就是,实际上每个非静态的成员方法在本质上都会被隐式的传入一个this指针。
综上所述,成员方法并不会占用对象的内存
静态成员函数也是在代码段
class MethodClass {
public:
int a;
static int s_count; // 静态变量
void doSomething(); // 普通方法
static void print(); // 静态方法
};这样一个什么都有的类,实际上被实例化后也只会占用 4 个字节
单继承
比较简单,先把父类的基础上,先把父类的内存布局抄一份下来,接着在尾部接上自己的内存布局
class Base {
int a;
};
class Derived : public Base {
int b;
};那么它的内存分布图 ——
+-------------------+ <--- Derived 对象起始地址
| Base::a (4 bytes) | \__ 属于父类 Base 的部分
+-------------------+ /
| Derived::b(4 bytes| \__ 属于子类 Derived 自己的部分
+-------------------+ /多态
首先,因为多态函数调用的时候,其到底要调用哪个具体的方法,是无法在编译器确定,而是要在运行时确定的。
Base *p = getObj();
p->foo();由于 getObj 返回的对象只能在运行期直到,所以调用的 foo 是哪个也只能在运行时直到,所以需要用一种方式来对这些方法进行定位 ——
对于出现虚函数的类,会编译出对应的 vptr / vtable,来进行虚方法的定位
NOTE
The C++ standard does not specify how virtual functions should be implemented (this detail is left up to the implementation).
先说虚函数表,它记录了所有被 virtual 标记的虚函数的存放地址(函数指针地址,也就是虚函数的具体实现在代码段的位置
一个类的虚函数表的布局显然是在编译器就可以确定下来的,如果给每一个实例都分配一个显然是对内存空间的不尊重(严重浪费),所以它是放在只读段的一个函数指针数组。
接着只要类有虚函数(或继承自有虚函数的类),编译器就会在对象内存中加一个隐藏指针成员:vptr,用于指向自己这个类所对应的虚函数表。
所以每次在访问的时候,先读取到 vptr,然后再通过偏移量确定具体的实现。
举个例子 —— 假设 Base* p = new Derived(); p->func1();
- 取出
p对象头部的vptr值(一个内存地址)。 - 跳转到该地址,即进入了静态区中的
Derived虚函数表。 - 根据编译器确定的偏移量(
func1如果是第一个虚方法,通常在索引 0),然后取出表中的函数指针。 - 跳转到该指针指向的代码区,执行
Derived::func1的指令。
综上所述,虚函数本质上就是一个普通函数,只是调用方式不同(通过 vtable 间接调用)。
那么性能损耗也是必然的,普通函数可以直接跳地址,但是虚调用通常比直接调用多一次间接派发,并可能妨碍内联和其他跨函数优化;间接分支也可能增加预测压力. (不过看现代编译黑魔法了)
很聪明的查表方法(一般来说编译期生成),但是也是有一定的性能损耗
struct Base;
struct VTable { void (*Foo)(Base*); };
struct Base
{
const VTable* vptr;
int a;
};
struct Derived
{
Base base; // 复制内存空间
int derived; // 自己特有的
};
// Base 的 Foo 实现
void BaseFoo(Base* self) { std::cout << "Base::Foo, a = " << self->a << '\n'; }
void DerivedFoo(Base* self)
{
auto* derived = reinterpret_cast<Derived*>(self);
std::cout << "Derived::Foo, a = "
<< derived->base.a
<< ", b = " << derived->derived
<< '\n';
}
const VTable BaseVTable{ &BaseFoo };
const VTable DerivedVTable{ &DerivedFoo };
void ConstructBase(Base* self)
{
self->vptr = &BaseVTable;
self->a = 114;
}
void ConstructDerived(Derived* self)
{
ConstructBase(&self->base);
self->base.vptr = &DerivedVTable;
self->derived = 514;
}
void CallFoo(Base* object) { object->vptr->Foo(object); }
int main()
{
Base base;
ConstructBase(&base);
Derived derived;
ConstructDerived(&derived);
CallFoo(&base);
CallFoo(&derived.base);
}简单进行一个手写实现 —— auto* derived = reinterpret_cast<Derived*>(self); 其实比较巧妙, 因为子类的头部布局和父类一致, 所以在强制类型转化的时候保证了传入的 base 指针所指向的地址就是当前子类的首地址, 所以可以正常转化.
当然, 我们也可以稍微量化一下这个过程, 为之后说明多继承先埋个钩子. 假设 ——
- D = Derived complete object 的起始地址
- B = Base subobject 的起始地址
- O = Base subobject 相对于 Derived 起点的 offset
那么有
也就是说我们如果已知 D 和 O 的前提下, 便可以计算出 B 的地址, 从而进行调用, 那么在单继承的时候, 由于此处的 offset 就是 0, 所以被我们忽略不写罢.
构造 / 析构
struct Base
{
Base();
virtual void Foo();
};
struct Derived : Base
{
Derived();
void Foo() override;
};在实例化一个派生类的对象时, 顺序是
- 构造 Base Subject
- 构造 Derived 的部分
那么此时 vptr 的行为 —— 由于先进行基类的构造操作, 所以此时 vptr 指向的是 Base 的实现, 而只有等基类初始化完成之后, 开始构造派生类自己的部分的时候, 才会让 vptr 指向派生类的实现.
然后在析构函数的时候反过来, 由于先执行自己派生部分的析构, 然后再执行基类的析构, 所以在进入 Base()::~Base() 的时候, 此时对象就不应该视作派生类类.
Object slicing
那么在这种情况下, 多态消失也变得非常好解释 ——
Derived d;
Base b = d;此时 b 无法访问派生类的实现, 显得非常的好说明 —— 因为实际上此处我们是借助 d 重新构造出了一个基类 b —— 甚至是说借助 d 中的 Base Subject 来对 b 进行构造.
多继承
可以理解成对于单继承的拓展版本 ——
struct A
{
virtual void FooA();
std::uint64_t a;
};
struct B
{
virtual void FooB();
std::uint64_t b;
};
struct C : A, B
{
void FooA() override;
void FooB() override;
virtual void FooC();
std::uint64_t c;
};一个简单的继承树, 我们可以大致的把当前的内存布局视作 ——
C complete object
┌────────────────────┐
│ A subobject │
│ └─ a, vptr A/C │
├────────────────────┤
│ B subobject │
│ └─ b, vptr B │
├────────────────────┤
│ C::c │
└────────────────────┘假设我们还是沿用之前的思路, A 一个 vTable, B 一个, C 一个; 那么这不是一件好事, A 在自己的 vTable 中实现的 slot 0 -> FooA; 同理 B 在自己的实现是 slot 0 -> FooB, 也就是说对于自己的 vtable 而言, 各父类都有自己的翻译原则.
然后在我们运行时调用的时候
void CallFooB(B* p)
{
p->FooB();
}此时的 p 只知道自己有一个 vptr, 比如在此处指向 slot 0, 那么对于 B 自己本身肯定是可以正常访问的, 但是对 C 呢? 它的 Slot 0 究竟应该存储 FooA 的 override 还是 FooB 的 override ?
所以具体的实现肯定还会再复杂一些 —— 首先假设我们不继承 B, 那么就化归成了单继承的情况, 那么类似的, 我们就可以让 A 这个 Base Subject 在 C 里头的 vptr 对 A 负责, 同时也对 C 自己负责 (就是让其行为和单继承的时候没差)
C primary / A-in-C table
slot 0 → C::FooA ← override
slot 1 → C::FooC ← C 新增
slot 2 → C::FooB ← 实际上 FooB 也是 C 中声明的虚函数类似这样, 那么再重新加上 B 的情况 —— 我们需要另外一张 B-in-C 的 vTable 来帮助我们实现虚函数的分发. 与此同时, 我们也需要记录 B 在 C 中的 offset (实际上对于普通的非虚继承来说, 这个量是在编译期确定的, 所以编译器可以直接生成最终的表达式), 作用是在进行 B* pb = &c; 这样操作的时候, 可以让 pb 成功定位到 C 中 B 这个 Base Subject 的位置.
假设我们记住了 offset, 那么在执行的时候可以根据在单继承的时候说的式子, 得到 B_in_C_address = &C + offset 好的, 成功定位了.
接着再调用 pb->FooB() 的时候就可以让 B-in-C vTable 对于 Slot 的解释和 B 对 Slot 的解释一致, 从而可以正确的定位到 C 中的 FooB 实现, 不过此时又会出现一个问题 —— 现在的指针不是指向 C, 而是指向 C 内部的 B 实现, 那么在调用成员函数的时候, 上文说过会隐式的传递一个 this 指针, 那么此时就会出现问题. 很坏, 实际效果类似于 ——
C_FooB(reinterpret_cast<C*>(pb));但是此时的 pb 指向的是 C 内部的 B 位置, 结果便是所有的成员访问都错位.
我们需要在这一步把 this 指针还原回 C* this!
也就是把上述定位到 B 的运算反过来做一次 —— C = B - O_B, 我们确乎可以把这个操作放在 C_FooB 中实现, 不过这样会出现一个神奇的现象 —— c.FooB() 的时候其实不需要进行操作, 只有从 B* 进入的时候才需要执行这个恢复 this 指针的操作.
于是不妨引入一个 thunk 方法, 让其只有进入从 B* 进入的时候才进行 this 指针的转化操作, 类似于
void BToCFooBThunk(B* self)
{
C* c =
reinterpret_cast<C*>(
reinterpret_cast<char*>(self) - OffsetB
);
C_FooB(c);
}(或者抽象成 B* this -> this -= OffsetB -> Jump to C::FooB 这样的一条数据流)
综上, 我们的 B-in-C table 实际可能指向的是 thunk 函数 —— 一个小小“适配器”