動的ディスパッチとvtableの内部
仮想関数が「どこで呼び先を決めているか」を内部から押さえれば、継承時のレイアウトもインターフェース呼び出しのコストも見抜ける。vtableとfat pointerの実装差を原理から解き明かします。
- 1.動的ディスパッチはオブジェクト先頭のvtableポインタをたどり、関数表の固定スロットから呼び先アドレスを引いて間接呼び出しする二段の間接参照で実現される。
- 2.単一継承はvtableを拡張するだけで済むが、多重継承では複数のvtableとthisを補正するthunk、仮想継承ではvbaseオフセット表が要る。
- 3.C++はオブジェクトにvtableポインタを埋め込むが、RustやGoのインターフェースは(データ,vtable)の2語fat pointerで型とメソッド表を外付けにする。
静的束縛と動的束縛の分かれ目
オブジェクト指向 の多態性は、同じ呼び出し式が実際の型に応じて別の関数本体へ届く性質です。問題は「どの本体へ届けるか」を いつ 決めるかにあります。非仮想関数や通常の関数呼び出しは、呼び出し先がコンパイル時に確定するため、命令列にアドレスを直接埋め込めます。これが 静的束縛(static binding) で、インライン展開の対象にもなります。
対して仮想関数は、呼び先が「変数の宣言型」ではなく「実行時の実体の型」で決まります。基底クラス型の参照に派生クラスの実体が入っていれば、派生側のオーバーライドが呼ばれなければなりません。この決定は実行時にしか行えないため、コンパイラは呼び先アドレスを直接埋められず、実行時にアドレスを引いてから間接呼び出しする 仕掛けを生成します。これが 動的束縛(dynamic binding) であり、その標準的な実装が仮想関数テーブル、すなわち vtable です。
vtableとvptr:二段の間接参照
vtable は、あるクラスの仮想関数の呼び先アドレスを並べた 関数ポインタの配列 です。クラスごとに1つだけ作られ、読み取り専用データとして実行ファイルに置かれます。配列内の位置(スロット番号)は、その仮想関数を宣言した順に固定で割り当てられます。重要なのは、派生クラスは基底クラスのスロット順を必ず引き継ぐ ことです。基底で n 番だった foo() は、派生の vtable でも n 番に置かれ、中身だけがオーバーライド先のアドレスに差し替わります。だからコンパイラは「foo() は n 番」とだけ知っていれば、実体の型を知らなくても正しく呼べます。
各オブジェクトの先頭には、自分の型の vtable を指す隠しポインタ vptr(vtable pointer) が埋め込まれます。vptr はコンストラクタが「実際に構築された型の vtable」で初期化します。仮想呼び出し obj->foo() は、おおむね次の二段の間接参照に展開されます。
// obj->foo() の展開(foo はスロット n、this 渡しは省略表記)
vptr = *(obj + 0) // ① オブジェクト先頭から vtable のアドレスを読む
fn = *(vptr + n * 8) // ② vtable の n 番スロットから関数アドレスを読む
call fn(obj, args...) // ③ そのアドレスへ間接呼び出し
このため動的ディスパッチのコストは「2回のメモリ読み込み+1回の間接呼び出し」です。スロット番号 n は定数なので、実行時に名前を文字列で探す処理は一切ありません。
vtable はクラス共有の静的データで、同じクラスのインスタンスが100万個あっても vtable は1つです。一方 vptr は各オブジェクトに1個ずつ埋まるため、仮想関数を1つでも持つと全インスタンスが1ワード(64bit環境で8バイト)分太ります。メモリレイアウトとデータ局所性 の観点では、この vptr が構造体の先頭を占めることでアライメントや並びにも影響します。
単一継承:vtableを伸ばすだけ
単一継承は最も素直です。派生クラスのオブジェクトは「基底部分のレイアウトをそのまま先頭に置き、その後ろに派生固有のメンバを足した」形になります。基底のメンバオフセットが派生でもそのまま通用するので、基底型ポインタへのアップキャストはアドレスを変えずに済みます(this 補正が不要)。
vtable も同様に、基底のスロット列をそのまま受け継ぎ、派生で新たに宣言した仮想関数を末尾に追加します。オーバーライドされたスロットは中身だけ差し替わり、追加分は後ろに伸びるだけです。スロット番号が継承を越えて安定するのはこの構造のおかげで、基底* 経由でも 派生* 経由でも同じスロット番号で同じ関数に届きます。
多重継承:複数vtableとthis調整thunk
多重継承 になると話が一段複雑になります。1つのオブジェクトが2つの基底 A, B を持つ場合、A のサブオブジェクトと B のサブオブジェクトは同じオブジェクト内で 別々のオフセット に並びます。そして両方が仮想関数を持てば、オブジェクトには vptr が複数個(基底ごとに1つ)埋め込まれ、対応する vtable も複数個できます。
ここで生じるのが this ポインタのずれです。B のメソッドは「B サブオブジェクトの先頭」を this として受け取りたいのに、派生* から B* へキャストしたときのアドレスは派生オブジェクト先頭からずれています。これを吸収するのが thunk(サンク/調整コード) です。B 経由で仮想関数を呼ぶと、vtable のスロットは本来の関数ではなく「this を B サブオブジェクトの位置へ補正してから本物へジャンプする」小さなコード片を指します。
| 継承形態 | vptrの個数 | this補正 | 追加で必要なもの |
|---|---|---|---|
| 単一継承 | 1個 | 不要 | なし(vtable を末尾に拡張) |
| 多重継承 | 基底ごとに複数 | 副基底でずれる | this 調整 thunk |
| 仮想継承 | 複数あり得る | 実行時に変動 | vbase オフセット表 |
仮想継承:vbaseオフセットで実行時に位置を引く
ダイヤモンド継承(B と C がともに A を継承し、D が両方を継承)で A を1個に共有したい場合に使うのが 仮想継承(virtual inheritance) です。共有される基底 A は、派生オブジェクトの固定位置には置けません。D の中での A の位置と、B 単体での A の位置は異なり得るからです。そこで A のサブオブジェクトはオブジェクトの別の場所に1個だけ置かれ、各サブオブジェクトは「自分から共有 A までの距離」を 実行時に引く 必要があります。
この距離を保持するのが vbase オフセット(virtual base offset)で、多くの実装は vtable に隣接した領域へこのオフセットを格納します。仮想基底のメンバへアクセスするコードは、vtable 付近からオフセットを読み、それを this に足して共有 A の位置を求めます。固定オフセットで済む通常継承に比べ、仮想継承は1回余分なメモリ参照が入る分わずかに重く、レイアウトも複雑になります。これが「仮想継承は必要なときだけ使う」と言われる実装上の根拠です。
fat pointer:vtableを外付けにする流儀
C++ や Java の伝統的な方式は、vptr を オブジェクトの中に埋め込む(intrusive) 設計でした。これに対し Rust や Go のインターフェースは、メソッド表をオブジェクトの外に置く fat pointer(太いポインタ) 方式を採ります。
Rust の &dyn Trait は、(データへのポインタ, vtableへのポインタ) という 2ワードの組 です。実体(struct)自体には vptr が一切埋め込まれず、ある型をトレイトオブジェクトとして扱う瞬間に「その型 × そのトレイト」用の vtable へのポインタが付きます。vtable にはメソッドアドレスに加え、型のサイズ・アラインメント・デストラクタ(drop)も入ります。Go のインターフェース値も同様に (型情報itab, データ) の2語表現です。
| 観点 | 埋め込み型(C++ vtable) | fat pointer(Rust dyn / Go iface) |
|---|---|---|
| vptrの置き場 | オブジェクト内部の先頭 | ポインタの相方として外付け |
| 素のオブジェクト | vptr の分だけ太る | vptr を持たず素のまま |
| 呼び出しの間接段数 | オブジェクト→vtable→関数(2段) | fat pointer→vtable→関数(2段) |
| 1型が複数の表に属す | やや扱いづらい | トレイトごとに別 vtable で自然 |
この差は設計思想の違いです。埋め込み型はオブジェクトを渡せば常に動的ディスパッチできて便利ですが、全インスタンスが vptr 分太ります。fat pointer 型は素の値を静的ディスパッチ(ジェネリクス による単相化)で軽く扱い、必要なときだけ vtable を付けて動的化できるため、「使わないものにコストを払わない」という方針に合致します。なお呼び出し命令そのものは、いずれの方式でもアドレスを実行時に引いてからの間接呼び出しで、ABI・呼び出し規約 の上では普通の call に乗ります。
動的ディスパッチは2回のメモリ参照と間接呼び出しを伴い、しかも呼び先が実行時まで不定なのでインライン展開を妨げます。CPU の分岐予測(間接分岐予測)が当たれば実害は小さい一方、呼び先がばらつくとパイプラインがストールし、ホットループでは無視できない差になります。実装処理系は 多態的インラインキャッシュ や デバーチャライゼーション(呼び先が1種類と分かれば直接呼び出しへ降格)でこの間接コストを削ります。
「仮想関数呼び出しの仕組みを説明せよ」には、オブジェクト先頭の vptr → クラス共有の vtable の固定スロット → 関数アドレスを間接呼び出し の三段で答えます。「多重継承で何が増えるか」は vptr が基底ごとに複数になり、副基底の this を補正する thunk が要る、「仮想継承では」 共有基底の位置を vbase オフセットで実行時に引く まで言えると上級として十分です。
まとめ
動的ディスパッチの核心は、呼び先を宣言型ではなく実体の型で決めるために、vptr → vtable の固定スロット → 関数アドレス という二段の間接参照を踏むことです。スロット番号が継承を越えて安定するから、コンパイラは実体型を知らずに正しく呼べます。単一継承は vtable を末尾へ伸ばすだけで済みますが、多重継承では複数 vptr と this 調整 thunk、仮想継承では vbase オフセット表が要り、レイアウトは段階的に重くなります。C++ がオブジェクトに vptr を埋め込むのに対し、Rust や Go は (データ, vtable) の fat pointer でメソッド表を外付けにし、素の値の軽さと動的化の柔軟さを両立させます。どれも「実行時にアドレスを引いてから呼ぶ」という一つの原理の異なる実装表現にすぎません。
プログラミング Article
動的ディスパッチとvtableの内部を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
動的ディスパッチ
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 5
導入後に効く点
単一継承はvtableを拡張するだけで済むが、多重継承では複数のvtableとthisを補正するthunk、仮想継承ではvbaseオフセット表が要る。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 5
判断チェックリスト
- 自社の用途が「動的ディスパッチ / vtable」に近いか確認する。
- 強みである「動的ディスパッチはオブジェクト先頭のvtableポインタをたどり、関数表の固定スロットから呼び先アドレスを引いて間接呼び出しする二段の間接参照で実現される。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。