C++でクラスを多重継承した場合にどのようなコードが生成されて、 virtual
関数の呼び出しがどのようになるのかを調べた。
多重継承できるのはC++くらいだけど、JavaやC#などでインタフェースを使用する場合にも同じような動作が求められるので、どういう処理になるか推測できると思う。
サンプルソース
|
元となるクラス A
, B
と同名の virtual
関数 f
、それを継承する C
があるとする。
それぞれのクラスに A::afunc
, B::bfunc
のメンバ関数がある場合に、クラス C
のインスタンス p
に対して呼び出すとどうなるか。
C
の第一の親クラスである A
に対して(または単一継承の場合)は、 C
インスタンスのメモリレイアウトや仮想関数テーブルの前半部分が A
と同じ配置になっていることで、 afunc
から C::f
が呼び出された時に C
のメンバーにアクセスできるのはわかる。
二番目以降に継承された B
の場合、 B::bfunc
内では B
インスタンスのメモリレイアウト・仮想関数テーブルに合っている必要があるが、そこから呼び出される C::f
は C
に合っている必要がある。
これがどうやって実現されてるのか?
Cによる実装
C++のコードがどのようなマシン後にコンパイルされるかをCompiler Explorerで確認した:https://godbolt.org/z/MMec4oxqT
それを読んでCで再現してみた:
A部
|
C++で仮想関数を持つクラスには仮想関数テーブル vtable
がメンバ変数として挿入される。
コンストラクタに対応する a_ctor
ではコードに記述された処理以外に、vtable
の初期化も行われる。
afunc
からは vtable
経由でメソッド f
を取り出して呼び出しが行われることで、
継承したクラスであればオーバーライドされたメソッドが呼び出される。
B部
typedef struct { |
B
も内容的には A
と全く同じ。
C部
typedef struct { |
C
クラスに対応する構造体のレイアウトは A
のメンバ、B
のメンバに続けて、自分自身のメンバが並ぶ。
vtable
は最初の親クラス A
のものに追加される形になる。
B
側の仮想関数テーブル vtable2
は別に配置される。
コンストラクタ c_ctor
では A
と B
のコンストラクタ呼び出しが行われる。
メソッド呼び出し時にはポインタの変換が必要になる。
第一の親 A
への変換 C_TO_A
は型の変換のみなので実質元の値のままになるが、 C_TO_B
では B
メンバの先頭 vtable2
までのオフセットを加算してやる必要がある。
親クラスの初期化後に、 C
クラス自身の仮想関数テーブルで上書きする。
B
側のテーブルに与える f
は A
側のもの 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);
}
afunc
や bfunc
などの仮想関数じゃないメソッド呼び出しはコンパイル時に解決できるので、関数の直接呼び出しになる。
C
インスタンスに対する f
の呼び出しは第一の親の vtable
経由になる。
結果
2つ目以降に継承されたクラスにアップキャストする際にはポインタのオフセット操作が挟まり、 そこから呼び出される仮想関数は逆のオフセット操作をして実際の仮想関数呼び出しのジャンプが挟まる。
- 細かくは、アップキャスト時はヌルチェックが入り
nullptr
は維持される