19

本の効果的なc ++のステートメントを理解しようとしています。以下は、多重継承の継承図です。

ここに画像の説明を入力

ここに画像の説明を入力

現在、この本には、vptr には各クラスに個別のメモリが必要であると書かれています。また、次のステートメントを作成します

上の図の奇妙な点は、4 つのクラスが関係しているにもかかわらず vptr が 3 つしかないことです。実装は、必要に応じて 4 つの vptr を自由に生成できますが、3 つあれば十分です (B と D が vptr を共有できることがわかります)。ほとんどの実装では、この機会を利用して、コンパイラが生成するオーバーヘッドを削減します。

vptr の各クラスに個別のメモリが必要な理由がわかりませんでした。vptr は、継承タイプが何であれ、基本クラスから継承されることを理解していました。継承されたvptrを使用して結果のメモリ構造を示していると仮定すると、どのようにステートメントを作成できますか

B と D は vptr を共有できます

誰かが多重継承の vptr について少し明確にしてくれませんか?

  • クラスごとに個別の vptr が必要ですか?
  • また、上記が当てはまる場合、B と D が vptr を共有できるのはなぜですか?
4

5 に答える 5

29

あなたの質問は興味深いですが、最初の質問としては大きすぎることを目指しているのではないかと心配しています。よろしければ、いくつかの段階に分けてお答えします :)

免責事項: 私はコンパイラの作成者ではありません。この件については確かに調査しましたが、私の言葉は慎重に受け止める必要があります。不正確な点があります。そして、私は RTTI に精通していません。また、これは標準ではないため、私が説明するのは可能性です。

1. 継承の実装方法は?

注: アラインメントの問題は省略します。これは、ブロック間にパディングが含まれる可能性があることを意味するだけです。

ここでは、仮想メソッドを省略して、以下で継承がどのように実装されるかに集中しましょう。

実のところ、継承と構成には多くの共通点があります。

struct B { int t; int u; };
struct C { B b; int v; int w; };
struct D: B { int v; int w; };

次のようになります。

B:
+-----+-----+
|  t  |  u  |
+-----+-----+

C:
+-----+-----+-----+-----+
|     B     |  v  |  w  |
+-----+-----+-----+-----+

D:
+-----+-----+-----+-----+
|     B     |  v  |  w  |
+-----+-----+-----+-----+

衝撃的ですね:) ?

ただし、これは、多重継承を理解するのが非常に簡単であることを意味します。

struct A { int r; int s; };
struct M: A, B { int v; int w; };

M:
+-----+-----+-----+-----+-----+-----+
|     A     |     B     |  v  |  w  |
+-----+-----+-----+-----+-----+-----+

これらの図を使用して、派生ポインターをベース ポインターにキャストするとどうなるかを見てみましょう。

M* pm = new M();
A* pa = pm; // points to the A subpart of M
B* pb = pm; // points to the B subpart of M

前の図を使用すると、次のようになります。

M:
+-----+-----+-----+-----+-----+-----+
|     A     |     B     |  v  |  w  |
+-----+-----+-----+-----+-----+-----+
^           ^
pm          pb
pa

pbのアドレスが のアドレスとわずかに異なるという事実はpm、コンパイラによって自動的にポインター演算によって処理されます。

2. 仮想継承の実装方法は?

仮想継承は注意が必要です。単一のV(仮想の) オブジェクトが他のすべてのサブオブジェクトによって共有されるようにする必要があります。簡単なダイヤモンドの継承を定義しましょう。

struct V { int t; };
struct B: virtual V { int u; };
struct C: virtual V { int v; };
struct D: B, C { int w; };

表現を省略し、オブジェクト内でとサブパーツDの両方が同じサブオブジェクトを共有することに集中します。どうすればできますか?BC

  1. クラスのサイズは一定でなければならないことに注意してください
  2. 設計時に、B も C も、それらが一緒に使用されるかどうかを予測できないことに注意してください。

したがって、見つかった解決策は単純です。BCのポインター用のスペースのみを予約し、次のようVにします。

  • stand-alone をビルドする場合B、コンストラクターはヒープに を割り当てVます。これは自動的に処理されます。
  • Bの一部としてビルドする場合D、サブパーツは、コンストラクターがポインターをの場所に渡すBことを期待します。DV

C明らかに、と同じです。

ではD、最適化により、コンストラクターがVオブジェクト内の右側のスペースを予約できるようになります。これは、またはDから仮想的に継承しないため、示した図が得られます (まだ仮想メソッドはありません)。BC

B:  (and C is similar)
+-----+-----+
|  V* |  u  |
+-----+-----+

D:
+-----+-----+-----+-----+-----+-----+
|     B     |     C     |  w  |  A  |
+-----+-----+-----+-----+-----+-----+

Bfrom からto へのキャストは、単純なポインター演算よりも少しトリッキーであることに注意してください。単純なポインター演算ではなくA、ポインターをたどる必要があります。B

ただし、アップキャストという悪いケースがあります。にA戻る方法を教えてくださいB

この場合、マジックは によって実行されdynamic_castますが、これには何らかのサポート (つまり、情報) がどこかに格納されている必要があります。これは、いわゆるRTTI(ランタイム型情報) です。最初にそれが a の一部であるとdynamic_cast判断し、次に D のランタイム情報を照会して、サブオブジェクト内のどこに格納されているかを確認します。ADDB

サブオブジェクトがない場合は、B0 を返す (ポインター形式) か、bad_cast例外をスローします (参照形式)。

3. 仮想メソッドを実装するには?

一般に、仮想メソッドは、クラスごとの v-table (つまり、関数へのポインタのテーブル) と、オブジェクトごとのこのテーブルへの v-ptr によって実装されます。これは唯一の可能な実装ではなく、他の実装の方が高速であることが実証されていますが、単純であり、オーバーヘッドが予測可能です (メモリとディスパッチ速度の両方の点で)。

仮想メソッドを使用して単純な基本クラス オブジェクトを取得すると、次のようになります。

struct B { virtual foo(); };

コンピューターの場合、メンバー メソッドなどは存在しないため、実際には次のようになります。

struct B { VTable* vptr; };

void Bfoo(B* b);

struct BVTable { RTTI* rtti; void (*foo)(B*); };

から派生する場合B:

struct D: B { virtual foo(); virtual bar(); };

これで 2 つの仮想メソッドができました。1 つは overridesB::fooで、もう 1 つはまったく新しいメソッドです。コンピュータ表現は次のようになります。

struct D { VTable* vptr; }; // single table, even for two methods

void Dfoo(D* d); void Dbar(D* d);

struct DVTable { RTTI* rtti; void (*foo)(D*); void (*foo)(B*); };

BVTableとがどのように似ているかに注意してください ( を前DVTableに置いたので) ? 重要です!foobar

D* d = /**/;
B* b = d; // noop, no needfor arithmetic

b->foo();

foo呼び出しを機械語に (ある程度)翻訳してみましょう。

// 1. get the vptr
void* vptr = b; // noop, it's stored at the first byte of B

// 2. get the pointer to foo function
void (*foo)(B*) = vptr[1]; // 0 is for RTTI

// 3. apply foo
(*foo)(b);

これらの vptrs は、オブジェクトのコンストラクターによって初期化されます。 のコンストラクターを実行すると、次のようになりますD

  • D::D()B::B()何よりもまず、そのサブパーツを初期化するために呼び出します
  • B::B()vptrその vtable を指すように初期化してから、戻ります
  • D::D()vptrその vtable を指すように初期化し、B をオーバーライドします

したがって、vptrここでは D の vtable を指しているため、foo適用されたのは D でした。それBは完全に透明だったからです。

ここでは、B と D が同じ vptr を共有しています。

4. 多重継承の仮想テーブル

残念ながら、この共有は常に可能であるとは限りません。

まず、これまで見てきたように、仮想継承の場合、「共有」アイテムが最終的な完全なオブジェクトに奇妙に配置されます。したがって、独自の vptr があります。それは1です。

第 2 に、多重継承の場合、最初のベースは完全なオブジェクトと位置合わせされますが、2 番目のベースはそうすることができず (どちらもデータ用のスペースが必要です)、その vptr を共有できません。それは2です。

第 3 に、最初のベース完全なオブジェクトと一致しているため、単純な継承の場合と同じレイアウトが提供されます (同じ最適化の機会)。それは3です。

とても簡単ですよね?

于 2011-04-16T11:26:58.973 に答える
1

クラスに仮想メンバーがある場合、そのアドレスを見つける方法が必要です。これらは定数テーブル (vtbl) に収集され、そのアドレスは各オブジェクト (vptr) の非表示フィールドに格納されます。仮想メンバーへの呼び出しは、基本的に次のとおりです。

obj->_vptr[member_idx](obj, params...);

基本クラスに仮想メンバーを追加する派生クラスにも、それらの場所が必要です。したがって、それらのための新しい vtbl と新しい vptr です。継承された仮想メンバーへの呼び出しは引き続き

obj->_vptr[member_idx](obj, params...);

新しい仮想メンバーへの呼び出しは次のとおりです。

obj->_vptr2[member_idx](obj, params...);

ベースが仮想でない場合、2 番目の vtbl を最初の vtbl の直後に配置して、vtbl のサイズを効果的に増やすことができます。_vptr2 はもう必要ありません。したがって、新しい仮想メンバーへの呼び出しは次のようになります。

obj->_vptr[member_idx+num_inherited_members](obj, params...);

(非仮想) 多重継承の場合、1 つは 2 つの vtbl と 2 つの vptr を継承します。それらはマージできず、呼び出しはオブジェクトにオフセットを追加するように注意する必要があります (継承されたデータ メンバーが正しい場所で見つかるようにするため)。最初の基底クラス メンバーへの呼び出しは、

obj->_vptr_base1[member_idx](obj, params...);

そして2番目に

obj->_vptr_base2[member_idx](obj+offset, params...);

新しい仮想メンバーは、新しい vtbl に配置するか、最初のベースの vtbl に追加することができます (将来の呼び出しでオフセットが追加されないようにするため)。

ベースが仮想の場合、競合が発生する可能性があるため、新しい vtbl を継承されたものに追加することはできません (あなたが示した例では、B と C の両方が仮想関数を追加した場合、D はそのバージョンをどのように構築できますか?) .

したがって、A には vtbl が必要です。B と C には vtbl が必要ですが、A は両方の仮想ベースであるため、A の vtbl に追加することはできません。D には vtbl が必要ですが、B は D の仮想基底クラスではないため、B に追加できます。

于 2011-04-16T05:54:22.083 に答える
0

D には 2 つまたは 3 つの vptrs が必要だと思います。

ここで、A は vptr を必要とする場合と必要としない場合があります。B は、A と共有されるべきではないものを必要とします (A は事実上継承されるため)。C は、A と共有してはならないものを必要とします (同上)。D は、B または C の vftable を新しい仮想関数 (存在する場合) に使用できるため、B または C を共有できます。

私の古い論文「C++: Under the Hood」では、仮想基底クラスの Microsoft C++ 実装について説明しています。http://www.openrce.org/articles/files/jangrayhood.pdf

また、(MS C++) cl /d1reportAllClassLayout でコンパイルして、クラス メモリ レイアウトのテキスト レポートを取得できます。

ハッピーハッキング!

于 2011-06-27T22:01:07.100 に答える
0

それはすべて、コンパイラがメソッド関数の実際のアドレスをどのように把握するかに関係しています。コンパイラは、仮想テーブル ポインターがオブジェクトのベースからの既知のオフセット (通常はオフセット 0) にあると想定します。コンパイラは、各クラスの仮想テーブルの構造、つまり、仮想テーブル内の関数へのポインターを検索する方法も認識する必要があります。

クラス B とクラス C は、メソッドが異なるため、仮想テーブルの構造がまったく異なります。クラス D の仮想テーブルは、クラス B の仮想テーブルの後にクラス C のメソッドの追加データが続くように見えます。

クラス D のオブジェクトを生成すると、それを B へのポインター、C へのポインター、またはクラス A へのポインターとしてキャストできます。これらのポインターを、クラス D の存在さえ認識していないモジュールに渡すことができます。 、ただし、クラス B、C、または A のメソッドを呼び出すことはできます。これらのモジュールは、クラスの仮想テーブルへのポインタを見つける方法を知る必要があり、クラス B/C/A のメソッドへのポインタを仮想テーブル。そのため、クラスごとに個別の VPTR が必要です。

クラス D は、クラス B の存在とその仮想テーブルの構造を十分に認識しているため、その構造を拡張して、オブジェクト B から VPTR を再利用できます。

オブジェクト D へのポインターをオブジェクト B、C、または A へのポインターにキャストすると、実際には、特定の基本クラスに対応する vptr から開始するように、ポインターが何らかのオフセットで更新されます。

于 2011-04-16T05:54:32.117 に答える
0

vptrの各クラスに個別のメモリが必要な理由がわかりませんでした

実行時に、ポインタを介して (仮想) メソッドを呼び出すと、CPU はメソッドがディスパッチされる実際のオブジェクトについて何も知りません。あなたが持っている場合、変数 b は、または を介し​​てB* b = ...; b->some_method();作成されたオブジェクトを潜在的に指すことができます。これらの各クラスは、 に対して独自の実装 (オーバーライド) を提供できます。したがって、呼び出しは、またはb が指しているオブジェクトに応じて、から実装をディスパッチする必要があります。new B()new D()new E()EBDsome_method()b->some_method()BDE

オブジェクトの vptr により、CPU は、そのオブジェクトに対して有効な some_method の実装のアドレスを見つけることができます。各クラスは独自の vtbl (すべての仮想メソッドのアドレスを含む) を定義し、クラスの各オブジェクトはその vtbl を指す vptr で始まります。

于 2011-04-16T06:00:02.903 に答える