【C++】多重継承の仮想関数呼び出しはどう実現されるか?

2022-02-26

C++でクラスを多重継承した場合にどのようなコードが生成されて、 virtual 関数の呼び出しがどのようになるのかを調べた。 多重継承できるのはC++くらいだけど、JavaやC#などでインタフェースを使用する場合にも同じような動作が求められるので、どういう処理になるか推測できると思う。

サンプルソース

#include <cstdio>

class A {
char a;
public:
A(): a(1) {}
virtual void f() { printf("A::f, a=%d\n", a); }

void afunc() { f(); }
};

class B {
short b;
public:
B(): b(2) {}
virtual void f() { printf("B::f, b=%d\n", b); }

void bfunc() { f(); }
};

class C : public A, public B {
int c;
public:
C(): c(3) {}
virtual void f() { printf("C::f, c=%d\n", c); }
};


int main() {
C* p = new C();
p->afunc();
p->bfunc();
p->f();
delete p;
}

元となるクラス A, B と共通の virtual 関数 f、それを継承する C があるとする。 それぞれのクラスに A::afunc, B::bfunc のメンバ関数がある場合に、クラス C のインスタンス p に対して呼び出すとどうなるか。

C の第一の親クラスである A に対して(または単一継承の場合)は、 C インスタンスのメモリレイアウトや仮想関数テーブルの前半部分が A と同じ配置になっていることで、 afunc から C::f が呼び出された時に C のメンバーにアクセスできるのはわかる。

二番目以降に継承された B の場合、 B::bfunc 内では B インスタンスのメモリレイアウト・仮想関数テーブルに合っている必要があるが、そこから呼び出される C::fC に合っている必要がある。 これがどうやって実現されてるのか?

Cによる実装

C++のコードがどのようなマシン後にコンパイルされるかをCompiler Explorerで確認した:https://godbolt.org/z/MMec4oxqT

それを読んでCで再現してみた:

A部

#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>

typedef struct {
const struct VTableForA *vtable;
char a;
} A;
struct VTableForA {
void (*f)(A*);
};

void a_ctor(A* this_) {
void a_f(A* this_);
static const struct VTableForA vtableForA = {
a_f,
};
this_->vtable = &vtableForA;
this_->a = 1;
}

void a_f(A* this_) {
printf("A::f, a=%d\n", this_->a);
}

void afunc(A* this_) {
(*this_->vtable->f)(this_);
}

C++で仮装関数を持つクラスには仮装関数テーブル vtable がメンバ変数として挿入される。 コンストラクタに対応する a_ctor ではコードに記述された処理以外に、vtable の初期化も行われる。

afunc からは vtable 経由でメソッド f を取り出して呼び出しが行われることで、 継承したクラスであればオーバーライドされたメソッドが呼び出される。

B部

typedef struct {
const struct VTableForB *vtable;
short b;
} B;
struct VTableForB {
void (*f)(B*);
};

void b_ctor(B* this_) {
void b_f(B* this_);
static const struct VTableForB vtableForB = {
b_f,
};
this_->vtable = &vtableForB;
this_->b = 2;
}

void b_f(B* this_) {
printf("B::f, b=%d\n", this_->b);
}

void bfunc(B* this_) {
(*this_->vtable->f)(this_);
}

B も内容的には A と全く同じ。

C部

typedef struct {
// Aのメンバー
const struct VTableForC *vtable;
char a;
// Bのメンバー
const struct VTableForB *vtable2;
short b;
// Cのメンバー
int c;
} C;
struct VTableForC {
// A
void (*f)(C*);
// C
};

#define PTRADD(ptr, offset) (((uintptr_t)ptr) + offset)
#define C_TO_A(this_) ((A*)this_)
#define C_TO_B(this_) ((B*)PTRADD(this_, offsetof(C, vtable2)))
#define B_TO_C(this_) ((C*)PTRADD(this_, -offsetof(C, vtable2)))

void c_ctor(C* this_) {
a_ctor(C_TO_A(this_));
b_ctor(C_TO_B(this_));

void c_f(C* this_);
static const struct VTableForC vtableForC = {
c_f,
};
this_->vtable = &vtableForC;

void c_f_thunk(B* b);
static const struct VTableForB vtableForC2 = {
c_f_thunk,
};
this_->vtable2 = &vtableForC2;

this_->c = 3;
}

void c_f(C* this_) {
printf("C::f, c=%d\n", this_->c);
}
void c_f_thunk(B* b) {
c_f(B_TO_C(b));
}

C クラスに対応する構造体のレイアウトは A のメンバ、B のメンバに続けて、自分自身のメンバが並ぶ。 vtable は最初の親クラス A のものに追加される形になる。 B 側の仮装関数テーブル vtable2 は別に配置される。

コンストラクタ c_ctor では AB のコンストラクタ呼び出しが行われる。 メソッド呼び出し時にはポインタの変換が必要になる。 第一の親 A への変換 C_TO_A は型の変換のみなので実質元の値のままになるが、 C_TO_B では B メンバの先頭 vtable2 までのオフセットを加算してやる必要がある。

親クラスの初期化後に、 C クラス自身の仮装関数テーブルで上書きする。 B 側のテーブルに与える fA 側のもの c_f とは異なり、別の c_f_thunk となる。

サンクではなにをしているか? B 側の仮装関数テーブルから f が呼び出された場合に渡ってくる this_B なので、 C 内の B のメンバ開始位置 vtable2 を指している。 なので B_TO_C でポインタを戻す処理を挟んでから c_f に渡してやる (Compiler Explorerでは jmp .LTHUNK0 となっていて、どこに飛んでいるのかは不明だが多分…)。

メイン部

int main() {
  C* p = malloc(sizeof(*p));
  c_ctor(p);
  afunc(C_TO_A(p));
  bfunc(C_TO_B(p));
  (*p->vtable->f)(p);
  free(p);
}

afuncbfunc などの仮想関数じゃないメソッド呼び出しはコンパイル時に解決できるので、関数の直接呼び出しになる。 C インスタンスに対する f の呼び出しは第一の親の vtable 経由になる。

結果

2つ目以降に継承されたクラスにアップキャストする際にはポインタのオフセット操作が挟まり、 そこから呼び出される仮想関数は逆のオフセット操作をして実際の仮想関数呼び出しのジャンプが挟まる。

  • 細かくは、アップキャスト時はヌルチェックが入り nullptr が維持される