これはC++標準では必須ではありませんが、たとえばGCCが純粋な抽象クラスを含む親クラスを実装する方法は、問題のクラスのすべてのインスタンス化にその抽象クラスのvテーブルへのポインターを含めることによるもののようです。
当然、これは、このクラスのすべてのインスタンスのサイズを、それが持つすべての親クラスへのポインタによって膨らませます。
しかし、多くのC#クラスと構造体には、基本的に純粋な抽象クラスである多くの親インターフェースがあることに気づきました。例えば Decimal
のすべてのインスタンスが、さまざまなインターフェイスへの6つのポインタで肥大化されているとしたら、私は驚くでしょう。
したがって、C#がインターフェイスを異なる方法で実行する場合、少なくとも典型的な実装では、どのようにそれらを実行しますか(標準自体がそのような実装を定義していない可能性があることを理解しています)?また、C++実装には、純粋な仮想親をクラスに追加するときにオブジェクトサイズの膨張を回避する方法がありますか?
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 interpreter または x86 compiler で実際のコードを比較してください。)
C#(より正確には、CLR)は、関連するアプローチを使用します。ただし、ここではitablesにはメソッドへのポインタは含まれていませんが、スロットマップです。これらはクラスのメインのvtable内のエントリをポイントしています。 Javaの場合と同様に、正しいitableを検索する必要があるのは最悪の場合のシナリオにすぎず、呼び出しサイトでのキャッシングによってこの検索をほぼ常に回避できると予想されます。 CLRは、仮想スタブディスパッチと呼ばれる手法を使用して、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疑似コードとの主な違いは、OpenJDKでは各クラスに直接または間接的に実装されたすべてのインターフェースの配列があるのに対し、CLRはそのクラスに直接実装されたインターフェースのスロットマップの配列しか保持しないことです。したがって、スロットマップが見つかるまで、継承階層を上方向に歩く必要があります。深い継承階層の場合、これによりスペースが節約されます。これらは、ジェネリックの実装方法により、CLRに特に関連しています。ジェネリックの特殊化では、クラス構造がコピーされ、メインのvtableのメソッドは特殊化に置き換えられます。スロットマップは正しいvtableエントリをポイントし続けるので、クラスのすべての一般的な特殊化の間で共有できます。
最後に、インターフェイスディスパッチを実装する可能性がさらにあります。オブジェクトまたはクラス構造にvtable/itableポインターを配置する代わりに、基本的に(Object*, VTable*)
のペアである、オブジェクトへの脂肪ポインターを使用できます。欠点は、これによりポインタのサイズが2倍になり、アップキャスト(具象型からインターフェイス型へ)が自由にならないことです。ただし、柔軟性が高く、間接性が少なく、インターフェイスをクラスの外部から実装できることも意味します。関連するアプローチは、Goインターフェース、Rustトレイト、およびHaskellタイプクラスによって使用されます。
参考文献と参考文献:
当然、これは、このクラスのすべてのインスタンスのサイズを、それが持つすべての親クラスへのポインタによって膨らませます。
「親クラス」が「基本クラス」を意味する場合、これはgccには当てはまりません(他のコンパイラーでも同様です)。
CがBから派生し、Aが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);
}