多继承下的虚函数

多继承下重载签名相同的函数

结果是会把基类同签名的所有非 final 虚函数都重写了,而且实现方式相同。尽管基类的虚函数签名一样,但是他们没有关联性,所以在子类的虚表中占两个槽(slots)(一个槽是一个指针)。同样的,如果 Interface 中有虚函数 foo,而 A 和 B 都继承了 Interface,C 继承了 A 和 B。如果 A 和 B 没有虚拟继承 Interface,那么在 C 的对象调用函数 foo 时将出现 ambiguous 指代错误。如果 C 重写了 foo 函数,那么指代就还是明确的。或者,如果 A 和 B 都是虚拟继承自 Interface,那么也不会有编译错误。但这样通过指针/引用调用虚函数 foo 就需要先取虚基类子对象 this 的偏移,修改 this 之后再从 vptr 中读虚函数 foo,开销是 4 次访存(将虚拟继承和虚函数调用的代价累加起来了) 。

多继承下调用虚函数时修正 this 指针

为什么需要修正 this 指针?

重写签名相同的虚函数时,生成的函数代码只能对其中一个基类的虚函数做 this 修正。

#include <cstdio>
using namespace std;

struct A {
    int x;
    virtual void foo() { printf("A::foo() x=%d\n", x); }
};

struct B {
    virtual void foo() { printf("B::foo()\n"); }
};

struct C : A, B {
    void foo() override { printf("C::foo()\n"); }
};

int main() {
    C c;
    A *p = &c;    // <--
    p->foo();
    A *q = new A;
    q->foo();
    B *r = new C; // <--
    r->foo();
}

首先 C::foo() 代码只有一份(生成多份太浪费了),通过上面 p 指针调用 foo(),会从 vtable 中选中 C::foo(),然后用 C 的 this(这是因为 A 和 C 的 this 是重合的,不需要修改)。如果用 r 指针调用foo(),则需要将 B 的 this 修改成 C 的 this,那么直接复用 C::foo() 代码就不行了!

单继承不需要担心指针修正,因为 this 指针没有偏移。

thunk

一说为 knuth 博士名字的倒写。作用是提供一层间接关系,先修改指针,再调用真正的代码。gcc 用的就是这种。

.LTHUNK0 应该就是 C::foo() 的地址)

而vtable 中对应项就不存储原本的虚函数了,而是存这个 thunk。

上面虚表中的两个函数地址最终都会落到 C::foo() 上面。

哪些时候需要地址偏移

  1. 第二基类指针指向子类时,通过该指针调用由子类改写过的方法。需要将第二基类 this 指针偏移到子类 this。
  2. 子类指针指向子类时,通过该指针调用子类从第二基类继承来但未改写的方法。需要将子类 this 指针偏移到第二基类 this。
  3. 子类重写了基类的虚函数,并且返回值类型更加具体(协变),又用基类指针指向子类并调用前面提到的虚函数。如果返回值以引用/指针出现,而且 this 需要偏移,则还需要做额外处理。可能的处理方式:thunk 里先调用真实函数再对返回值偏移后,返回结果。

thunk 的替代

  1. 对于足够短的函数,可以直接生成一份新的函数,在函数中有不同的 this 地址调整操作。
  2. 也可以使用多入口点。函数一开始就做调整 this 指针的操作,然后跳转到共享代码。对于不需要调整 this 指针的情况,vtable 中可以直接留下共享代码的入口地址;对需要调整 this 指针的情况,vtable 中留下调整 this 指针代码的地址。在双继承场景下,这种处理不会引入额外的跳转,相当于 thunk 紧密排列在真实函数之前。
  3. 微软使用的是 address point,也就是先调整完 this 指针再传入,而不是在虚函数中调整 this。