純粋な抽象クラスとインターフェースの実装


27

これはC ++標準では必須ではありませんが、たとえばGCCが純粋な抽象クラスを含む親クラスを実装する方法は、問題のクラスのすべてのインスタンス化にその抽象クラスのvテーブルへのポインタを含めることです。

当然、これは、このクラスのすべてのインスタンスのサイズを、それが持つすべての親クラスのポインターによって膨張させます。

しかし、多くのC#クラスと構造体には、基本的に純粋な抽象クラスである多くの親インターフェイスがあることに気付きました。sayのすべてのインスタンスがDecimal、さまざまなインターフェースすべてへの6つのポインターで肥大化していた場合、私は驚くでしょう。

それで、C#がインターフェースを異なる方法で実行する場合、少なくとも典型的な実装では、どのようにインターフェースを実行しますか(標準自体はそのような実装を定義しないかもしれません)。また、純粋な仮想親をクラスに追加するときに、C ++の実装にオブジェクトサイズの膨張を回避する方法がありますか?


1
C#は、通常、多分のvtableではなく、メタデータのかなり多くが取り付けられていたオブジェクトその大きなものと比較して
max630

idl逆アセンブラでコンパイルされたコードを調べることから始めることができます
-max630

C ++は、その「インターフェース」のかなりの部分を静的に実行します。比較IComparerCompare
Caleth

4
たとえば、GCCは、複数の基本クラスを持つクラスのオブジェクトごとに、vtableテーブルポインター(vtableのテーブルへのポインター、またはVTT)を使用します。そのため、各オブジェクトには、想像しているコレクションではなく、1つの余分なポインターしかありません。おそらく、実際には、コードの設計が不十分で、大規模なクラス階層が関係している場合でも、問題ではないことを意味します。
スティーブンM.ウェッブ

1
@ StephenM.Webb このSOの回答から理解した限り、VTTは仮想継承を使用した構築/破棄の順序付けにのみ使用されます。メソッドのディスパッチに参加せず、オブジェクト自体のスペースを節約しません。C ++アップキャストはオブジェクトスライスを効果的に実行するため、vtableポインターをオブジェクト以外に置くことはできません(MIでは、オブジェクトの中央にvtableポインターを追加します)。g++-7 -fdump-class-hierarchy出力を見て検証しました。
アモン

回答:


35

C#およびJava実装では、オブジェクトには通常、そのクラスへの単一のポインターがあります。これは、単一継承言語であるため可能です。クラス構造には、単一継承階層のvtableが含まれます。しかし、インターフェイスメソッドの呼び出しには、多重継承の問題もすべてあります。これは通常、実装されたすべてのインターフェイスの追加のvtableをクラス構造に入れることで解決されます。これにより、C ++の一般的な仮想継承の実装と比較してスペースが節約されますが、インターフェイスメソッドのディスパッチがより複雑になります。これは、キャッシングによって部分的に補うことができます。

たとえば、OpenJDK JVMでは、各クラスには、実装されたすべてのインターフェースのvtableの配列が含まれています(インターフェースvtableはitableと呼ばれます)。インターフェイスメソッドが呼び出されると、このインターフェイスのitableをこの配列で線形的に検索し、そのitableを介してメソッドをディスパッチできます。キャッシュは、各呼び出しサイトがメソッドディスパッチの結果を記憶するように使用されるため、この検索は、具体的なオブジェクトタイプが変更された場合にのみ繰り返す必要があります。メソッドディスパッチの擬似コード:

// Dispatch SomeInterface.method
Method const* resolve_method(
    Object const* instance, Klass const* interface, uint itable_slot) {

  Klass const* klass = instance->klass;

  for (Itable const* itable : klass->itables()) {
    if (itable->klass() == interface)
      return itable[itable_slot];
  }

  throw ...;  // class does not implement required interface
}

(OpenJDK HotSpot インタープリターまたはx86コンパイラーの実際のコードを比較します。)

C#(より正確にはCLR)は、関連するアプローチを使用します。ただし、ここではitablesにはメソッドへのポインターは含まれませんが、スロットマップです。クラスのメインvtable内のエントリを指します。Javaの場合と同様に、正しいitableを検索する必要があるのは最悪のシナリオに過ぎず、呼び出しサイトでキャッシュすることでほぼ常にこの検索を回避できることが期待されます。CLRはVirtual Stub Dispatchと呼ばれる手法を使用して、JITでコンパイルされたマシンコードにさまざまなキャッシュ戦略を適用します。擬似コード:

Method const* resolve_method(
    Object const* instance, Klass const* interface, uint interface_slot) {

  Klass const* klass = instance->klass;

  // Walk all base classes to find slot map
  for (Klass const* base = klass; base != nullptr; base = base->base()) {
    // I think the CLR actually uses hash tables instead of a linear search
    for (SlotMap const* slot_map : base->slot_maps()) {
      if (slot_map->klass() == interface) {
        uint vtable_slot = slot_map[interface_slot];
        return klass->vtable[vtable_slot];
      }
    }
  }

  throw ...;  // class does not implement required interface
}

OpenJDK-pseudocodeとの主な違いは、OpenJDKでは各クラスに直接または間接的に実装されたすべてのインターフェイスの配列があるのに対して、CLRはそのクラスに直接実装されたインターフェイスのスロットマップの配列のみを保持することです。したがって、スロットマップが見つかるまで、継承階層を上に向かって歩く必要があります。深い継承階層の場合、これによりスペースが節約されます。これらは、ジェネリックの実装方法が原因でCLRに特に関連します。ジェネリック特化の場合、クラス構造がコピーされ、メインvtableのメソッドがスペシャライゼーションに置き換えられます。スロットマップは引き続き正しいvtableエントリをポイントするため、クラスのすべての汎用特殊化の間で共有できます。

最後に、インターフェイスのディスパッチを実装する可能性がさらにあります。オブジェクトまたはクラス構造にvtable / itableポインターを配置する代わりに、基本的にはペアであるオブジェクトへのファットポインターを使用できます(Object*, VTable*)。欠点は、これによりポインターのサイズが2倍になり、アップキャスト(具象型からインターフェイス型へ)が解放されないことです。しかし、より柔軟で、間接性が少なく、クラスから外部にインターフェースを実装できることも意味します。関連するアプローチは、Goインターフェース、Rust特性、およびHaskell型クラスによって使用されます。

参考資料と詳細資料:

  • ウィキペディア:インラインキャッシュ。高価なメソッド検索を回避するために使用できるキャッシュアプ​​ローチについて説明します。通常、vtableベースのディスパッチには必要ありませんが、上記のインターフェイスディスパッチ戦略のようなより高価なディスパッチメカニズムには非常に望ましいです。
  • OpenJDK Wiki(2013):インターフェース呼び出し。itablesについて説明します。
  • Pobar、Neward(2009):SSCLI 2.0内部。本の第5章では、スロットマップについて詳しく説明しています。決して公開されませんでしたが、著者のブログで利用可能になりましたPDFリンクは以来、移動しました。この本はおそらくCLRの現在の状態を反映していないでしょう。
  • CoreCLR(2006):仮想スタブ派遣。In:Book Of The Runtime。高価な検索を回避するために、スロットマップとキャッシュについて説明します。
  • Kennedy、Syme(2001):.NET共通言語ランタイムのジェネリックの設計と実装。(PDFリンク)。ジェネリックを実装するためのさまざまなアプローチについて説明します。メソッドは特殊化される可能性があるため、vtablesを書き換える必要があるため、ジェネリックはメソッドディスパッチと対話します。

JavaとCLRがこれをどのように達成するかについての追加の詳細を楽しみにして、@ amonのすばらしい回答に感謝します!
クリントン

@Clintonいくつかの参考文献で投稿を更新しました。VMのソースコードを読むこともできますが、私はそれに従うのが難しいと感じました。私の参考文献は少し古いですが、何か新しいものを見つけたら、私は非常に興味があります。この回答は基本的に、ブログ投稿のために横になっていたメモの抜粋ですが、それを公開するために周りに行ったことはありませんでした:/
amon

1
callvirtCEE_CALLVIRTCoreCLRの AKA は、インターフェイスメソッドの呼び出しを処理するCIL命令です。ランタイムがこのセットアップを処理する方法について詳しく知りたい場合は、
jrh

callオペコードstaticメソッドに使用されることに注意してください。興味深いことにcallvirt、クラスがであっても使用されsealedます。
jrh

1
Re、「[C#]オブジェクトは通常、そのクラスへの単一のポインターを持っています... [C#は]単一継承言語だからです。」C ++でさえ、多重継承型の複雑なWebの可能性がすべてあるため、プログラムが新しいインスタンスを作成する時点で1つの型のみを指定することができます。理論的には、C ++コンパイラとランタイムサポートライブラリを設計して、クラスインスタンスがRTTIの複数のポインタ値を保持しないようにすることは可能です。
ソロモンスロー

2

当然、これは、このクラスのすべてのインスタンスのサイズを、それが持つすべての親クラスのポインターによって膨張させます。

「親クラス」が「基本クラス」を意味する場合、これはgccには当てはまりません(また、他のコンパイラでは期待していません)。

CがBから派生する場合、AがポリモーフィッククラスであるAから派生する場合、Cインスタンスには1つのvtableがあります。

コンパイラには、AのvtableのデータをBに、BのデータをCにマージするために必要なすべての情報があります。

次に例を示します。https//godbolt.org/g/sfdtNh

vtableの初期化は1つしかありません。

メイン関数のアセンブリ出力を注釈付きでコピーしました。

main:
        push    rbx

# allocate space for a C on the stack
        sub     rsp, 16

# initialise c's vtable (note: only one)
        mov     QWORD PTR [rsp+8], OFFSET FLAT:vtable for C+16

# use c    
        lea     rdi, [rsp+8]
        call    do_something(C&)

# destruction sequence through virtual destructor
        mov     QWORD PTR [rsp+8], OFFSET FLAT:vtable for B+16
        lea     rdi, [rsp+8]
        call    A::~A() [base object destructor]

        add     rsp, 16
        xor     eax, eax
        pop     rbx
        ret
        mov     rbx, rax
        jmp     .L10

参考のための完全なソース:

struct A
{
    virtual void foo() = 0;
    virtual ~A();
};

struct B : A {};

struct C : B {

    virtual void extrafoo()
    {
    }

    void foo() override {
        extrafoo();
    }

};

int main()
{
    extern void do_something(C&);
    auto c = C();
    do_something(c);
}

我々が取る場合は2つの基底クラスから直接サブクラスの継承の例のようにclass Derived : public FirstBase, public SecondBase、2つのvtableがあることができます。実行g++ -fdump-class-hierarchyしてクラスレイアウトを確認できます(リンクされたブログ記事にも表示されています)。Godboltは、2番目のvtableを選択するために、呼び出しの前に追加のポインター増分を表示します。
アモン
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.