コンピュータプログラミングにおいて、仮想メソッドテーブル(VMT)、仮想関数テーブル、仮想呼び出しテーブル、ディスパッチテーブル、vtable、またはvftableは、プログラミング言語で動的ディスパッチ(または実行時メソッドバインディング)をサポートするために使用されるメカニズムです。
クラスが仮想関数(またはメソッド)を定義すると、ほとんどのコンパイラは、仮想メソッドテーブルと呼ばれる、(仮想)関数へのポインタの配列を指す隠しメンバ変数をクラスに追加します。これらのポインタは、実行時に適切な関数実装を呼び出すために使用されます。コンパイル時には、基底関数が呼び出されるのか、基底クラスを継承するクラスによって実装された派生関数が呼び出されるのかがまだわからない場合があるためです。
このような動的ディスパッチを実装する方法は数多くありますが、仮想メソッドテーブルの使用は、C++や関連言語 ( DやC#など) で特に一般的です。Visual BasicやDelphiのように、オブジェクトのプログラムインターフェースと実装を分離する言語も、このアプローチを採用する傾向があります。これは、異なるメソッドポインタセットを使用するだけで、オブジェクトが異なる実装を使用できるためです。この方法では、他の手法ではできないかもしれない外部ライブラリの作成が可能です。[ 1 ]
プログラムに継承階層に 3 つのクラス (スーパークラス、Cat、および 2 つのサブクラス、 、HouseCat)があるとしますLion。クラスCat はという名前の仮想関数を定義しているspeak()ため、そのサブクラスは適切な実装 (たとえば、 またはのいずれmeow()かroar()) を提供できます。プログラムがCat参照 ( のインスタンス、またはまたはのインスタンスを参照できます)に対してspeak関数を呼び出すとき、コードは呼び出しをディスパッチする関数の実装を判断できなければなりません。これは、オブジェクトの実際のクラスに依存し、参照のクラス ( ) には依存しません。クラスは一般に静的に(つまりコンパイル時に )決定できないため、コンパイラもその時点でどの関数を呼び出すかを決定することはできません。代わりに、呼び出しは動的に(つまり実行時に )適切な関数にディスパッチする必要があります。CatHouseCatLionCat
オブジェクトの仮想メソッドテーブルには、そのオブジェクトに動的にバインドされたメソッドのアドレスが格納されます。メソッド呼び出しは、オブジェクトの仮想メソッドテーブルからメソッドのアドレスを取得することによって実行されます。仮想メソッドテーブルは、同じクラスに属するすべてのオブジェクトで同じであるため、通常はそれらの間で共有されます。型が互換性のあるクラス(たとえば、継承階層の兄弟クラス)に属するオブジェクトは、同じレイアウトの仮想メソッドテーブルを持ちます。つまり、特定のメソッドのアドレスは、すべての型が互換性のあるクラスで同じオフセットに表示されます。したがって、仮想メソッドテーブル内の特定のオフセットからメソッドのアドレスを取得すると、オブジェクトの実際のクラスに対応するメソッドが取得されます。[ 2 ]
C ++規格では、動的ディスパッチの実装方法について厳密な規定はないが、コンパイラは一般的に、同じ基本モデルに若干のバリエーションを加えたものを使用している。
通常、コンパイラはクラスごとに個別の仮想メソッドテーブルを作成します。オブジェクトが作成されると、このテーブルへのポインタ(仮想テーブルポインタ、vpointer 、またはVPTRと呼ばれる)が、オブジェクトの隠しメンバとして追加されます。そのため、コンパイラは各クラスのコンストラクタに「隠し」コードを生成し、新しいオブジェクトの仮想テーブルポインタをそのクラスの仮想メソッドテーブルのアドレスに初期化する必要があります。
多くのコンパイラは仮想テーブルポインタをオブジェクトの最後のメンバーとして配置しますが、他のコンパイラは最初のメンバーとして配置します。移植可能なソースコードはどちらの場合でも動作します。[ 3 ] 例えば、g++ は以前はポインタをオブジェクトの末尾に配置していました。[ 4 ]
C++における以下のクラス宣言について考えてみましょう。
import std ;class Base1 { private : int b1 = 0 ; public : explicit Base1 ( int b1 ) : b1 { b1 } {}virtual ~ Base1 () = default ;void nonVirtual () { std :: println ( "Base1::nonVirtual() が呼び出されました!" ); }virtual void fn1 () { std :: println ( "Base1::fn1() が呼び出されました!" ); } };class Base2 { private : int b2 = 0 ; public : explicit Base2 ( int b2 ) : b2 { b2 } {}virtual ~ Base2 () = default ;virtual void fn2 () { std :: println ( "Base2::fn2() が呼び出されました!" ); } };class Derived : public Base1 , public Base2 { private : int d = 0 ; public : explicit Derived ( int b1 , int b2 , int d ) : Base1 ( b1 ), Base2 ( b2 ), d { d } {}~派生() =デフォルト;void fn3 () { std :: println ( "Derived::fn3() が呼び出されました!" ); }void fn2 () override { std :: println ( "Derived::fn2() が呼び出されました!" ); } };int main () { Base2 * base2 = new Base2 (); Derived * derived = new Derived ();// ...base2を削除; derivedを削除; }GCCの g++ 3.4.6 は、オブジェクトに対して次の 32 ビット メモリ レイアウトを生成しますbase2: [ nb 1 ]
b2: +0: Base2 の仮想メソッドテーブルへのポインタ +4: b2の値 Base2の仮想メソッドテーブル: +0: Base2::fn2()
オブジェクトのメモリレイアウトは以下のとおりですderived。
派生: +0: Derived の仮想メソッドテーブルへのポインタ (Base1 用) +4: b1の値 +8: Derived の仮想メソッドテーブルへのポインタ (Base2 用) +12: b2の値 +16: dの値 合計サイズ:20バイト。 Derived の仮想メソッドテーブル (Base1 用): +0: Base1::fn1() // Base1::fn1() はオーバーライドされていません Derived の仮想メソッドテーブル (Base2 用): +0: Derived::fn2() // Base2::fn2() は Derived::fn2() によってオーバーライドされます
virtual宣言にキーワードが含まれていない関数(やnonVirtual()など)は、通常、仮想メソッドテーブルには表示されません。デフォルトコンストラクタd()などの特殊なケースには例外があります。
また、基底クラスおよびの仮想デストラクタにも注意してください。これらは、 がまたは型へのポインタまたは参照である場合、 だけでなくおよび のメモリも解放できるようにするために必要です。例を簡潔にするために、これらはメモリレイアウトから除外されています。[注2 ]Base1Base2delete derived;DerivedBase1Base2derivedBase1B2
fn2()クラス内のメソッドのオーバーライドは、Derivedの仮想メソッドテーブルを複製しBase2、へのポインタをBase2::fn2()へのポインタに置き換えることによって実装されますDerived::fn2()。
g++ コンパイラは、クラスとクラスの多重継承を、各基底クラスに 1 つずつ、2 つの仮想メソッド テーブルを使用して実装します。(多重継承を実装する方法は他にもありますが、これが最も一般的な方法です。)これにより、キャスト時に「ポインタ修正」(サンクとも呼ばれます)が必要になります。Base1Base2Derived
次のC++コードを考えてみましょう。
Derived * derived = new Derived (); Base1 * base1 = derived ; Base2 * base2 = derived ;derivedこのコードの実行後、とbase1は同じメモリ位置を指しますが、は(のメモリ位置から 8 バイト先)base2の位置を指します。したがって、は のインスタンスのように見える領域、つまり、のインスタンスと同じメモリレイアウトを持つ領域を指します。derived + 8derivedbase2derivedBase2Base2
への呼び出しは、のvpointerを逆参照し、仮想メソッドテーブルでエントリを検索し、そのポインタを逆参照してコードを呼び出すことderived->fn1()によって処理されます。derivedDerived::Base1fn1
単一継承の場合(または単一継承のみを持つ言語の場合)、vpointer が常に最初の要素である場合derived(多くのコンパイラの場合と同様)、これは次の擬似 C++ に簡略化されます。
( * (( *派生)[ 0 ]))(派生)ここで、 は*derivedの仮想メソッド テーブルを指しDerived、 は[0]仮想メソッド テーブルの最初のメソッドを指します。 パラメータはオブジェクトへの" " ポインタderivedになります。this
より一般的なケースでは、Base1::fn1()またはを呼び出すことDerived::fn2()はより複雑になります。
// derived->fn1() を呼び出す( * ( * ( derived [ 0 ] /* Derived の仮想メソッド テーブルへのポインタ (Base1 用) */ )[ 0 ]))( derived )// derived->fn2() を呼び出す( * ( * ( derived [ 8 ] /* Derived の仮想メソッド テーブルへのポインタ (Base2 用) */ )[ 0 ]))( derived + 8 )への呼び出しでは、ポインタがパラメータとしてderived->fn1()渡されますBase1。 への呼び出しでは、ポインタがパラメータとしてderived->fn2()渡されますBase2。この 2 番目の呼び出しでは、正しいポインタを生成するために修正が必要です。 の場所は、Base2::fn2の仮想メソッド テーブルにありませんDerived。
それに比べて、への呼び出しはderived->fnonvirtual()はるかに簡単です。
( * Base1 :: fnonvirtual )(派生)仮想関数呼び出しでは、コンパイル済みのポインタへのジャンプである非仮想関数呼び出しと比較して、少なくとも追加のインデックス付き逆参照と、場合によっては「修正」追加が必要になります。そのため、仮想関数呼び出しは、非仮想関数呼び出しよりも本質的に遅くなります。1996年に行われた実験では、実行時間の約6~13%が正しい関数へのディスパッチに費やされていることが示されていますが、オーバーヘッドは最大50%にも達することがあります。[ 5 ]現代のCPUアーキテクチャでは、キャッシュが大幅に大きくなり、分岐予測も改善されているため、仮想関数のコストはそれほど高くないかもしれません。
さらに、 JITコンパイルが使用されていない環境では、仮想関数呼び出しは通常インライン化できません。場合によっては、コンパイラがデバーチャライゼーションと呼ばれる処理を実行できることがあります。この処理では、例えば、ルックアップと間接呼び出しが、インライン化された各本体の条件付き実行に置き換えられますが、このような最適化は一般的ではありません。
このオーバーヘッドを避けるため、コンパイラは通常、コンパイル時に呼び出しを解決できる場合は仮想メソッドテーブルの使用を避けます。
したがって、上記の呼び出しではfn1テーブル参照は不要となる可能性があります。コンパイラは、現時点でderivedは が のみを保持でき、をオーバーライドしていないことを判断できるからです。あるいは、コンパイラ(またはオプティマイザ)は、プログラムのどこにも をオーバーライドするのサブクラスが存在しないことを検出できるかもしれません。またはの呼び出しでは、実装が明示的に指定されているため、テーブル参照は不要となるでしょう(ただし、 ポインタの修正は依然として必要です)。DerivedDerivedfn1Base1fn1Base1::fn1Base2::fn2this
仮想メソッドテーブルは一般的に動的ディスパッチを実現するための優れたパフォーマンスのトレードオフですが、バイナリツリーディスパッチなどの代替手段もあり、いくつかの典型的なケースではより高いパフォーマンスを発揮しますが、トレードオフが異なります。[ 1 ] [ 6 ]
しかし、仮想メソッドテーブルでは、特別な「this」パラメータに対して単一のディスパッチしか許可されません。これは、 CLOS、Dylan、またはJuliaのように、すべてのパラメータの型をディスパッチ時に考慮できる多重ディスパッチとは対照的です。
仮想メソッドテーブルは、ディスパッチが既知のメソッドセットに限定されている場合にのみ機能するため、ダックタイピング言語(Smalltalk、Python 、 JavaScriptなど)とは対照的に、コンパイル時に構築される単純な配列に配置できます。
これらの機能のいずれか、または両方を提供する言語は、ハッシュテーブルで文字列を検索したり、その他の同等の方法を用いることで処理をディスパッチすることが多い。この処理を高速化するための様々な手法が存在する(例えば、メソッド名のインターニング/トークン化、検索のキャッシュ、ジャストインタイムコンパイルなど)。
{{cite web}}: CS1 maint: bot: 元の URL の状態が不明です (リンク)