C++ Learning

第44回 vtable の可視化 ★目玉

仮想関数の正体を解剖します。p->speak() の裏では「オブジェクト → vptr → vtable → 関数アドレス」の 3 段ジャンプが起きています。各段階を可視化アニメで追いかけて、ポリモーフィズムがどう実装されているかを完全に理解しましょう。

このページで押さえること
✅ 最低限ここだけ覚える
  • 仮想関数を持つクラスのオブジェクトは先頭に vptr を持つ
  • vptr はクラスごとの vtable を指す
  • vtable は関数アドレスの表
  • 仮想呼び出し = 3 段の間接アクセス
⭐ 余裕があれば読む
  • vptr のメモリ位置
  • コンストラクタでの vtable 設定タイミング
  • 多重継承時の複数 vtable
  • コンパイラ最適化(devirtualization)

1. 3 段ジャンプを可視化

基底のポインタから仮想関数を呼ぶ様子を、段階ごとに追いかけます。

▶ p->speak() の 3 段ジャンプ
p の指すオブジェクト: step 0 / 4
Animal* p = new Dog(); p->speak();
① オブジェクト
② vtable(クラスごと)
③ 関数本体
Step ボタンで呼び出しの 3 段階を追いかけます。
3 段階の要約:
  1. ポインタ p がオブジェクトを指す
  2. オブジェクト先頭の vptr が vtable を指す
  3. vtable の該当スロットが実際の関数を指す

2. オブジェクトレイアウトの変化

仮想関数が1 つでもあると、そのクラスのオブジェクトは先頭に vptr(隠しポインタ)が追加されます。

仮想関数なしvptr なし
struct A { int x; void f(); // virtual でない }; // sizeof(A) == 4 (int 1 つぶん) // メモリ: [x (4B)]
仮想関数ありvptr 追加
struct B { int x; virtual void f(); // virtual }; // sizeof(B) == 16 (ポインタ 8B + int 4B + padding 4B) // メモリ: [vptr (8B)][x (4B)][padding]
vptr のサイズ: 64-bit 環境で 8 バイト、32-bit 環境で 4 バイト。これは「アラインメント」の都合でオブジェクトサイズがジャンプすることも。「vtable を使う代償はオブジェクト 1 つにつきポインタ 1 つ分」と覚えてください。

派生クラスでも vptr は 1 つ

派生クラスでは、基底の vptr が『派生クラスの vtable』を指すように差し替わるだけ。新たに vptr は増えません(単一継承の場合)。

3. vtable の中身

vtable はクラスごとに 1 つ存在する関数ポインタの配列。仮想関数の宣言順にスロットが並びます。

vtable イメージ概念
class Animal { virtual void speak(); // slot [0] virtual void eat(); // slot [1] }; class Dog : public Animal { void speak() override; // slot [0] // eat は基底のまま // slot [1] }; // vtable (概念): // Animal's vtable: [ &Animal::speak, &Animal::eat ] // Dog's vtable: [ &Dog::speak, &Animal::eat ]

派生クラスで上書きした関数はスロットの中身が差し替わり、上書きしなかった関数は基底のアドレスがそのまま残ります。だから基底ポインタから呼んでも、vtable 経由で自動的に実体の実装に飛びます。

呼び出しの疑似コード

コンパイラが生成する(概念)疑似
// p->speak() と書くと、コンパイラは概念的に… // 1. オブジェクトから vptr を取得 auto vptr = *(void**)p; // オブジェクト先頭 // 2. vtable のインデックス 0 の関数ポインタを取得 auto fn = vptr[0]; // speak() のスロット // 3. 関数を呼ぶ(this = p) fn(p);

1 回の呼び出しでメモリアクセスが 2 〜 3 回増える、というのが仮想関数のオーバーヘッド。

4. コンストラクタでの vptr 設定

オブジェクトを生成するとき、vptr の設定はコンストラクタの実行中に段階的に行われます。

Dog 生成時の vptr 変化段階的
Dog* d = new Dog(); // 1. メモリ確保 // 2. Animal のコンストラクタ開始: vptr → Animal's vtable // (この時点で仮想関数を呼ぶと Animal の版が呼ばれる) // 3. Animal のコンストラクタ終了 // 4. Dog のコンストラクタ開始: vptr → Dog's vtable に差し替え // (この時点以降、仮想関数は Dog の版が呼ばれる) // 5. Dog のコンストラクタ終了
重要な帰結: 基底クラスのコンストラクタ/デストラクタの中から仮想関数を呼ぶと、派生の実装は呼ばれず、基底の実装が呼ばれます。これは vptr がまだ(基底の)vtable を指しているため。初学者がハマるポイントのひとつ。
落とし穴ctor 内の仮想呼び出し
class Animal { public: Animal() { speak(); } // ← Animal::speak() が呼ばれる! virtual void speak() { std::cout << "(音)"; } virtual ~Animal() = default; }; class Dog : public Animal { public: void speak() override { std::cout << "ワン!"; } }; Dog d; // 出力: "(音)" ← Dog::speak は呼ばれない(想定外)
ここまでで vtable の仕組みは OK
最後は性能と最適化の話。普段は気にしなくていいが、知っておくと設計判断に役立ちます。

5. コストと最適化

仮想関数のコスト

ただし現代の CPU では普段は無視できるレベル。「ホットループで何百万回呼ぶ」場合だけ気にすればいい。

Devirtualization(仮想化除去)

コンパイラは可能な場合、仮想呼び出しを直接呼び出しに置き換える最適化を行います。

設計の指針:
  • 普通は仮想関数を気にせず使ってよい
  • 本当に継承が不要なら final を活用
  • 極端な性能が必要なら静的多態(テンプレート、CRTP、std::variant)を検討(STEP 8 と応用編)
広告スペース

確認クイズ

vtable を 4 問で確認。

Q1. 仮想関数を持つクラスのオブジェクトに追加される隠しメンバは?

vtable 本体
参照カウンタ
vptr(vtable へのポインタ)
型名の文字列
オブジェクトの先頭にvptr(仮想関数テーブルへのポインタ)が追加されます。vtable 自体はクラスごとに 1 つで、全インスタンスから共有されます。

Q2. vtable は何が保存されているか?

オブジェクトのメンバ変数
仮想関数の関数ポインタの配列
派生クラスの名前
参照カウント
vtable はクラスが持つ仮想関数の関数ポインタの配列。仮想関数の宣言順にスロットが並び、派生で上書きされた関数は該当スロットが差し替わります。

Q3. 基底クラスのコンストラクタ内で仮想関数を呼ぶとどうなる?

派生の実装が呼ばれる
基底の実装が呼ばれる(vptr はまだ基底を指している)
コンパイルエラー
未定義動作
コンストラクタは基底 → 派生の順で実行され、その途中では vptr はまだ基底の vtable を指しています。そのため基底 ctor 内で仮想関数を呼ぶと基底の版が呼ばれます。同じく派生の dtor が動いた後の基底 dtor でも同じ挙動。初学者がハマるポイント。

Q4. 仮想関数呼び出しのオーバーヘッドを避ける方法は?

常に new を使う
継承階層を深くする
final 指定や静的多態(テンプレート / CRTP)を使う
仮想関数を 2 つ以上用意する
final を付けるとコンパイラが仮想呼び出しを直接呼び出しに置き換えられる(devirtualization)。また、動的多態の代わりにテンプレートCRTP で静的多態を使うと、仮想呼び出しのコストそのものを無くせます。