vtable・vptr・仮想関数呼び出し¶
一言で言うと¶
仮想関数の「実行時に実際の型のメソッドが呼ばれる」魔法の正体は、関数ポインタの表(vtable)を型ごとに1つ作り、各オブジェクトが自分の型の表へのポインタ(vptr)を持つという単純な仕組みです。
※ これは標準の要求ではなく主流実装(MSVC/GCC/Clang)の説明です。標準は「仮想呼び出しの意味」だけを定め、実装方法は自由——ただし現実にはほぼ全処理系がこの方式です。
仕組みの図解¶
class Enemy {
public:
virtual ~Enemy() = default;
virtual void Attack();
virtual void Die();
int hp = 100;
};
class Dragon : public Enemy {
public:
void Attack() override; // 上書き
// Dieは上書きしない
};
型ごとに1つ(静的データ領域):
Enemy のvtable Dragon のvtable
[0] ~Enemy [0] ~Dragon
[1] Enemy::Attack [1] Dragon::Attack ← 上書きされたスロット
[2] Enemy::Die [2] Enemy::Die ← 継承のまま
オブジェクトごと(インスタンス内):
Enemy e: [vptr → Enemyのvtable][hp=100] sizeof = 8 + 4 (+パディング) = 16
Dragon d: [vptr → Dragonのvtable][hp=100] ← vptrはコンストラクタが設定する
Enemy* e = &d;
e->Attack();
// コンパイル結果(概念):
// 1. vptrを読む (メモリアクセス1回)
// 2. vtable[1]を読む (メモリアクセス1回)
// 3. その先を呼ぶ (間接call)
// 非仮想なら: call Enemy::Attack(アドレス直埋め、1命令)
検証済みサンプル: samples/vtable_size.cpp(vptrによるsizeof変化の観察)
この仕組みから導かれること¶
- サイズ: 仮想関数を1つでも持つと、インスタンスあたりポインタ1個分(64bitで8バイト)増える。仮想関数の数は無関係(表は型ごとに1つ)。10万個の弾に仮想関数を持たせると800KB+キャッシュ効率低下
- コスト: 間接呼び出し自体は数サイクル。本当のコストは (a) インライン化を阻害する(呼び先が実行時まで不明→展開できない→連鎖最適化も止まる)、(b) 分岐予測ミスの可能性、(c) vtableアクセスのキャッシュミス
- コンストラクタ中の仮想呼び出しが「その時点の型」になる理由: vptrは構築の進行に合わせて書き換わる(基底コンストラクタ実行中は基底のvtableを指している)
- 多重継承では vptr が複数になり、キャストでthisポインタの調整(アドレスのずらし)が入る——「多重継承は複雑」の実装面の根拠
- デバッガで「vtableが壊れている」クラッシュ(解放済みオブジェクトへの仮想呼び出し=UAFの典型症状)を読めるようになる
脱仮想化(devirtualization)¶
コンパイラが実際の型を証明できると、仮想呼び出しを直接呼び出しに変えてインライン化まで進めます。
「仮想関数=常に遅い」ではなく、最適化器に証明材料を与えられるかの問題でもあります。
C#との対応¶
- C#のvirtualメソッドも同様の仕組み(メソッドテーブル)。ただしC#は全オブジェクトが型情報ポインタを持つ(GC・リフレクションのため)ので、「virtualを避けてもオブジェクトヘッダは消えない」。C++は仮想関数を持たない型なら本当にただのデータになる——この「払わない選択ができる」ことがC++の特徴
- C#のinterface呼び出し・delegateも間接呼び出しで、コスト構造の直感は共通
Unity開発者が誤解しやすい点¶
- 「virtualは遅いから禁止」— フレーム数千回の呼び出しでは測定誤差。数十万回のループの内側で初めて設計問題になる(そこはECS/データ指向の領域)
- 「C++のクラスは全部重い」— 仮想関数がなければヘッダすらないゼロコスト。C#の感覚より軽い
- MonoBehaviourのUpdateが「仮想関数だから遅い」— 実際はリフレクション由来の呼び出し機構で、これとは別の話
ゲーム開発での使用例¶
- インターフェース境界(IWeapon等)は仮想で設計し、内側のホットループ(弾の移動)は非仮想・データ指向で書く——境界は仮想、内側は直接が実務のバランス
- プロファイラで仮想呼び出しがボトルネックと出たら: 型でソートして分岐予測を助ける/finalで脱仮想化/Type Objectやswitchへ構造変更
理解度チェック¶
- vtableは「何ごと」に、vptrは「何ごと」に存在しますか。
- 仮想呼び出しの本当のコストは間接callそのものより何ですか。
- 仮想関数を持つ型のsizeofが増える理由と増加量は?
演習¶
samples/vtable_size.cpp で (a) 仮想関数の有無によるsizeofの変化、(b) 仮想関数を2個→5個に増やしてもsizeofが変わらないことを確認してください。godbolt.orgで e->Attack() と d.Attack()(具象型)の生成コードの違いも観察を推奨。
前: テンプレートの実体化とinline | カテゴリ目次 | 次: RTTI・dynamic_cast・例外処理の仕組み