Skip to content

类的内存布局 ​

从简单到复杂吧,多态,虚继承、菱形继承……

成员变量 ​

成员变量是顺序存储的,在内存(看实例化的时候被开辟在堆区还是栈区,只是位置不同,但是内存布局一致)空间会开辟一段连续内存,里面只放非静态数据成员,同时,也会进行一些内存对齐的操作

那么,静态成员变量 —— 自然是存放在全局 / 静态数据段

另外的,权限修饰的关键字并不会影响内存布局,只会影响编译期的检查。

C++
class Simple {
public:
    int a;      // 4 字节
private:
    char b;     // 1 字节
    double c;   // 8 字节
};

那么看似应当占用 13 个字节,实际上会被对齐到 16 个字节 (看 aligning 策略吧)

成员函数 ​

如果把所有成员函数都存储在存变量的那段连续内存中,显然太笨了 —— 成员函数和其他普通的函数一样,都是存放在代码段里,然后在调用的时候 ——

C++
Logger lg;
lg.log();

---------

Logger_log(&lg);

也就是,实际上每个非静态的成员方法在本质上都会被隐式的传入一个this指针。

综上所述,成员方法并不会占用对象的内存

静态成员函数也是在代码段

C++
class MethodClass {
public:
    int a;
    static int s_count;     // 静态变量

    void doSomething();     // 普通方法
    static void print();    // 静态方法
};

这样一个什么都有的类,实际上被实例化后也只会占用 4 个字节

单继承 ​

比较简单,先把父类的基础上,先把父类的内存布局抄一份下来,接着在尾部接上自己的内存布局

C++
class Base {
    int a;
};
class Derived : public Base {
    int b;
};

那么它的内存分布图 ——

+-------------------+ <--- Derived 对象起始地址
| Base::a (4 bytes) | \__ 属于父类 Base 的部分
+-------------------+ /
| Derived::b(4 bytes| \__ 属于子类 Derived 自己的部分
+-------------------+ /

多态 ​

首先,因为多态函数调用的时候,其到底要调用哪个具体的方法,是无法在编译器确定,而是要在运行时确定的。

C++
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();

  1. 取出 p 对象头部的 vptr 值(一个内存地址)。
  2. 跳转到该地址,即进入了静态区中的 Derived 虚函数表。
  3. 根据编译器确定的偏移量(func1 如果是第一个虚方法,通常在索引 0),然后取出表中的函数指针。
  4. 跳转到该指针指向的代码区,执行 Derived::func1 的指令。

综上所述,虚函数本质上就是一个普通函数,只是调用方式不同(通过 vtable 间接调用)。

那么性能损耗也是必然的,普通函数可以直接跳地址,但是虚调用通常比直接调用多一次间接派发,并可能妨碍内联和其他跨函数优化;间接分支也可能增加预测压力. (不过看现代编译黑魔法了)

很聪明的查表方法(一般来说编译期生成),但是也是有一定的性能损耗

C++
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

那么有

B=D+O

也就是说我们如果已知 D 和 O 的前提下, 便可以计算出 B 的地址, 从而进行调用, 那么在单继承的时候, 由于此处的 offset 就是 0, 所以被我们忽略不写罢.

构造 / 析构 ​

C++
struct Base
{
    Base();

    virtual void Foo();
};

struct Derived : Base
{
    Derived();

    void Foo() override;
};

在实例化一个派生类的对象时, 顺序是

  1. 构造 Base Subject
  2. 构造 Derived 的部分

那么此时 vptr 的行为 —— 由于先进行基类的构造操作, 所以此时 vptr 指向的是 Base 的实现, 而只有等基类初始化完成之后, 开始构造派生类自己的部分的时候, 才会让 vptr 指向派生类的实现.

然后在析构函数的时候反过来, 由于先执行自己派生部分的析构, 然后再执行基类的析构, 所以在进入 Base()::~Base() 的时候, 此时对象就不应该视作派生类类.

Object slicing ​

那么在这种情况下, 多态消失也变得非常好解释 ——

C++
Derived d;

Base b = d;

此时 b 无法访问派生类的实现, 显得非常的好说明 —— 因为实际上此处我们是借助 d 重新构造出了一个基类 b —— 甚至是说借助 d 中的 Base Subject 来对 b 进行构造.

多继承 ​

可以理解成对于单继承的拓展版本 ——

C++
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 而言, 各父类都有自己的翻译原则.

然后在我们运行时调用的时候

C++
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++
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 指针的转化操作, 类似于

C++
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 函数 —— 一个小小“适配器”

Released under the MIT License.