仮想性とオーバーヘッド

誰もが継承とは何かを知っているか、少なくともそれについて聞いたことがあると思います。 多くの場合、オブジェクトの多態的な動作に継承を使用します。 しかし、仮想化のために支払う必要のある価格について考えますか? 質問を別の方法で提示します。誰もがこの価格を知っていますか? この問題を理解してみましょう。


一般に、継承は次のようになります。

class Base { int variable; }; class Child: public Base { }; 

同時に、気づいているように、ChildクラスはBaseクラスのすべてのメンバーを継承します。 つまり オブジェクトサイズに関しては、sizeof(Base)= sizeof(Child)であり、4(sizeof(int)= 4であるため)です。

アライメントとは何かをすぐに思い出させても害はありません。 次の2つのクラスがあります。

 class A1 { int iv; double dv; int iv2; }; class A2 { double dv; int iv; int iv2; }; 

それらは互いに違いはないようです。 ただし、サイズは同じではありません:sizeof(A2)= 16、sizeof(A1)= 24。

クラス内の変数の場所がすべてです。 タイプが異なる場合、それらの位置はオブジェクトのサイズに深刻な影響を与える可能性があります。 この場合、sizeof(double = 8)、つまり8 + 4 + 4 = 16ですが、同時にクラスA1のサイズが大きくなります。 すべての理由:



その結果、8バイトが余分に表示されます。これは、doubleが中央にあったために追加されたものです。 2番目の場合、画像は次のようになります。


しかし、おそらくあなたはすでにそれを知っていました。

ここで、クラス内の仮想関数の支払い方法を思い出しましょう。 仮想メソッドテーブルを覚えているかもしれません。 C ++標準は、実行時に関数のアドレスを計算するための単一の実装を提供しません。 すべては、少なくとも1つの仮想関数がある各クラスにポインターがあるという事実に帰着します。

1つの仮想関数をBaseクラスに追加して、サイズがどのように変化するかを見てみましょう。

 class Base { int variable; virtual void f() {} }; class Child: public Base { }; 

サイズは16になりました。8-ポインターサイズ4-intと位置合わせ。 32ビットアーキテクチャでは、サイズは8になります。4-ポインター+ 4 int、アライメントなし。

あなたが言葉を信じる必要がないように、ここにホッパー逆アセンブラーv4によって生成されたコードがあります:

//ソースコード
 class Base { public: int variable; virtual void f() {} Base(): variable(10) {} }; // main Base a; 

アセンブラーコード:

  ; Variables: ; var_8: -8 __ZN4BaseC2Ev: // Base::Base() 0000000100000f70 push rbp ; CODE XREF=__ZN4BaseC1Ev+16 0000000100000f71 mov rbp, rsp 0000000100000f74 mov rax, qword [0x100001000] 0000000100000f7b add rax, 0x10 0000000100000f7f mov qword [rbp+var_8], rdi 0000000100000f83 mov rdi, qword [rbp+var_8] 0000000100000f87 mov qword [rdi], rax 0000000100000f8a mov dword [rdi+8], 0xa 0000000100000f91 pop rbp 0000000100000f92 ret 

仮想関数がない場合、アセンブラコードは次のようになります。

  ; Variables: ; var_8: -8 __ZN4BaseC2Ev: // Base::Base() 0000000100000fa0 push rbp ; CODE XREF=__ZN4BaseC1Ev+16 0000000100000fa1 mov rbp, rsp 0000000100000fa4 mov qword [rbp+var_8], rdi 0000000100000fa8 mov rdi, qword [rbp+var_8] 0000000100000fac mov dword [rdi], 0xa 0000000100000fb2 pop rbp 0000000100000fb3 ret 

2番目のケースでは、アドレスのレコードがなく、変数は8バイトのオフセットなしで書き込まれていることがわかります。

アセンブラが気に入らない人のために、メモリ内でどのように見えるかを推測しましょう。

 #include <iostream> #include <iomanip> using namespace std; const int memorysize = 16; class Base { public: int variable; //virtual void f() {} Base(): variable(0xAAAAAAAA) {} //       }; class Child: public Base { }; void PrintMemory(const unsigned char memory[]) { for (size_t i = 0; i < memorysize / 8; ++i) { for (size_t j = 0; j < 8; ++j) { cout << setw(2) << setfill('0') << uppercase << hex << (int)(memory[i * 8 + j]) << " "; } cout << endl; } } int main() { unsigned char memory[memorysize]; memset(memory, 0xFF, memorysize * sizeof(unsigned char)); //   FF new (memory) Base; //       memory PrintMemory(memory); reinterpret_cast<Base *>(memory)->~Base(); return 0; } 

結論:

 AA AA AA AA FF FF FF FF FF FF FF FF FF FF FF FF 

仮想機能のコメントを外して、結果を楽しんでください。

 E0 30 70 01 01 00 00 00 AA AA AA AA FF FF FF FF 

これをすべて覚えたので、仮想継承について話しましょう。 C ++で多重継承が可能であることは秘密ではありません。 これは、不適切な手で触れないほうが良い強力な機能です-それは良いものにつながりません。 しかし、悲しいことについては話しましょう。 多重継承の最も一般的な問題は、ダイヤモンドの問題です。

 class A; class B: public A; class C: public A; class D: public B, public C; 

クラスDでは、クラスAの重複メンバーを取得します。何が問題なのですか? クラスのサイズがクラスAのサイズの余分なnバイトだけ増加することを考慮しなくても、クラスAの関数を呼び出すときに曖昧さが生じるのは悪いことです-どの関数を呼び出すかは明確ではありません:B :: A :: funcまたはC :: A :: func。 このようなあいまいさは、明示的な呼び出しによっていつでも排除できますが、これはあまり便利ではありません。 これは、仮想継承が作用する場所です。 クラスAの重複を受け取らないようにするために、仮想的にクラスAを継承します。

 class A; class B: public virtual A; class C: public virtual A; class D: public B, public C; 

今ではすべてが順調です。 かどうか? クラスAに仮想メソッドが1つしかない場合、クラスDはどのサイズになりますか?

 cout << sizeof(A) << " " << sizeof(B) << " " << sizeof(C) << " " << sizeof(D) << endl; 

ここではすべてがコンパイラに依存するため、これは興味深い質問です。 たとえば、プロジェクト設定がデフォルトのVisual Studio 2015では、4 8 8 12が生成されます。

つまり、クラスAのポインターごとに4バイト(以降、これらのポインターを省略してvtbAなど)、クラスBおよびC(vtbBおよびvtbC)の仮想継承のためにポインターごとに4バイトが追加されます。 最後にD:8 + 8-4では、vtbAは複製されないため、12が出てきます。

しかし、gcc 4.2.1は8 8 8 16を生成します。

結果は同じになるため、まず仮想継承のないケースを考えてみましょう。

vtbAでは8バイト、クラスBおよびCでは、これらのクラスの仮想テーブルへのポインターのみが保存されます。 仮想テーブルを複製していることがわかりましたが、相続人にvtbAを保存する必要はありません。 クラスDは、vtbBとvtbCの2つのアドレスを保存します。

 0000000100000f7f mov rax, qword [0x100001018] 0000000100000f86 mov rdi, rax 0000000100000f89 add rdi, 0x28 0000000100000f8d add rax, 0x10 0000000100000f91 mov rcx, qword [rbp+var_10] 0000000100000f95 mov qword [rcx], rax 0000000100000f98 mov qword [rcx+8], rdi 0000000100000f9c add rsp, 0x100000000100001018 dq 0x00000001000010a8 … __ZTV1D: // vtable for D 00000001000010a8 db 0x00 ; '.' ; DATA XREF=0x100001018 ... 00000001000010b0 dq __ZTI1D 00000001000010b8 db 0xc0 ; '.' ... 00000001000010c8 dq __ZTI1D 00000001000010d0 db 0xc0 ; '.' … 

何も分からない? 参照:2つのアドレスを0f95と0f98に保存します。 これらは、1018にあるアドレスに基づいて計算され、最初のケースでは0x28、2番目のケースでは0x10が加算されます。 合計で10b0と10d0が得られます。

次に、継承が仮想である場合を考えます。

アセンブラコードに関しては、ほとんど変更はありませんが、2つのアドレスがありますが、B、C、Dの仮想テーブルははるかに大きくなっています。 たとえば、クラスDのテーブルは7倍以上増加しています!

オブジェクトのサイズを節約しましたが、テーブルのサイズを増やしました。 しかし、一部の著者がアドバイスしているように、どこでも仮想継承を使用するとどうなりますか?

正確な参照は行いませんが、多重継承の概念が許可されている場合は、重複を避けるために常に仮想継承を使用する必要があることをどこかで読みます。

だから、私たちは額のアドバイスに従うようになります:

 class A; class B: public virtual A; class C: public virtual A; class D: public virtual B, public virtual C; 

サイズDはどれくらい変わりますか?

Visual Studio 2015は4 8 8 16を出力します。つまり、別のポインターがクラスDに追加されます。実験により、各クラスから仮想的に継承すると、スタジオが現在のクラスに別のポインターを追加することがわかりました。 たとえば、次のように書いた場合:

 class D: public virtual B, public C; 

または:

 class D: public B, public virtual C; 

サイズは12バイトのままです。

スタジオがメモリを節約するとは思わないでください。そうではありません。 標準設定では、ポインターのサイズは4バイトであり、gccのように8バイトではありません。 したがって、結果に2を掛けます。

gcc 4.2.1はどうですか? オブジェクトのサイズはまったく変更されず、出力は同じです-8 8 8 16.しかし、Dのテーブルに何が起こったのか想像してみてください。

実際、彼女はもちろん増加しましたが、有意ではありませんでした。 別の質問は、これがすべて後続の階層にどのように影響するかです。

純粋な実験として(これが実用的かどうかは考えません)、このような階層で何が起こるかを確認しましょう。

 class A { virtual void func() {} }; class B: public virtual A { }; class C: public virtual A { }; class D: public virtual B, public virtual C { }; class E: public virtual B, public virtual C, public virtual D { }; 

スタジオでは、クラスEのサイズが4増加し、すでにわかっています。gccでは、DとEのサイズは16バイトになります。

ただし、同時に、クラスEの仮想テーブルのサイズ(すべての仮想継承を削除するとかなり大きくなります)は4倍になります! すべてを正しく計算すれば、すでに0.5キロバイト程度に達します。

どのような結論を出すことができますか? 以前と同じ:多重継承は非常に慎重に使用する必要があります。仮想継承は万能薬ではありません。 おそらく、インターフェースについて考え、一般的な仮想継承を放棄する価値があるでしょう。

Source: https://habr.com/ru/post/J327052/


All Articles