1. 導入:なぜポインタのアドレスが変わるのか?
C++で複数のクラスを継承する「多重継承」を行う際、プログラムの実行時に思わぬ挙動に悩まされたことはありませんか?実は、基底クラスのポインタで派生クラスのインスタンスを指すとき、コンパイラは内部でアドレスを調整しています。この調整役を担うのが「Thunk(サンク)」という小さなコード片です。この仕組みを知ることは、複雑なクラス設計におけるデバッグや、パフォーマンスのボトルネックを特定するために非常に重要です。
2. 基礎知識:多重継承とthisポインタの物理的なずれ
C++のメモリレイアウトでは、多重継承されたクラスのインスタンスは、基底クラスのメンバが順番に並ぶ構成になっています。
例えば、Base1とBase2を継承したDerivedクラスがある場合、メモリ上には「Base1の領域」「Base2の領域」「Derived独自の領域」が順に並びます。そのため、Base2のポインタ経由でDerivedを扱うと、Base1から見てBase2の分だけアドレスが後ろにずれることになります。この物理的な「ずれ」を解消し、正しく関数を呼び出す仕組みがThunkです。
3. 実装と解決策:コンパイラによる自動補正
コンパイラは、2つ目以降の基底クラスの仮想関数テーブル(vtable)に、通常の関数のアドレスではなく、Thunkへのポインタを格納します。
Thunkが実行されるとき、内部では「thisポインタを本来の派生クラスの先頭アドレスに戻す(減算する)」という処理が行われ、その後に本来の関数へジャンプします。これにより、開発者は意識することなく、どのインターフェースからでも正しいメンバ変数にアクセスできるようになっています。
4. サンプルプログラム
以下のコードで、多重継承時の挙動を確認してみましょう。
include
struct Base1 {
virtual void func1() { std::cout << "Base1" << std::endl; }
};
struct Base2 {
virtual void func2() { std::cout << "Base2" << std::endl; }
};
// 多重継承:DerivedはBase1とBase2のメモリ領域を併せ持つ
struct Derived : Base1, Base2 {
void func2() override {
std::cout << "Derived's func2 (Thunk経由で呼ばれる)" << std::endl;
}
};
int main() {
Derived d;
Base2 b2 = &d; // ここでDerivedからBase2へのキャストが発生
// Base2として呼び出すが、内部でThunkが動き、
// 正しいDerivedのアドレスを指すように調整される
b2->func2();
return 0;
}
5. 応用・注意点:パフォーマンスへの影響
Thunkの存在は、単なる「アドレス調整」以上の意味を持ちます。
注意すべき点として、Thunkは間接ジャンプ(jmp)を伴うため、CPUの分岐予測キャッシュ(BTB)を消費します。頻繁に呼び出される「ホットループ」内で多重継承のインターフェースを経由した仮想関数呼び出しを行うと、この小さなコストが積み重なり、パフォーマンス低下を招くことがあります。
高頻度で呼ばれる処理では、多重継承を避けてコンポジション(メンバ変数として保持する設計)に切り替えるか、可能な限り仮想関数の呼び出し回数を減らす設計を検討してみてください。複雑な継承関係は便利ですが、その裏側にあるコストを理解しておくことが、上級エンジニアへの第一歩です。

コメント