仮想関数と複数の継承の場合のオブジェクトレイアウト
-
19-09-2019 - |
質問
私は最近、仮想関数と複数の継承を伴うオブジェクトレイアウトについてのインタビューで尋ねられました。
複数の継承が関係なく実装される方法のコンテキストで説明しました(つまり、コンパイラが仮想テーブルを生成する方法、各オブジェクトの仮想テーブルへの秘密のポインターを挿入するなど)。
私の説明には何かが欠けているように思えました。
質問があります(以下の例を参照)
- クラスCのオブジェクトの正確なメモリレイアウトは何ですか。
- クラスCの仮想テーブルエントリ
- クラスA、B、およびCのオブジェクトのサイズ(Sizeofによって返される)(8、8、16 ??)
- 仮想継承が使用されている場合はどうなりますか。確かにサイズと仮想テーブルエントリに影響を与えるはずですか?
例コード:
class A {
public:
virtual int funA();
private:
int a;
};
class B {
public:
virtual int funB();
private:
int b;
};
class C : public A, public B {
private:
int c;
};
ありがとう!
解決
メモリレイアウトとVtableレイアウトは、コンパイラに依存します。たとえば、私のGCCを使用して、次のように見えます。
sizeof(int) == 4
sizeof(A) == 8
sizeof(B) == 8
sizeof(C) == 20
sizeof(int)とvtableポインターに必要なスペースは、コンパイラからコンパイラ、プラットフォーム、プラットフォームまでさまざまであることに注意してください。 Sizeof(c)== 20以外ではない理由は、GCCがAサブオブジェクトで8バイト、Bサブオブジェクトの8バイト、メンバーの4バイトを与えるためです。 int c
.
Vtable for C C::_ZTV1C: 6u entries 0 (int (*)(...))0 4 (int (*)(...))(& _ZTI1C) 8 A::funA 12 (int (*)(...))-0x00000000000000008 16 (int (*)(...))(& _ZTI1C) 20 B::funB Class C size=20 align=4 base size=20 base align=4 C (0x40bd5e00) 0 vptr=((& C::_ZTV1C) + 8u) A (0x40bd6080) 0 primary-for C (0x40bd5e00) B (0x40bd60c0) 8 vptr=((& C::_ZTV1C) + 20u)
仮想継承の使用
class C : public virtual A, public virtual B
レイアウトが変更されます
Vtable for C C::_ZTV1C: 12u entries 0 16u 4 8u 8 (int (*)(...))0 12 (int (*)(...))(& _ZTI1C) 16 0u 20 (int (*)(...))-0x00000000000000008 24 (int (*)(...))(& _ZTI1C) 28 A::funA 32 0u 36 (int (*)(...))-0x00000000000000010 40 (int (*)(...))(& _ZTI1C) 44 B::funB VTT for C C::_ZTT1C: 3u entries 0 ((& C::_ZTV1C) + 16u) 4 ((& C::_ZTV1C) + 28u) 8 ((& C::_ZTV1C) + 44u) Class C size=24 align=4 base size=8 base align=4 C (0x40bd5e00) 0 vptridx=0u vptr=((& C::_ZTV1C) + 16u) A (0x40bd6080) 8 virtual vptridx=4u vbaseoffset=-0x0000000000000000c vptr=((& C::_ZTV1C) + 28u) B (0x40bd60c0) 16 virtual vptridx=8u vbaseoffset=-0x00000000000000010 vptr=((& C::_ZTV1C) + 44u)
GCCを使用すると、追加できます -fdump-class-hierarchy
この情報を取得します。
他のヒント
複数の継承に期待することは、(通常は最初ではない)サブクラスにキャストするときにポインターが変化する可能性があることです。インタビューの質問にデバッグして答える際に注意すべきこと。
まず、多型クラスには少なくとも1つの仮想関数があるため、VPTRがあります。
struct A {
virtual void foo();
};
にコンパイルされています:
struct A__vtable { // vtable for objects of declared type A
void (*foo__ptr) (A *__this); // pointer to foo() virtual function
};
void A__foo (A *__this); // A::foo ()
// vtable for objects of real (dynamic) type A
const A__vtable A__real = { // vtable is never modified
/*foo__ptr =*/ A__foo
};
struct A {
A__vtable const *__vptr; // ptr to const not const ptr
// vptr is modified at runtime
};
// default constructor for class A (implicitly declared)
void A__ctor (A *__that) {
__that->__vptr = &A__real;
}
注:C ++は、C(CFRONTが行ったように)のような別の高レベル言語にコンパイルできます。 virtual
)。置いた __
コンパイラ生成名で。
これはaです 単純化 RTTIがサポートされていないモデル。実際のコンパイラは、サポートするためにvtableにデータを追加します typeid
.
今、単純な派生クラス:
struct Der : A {
override void foo();
virtual void bar();
};
非仮想(*)ベースクラスのサブオブジェクトは、メンバーのサブオブジェクトのようなサブオブジェクトですが、メンバーのサブオブジェクトは完全なオブジェクトです。それらの実際の(動的)タイプは、宣言されたタイプであり、基本クラスのサブオブジェクトは完全ではなく、建設中の実際のタイプが変化します。
(*)仮想メンバー関数は非仮想メンバーとは異なるように、仮想ベースは非常に異なります
struct Der__vtable { // vtable for objects of declared type Der
A__vtable __primary_base; // first position
void (*bar__ptr) (Der *__this);
};
// overriding of a virtual function in A:
void Der__foo (A *__this); // Der::foo ()
// new virtual function in Der:
void Der__bar (Der *__this); // Der::bar ()
// vtable for objects of real (dynamic) type Der
const Der__vtable Der__real = {
{ /*foo__ptr =*/ Der__foo },
/*foo__ptr =*/ Der__bar
};
struct Der { // no additional vptr
A __primary_base; // first position
};
ここで、「最初の位置」とは、メンバーが最初でなければならないことを意味します(他のメンバーは再注文することができます)。 reinterpret_cast
ポインター、タイプは互換です。ゼロ以外のオフセットでは、算術を使用してポインター調整を行う必要があります char*
.
調整の欠如は、生成されたコードの観点からは大したことではないように思えないかもしれません(即時のASM命令を追加するものもあります)が、それ以上のことを意味します。それは、そのようなポインターが異なるタイプを持つと見なすことができることを意味します:タイプのオブジェクト A__vtable*
ポインターを含めることができます Der__vtable
どちらかとして扱われます Der__vtable*
またはa A__vtable*
. 。同じポインターオブジェクトは、 A__vtable
タイプのオブジェクトを扱う関数 A
そして、aへのポインターとして Der__vtable
タイプのオブジェクトを扱う関数 Der
.
// default constructor for class Der (implicitly declared)
void Der__ctor (Der *__this) {
A__ctor (reinterpret_cast<A*> (__this));
__this->__vptr = reinterpret_cast<A__vtable const*> (&Der__real);
}
VPTRで定義されている動的タイプは、VPTRに新しい値を割り当てると構築中に変化します(この特定のケースでは、基本クラスのコンストラクターへの呼び出しは役に立たず、最適化できませんが、それはありません。 t些細なコンストラクターではない場合)。
複数の継承を伴う:
struct C : A, B {};
a C
インスタンスにはaが含まれます A
そしてa B
, 、そのように:
struct C {
A base__A; // primary base
B base__B;
};
これらの基本クラスのサブオブジェクトのうちの1つだけが、オフセットゼロに座る特権を持つことができることに注意してください。これは多くの点で重要です:
ポインターの他の基本クラス(アップキャスト)への変換には、調整が必要です。逆に、アップキャストには反対の調整が必要です。
これは、基本クラスのポインターで仮想呼び出しを行うとき、
this
派生クラスオーバーライダーに入力するための正しい値があります。
したがって、次のコード:
void B::printaddr() {
printf ("%p", this);
}
void C::printaddr () { // overrides B::printaddr()
printf ("%p", this);
}
にコンパイルできます
void B__printaddr (B *__this) {
printf ("%p", __this);
}
// proper C::printaddr taking a this of type C* (new vtable entry in C)
void C__printaddr (C *__this) {
printf ("%p", __this);
}
// C::printaddr overrider for B::printaddr
// needed for compatibility in vtable
void C__B__printaddr (B *__this) {
C__printaddr (reinterpret_cast<C*>(reinterpret_cast<char*> (__this) - offset__C__B));
}
私たちは見ます C__B__printaddr
宣言されたタイプとセマンティクスは互換性があります B__printaddr
, 、そのため、使用できます &C__B__printaddr
のvtableで B
; C__printaddr
互換性はありませんが、 C
オブジェクト、または派生したクラス C
.
非仮想メンバー関数は、内部のものにアクセスできる自由関数のようなものです。仮想メンバー関数は、オーバーライドによってカスタマイズできる「柔軟性ポイント」です。仮想メンバー関数宣言クラスの定義で特別な役割を果たします。他のメンバーと同様に、外部世界との契約の一部ですが、同時に派生クラスとの契約の一部です。
非仮想ベースクラスは、オーバーライドを介して動作を改良できるメンバーオブジェクトのようなものです(保護されたメンバーにアクセスすることもできます)。外の世界のために、継承 A
の Der
暗黙の導出されたベースからベースへの変換がポインターのために存在することを意味し、 A&
にバインドできます Der
lvalueなど。さらに派生したクラスの場合(派生 Der
)、それはまた、の仮想関数を意味します A
に継承されます Der
: :仮想関数 A
さらに派生したクラスでオーバーライドできます。
クラスがさらに導き出されたら、たとえば Der2
から派生しています Der
, 、暗黙的な変換タイプのポインター Der2*
に A*
ステップで意味的に実行されます:最初に、への変換 Der*
検証されています(の相続関係へのアクセス制御 Der2
から Der
通常のパブリック/保護/プライベート/友人のルールでチェックされます)、次にのアクセス制御 Der
に A
. 。非仮想継承関係は、派生クラスでは洗練またはオーバーライドすることはできません。
非仮想メンバー関数は直接呼び出され、仮想メンバーはvtableを介して間接的に呼び出されなければなりません(実際のオブジェクトタイプがたまたまコンパイラによって知られている場合を除く)。 virtual
キーワードは、メンバー関数アクセスに間接を追加します。関数メンバーのように、 virtual
キーワードは、ベースオブジェクトアクセスに間接を追加します。機能の場合と同じように、仮想ベースクラスは継承に柔軟性ポイントを追加します。
非仮想的な、繰り返される、複数の継承を行うとき:
struct Top { int i; };
struct Left : Top { };
struct Right : Top { };
struct Bottom : Left, Right { };
2つしかありません Top::i
サブオブジェクト Bottom
(Left::i
と Right::i
)、メンバーのようにオブジェクト:
struct Top { int i; };
struct mLeft { Top t; };
struct mRight { mTop t; };
struct mBottom { mLeft l; mRight r; }
誰も2つあることに驚かない int
サブメンバー(l.t.i
と r.t.i
).
仮想関数で:
struct Top { virtual void foo(); };
struct Left : Top { }; // could override foo
struct Right : Top { }; // could override foo
struct Bottom : Left, Right { }; // could override foo (both)
それは、呼ばれる2つの異なる(無関係な)仮想関数があることを意味します foo
, 、異なるVtableエントリを使用します(どちらも同じ署名を持っているため、共通のオーバーライダーを持つことができます)。
非仮想ベースクラスのセマンティックは、基本的で非仮想的な継承が排他的な関係であるという事実に従います。左と上部の間に確立された継承関係は、さらなる派生によって変更できないため、同様の関係が存在するという事実は、 Right
と Top
この関係に影響を与えることはできません。特に、それはそれを意味します Left::Top::foo()
でオーバーライドできます Left
とで Bottom
, 、 しかし Right
, 、継承関係はありません Left::Top
, 、このカスタマイズポイントを設定できません。
仮想ベースクラスは異なります。仮想継承は、派生クラスでカスタマイズできる共有関係です。
struct Top { int i; virtual void foo(); };
struct vLeft : virtual Top { };
struct vRight : virtual Top { };
struct vBottom : vLeft, vRight { };
ここでは、これは1つの基本クラスのサブオブジェクトです Top
, 、 唯一 int
メンバー。
実装:
非仮想ベースクラスの部屋は、派生クラスに固定オフセットを備えた静的レイアウトに基づいて割り当てられます。派生クラスのレイアウトは、より派生クラスのレイアウトに含まれているため、サブオブジェクトの正確な位置は、実際の(動的)タイプのオブジェクトに依存しないことに注意してください(非仮想関数のアドレスのように、 )。 OTOH、仮想継承を持つクラスのサブオブジェクトの位置は、動的タイプによって決定されます(仮想関数の実装のアドレスと同じように、動的タイプがわかっている場合にのみ知られています)。
サブオブジェクトの位置は、VPTRとVTABLE(既存のVPTRの再利用は、スペースのオーバーヘッドが少ないことを意味します)、またはサブオブジェクトへの直接的な内部ポインター(より多くのオーバーヘッド、必要な間接的なものが少ない)を使用して実行時に決定されます。
仮想ベースクラスのオフセットは完全なオブジェクトに対してのみ決定され、特定の宣言されたタイプでは知られていないため、 仮想ベースはオフセットゼロで割り当てることができず、決して一次ベースではありません. 。派生クラスは、仮想ベースのVPTRを独自のVPTRとして再利用することはありません。
可能な翻訳の用語で:
struct vLeft__vtable {
int Top__offset; // relative vLeft-Top offset
void (*foo__ptr) (vLeft *__this);
// additional virtual member function go here
};
// this is what a subobject of type vLeft looks like
struct vLeft__subobject {
vLeft__vtable const *__vptr;
// data members go here
};
void vLeft__subobject__ctor (vLeft__subobject *__this) {
// initialise data members
}
// this is a complete object of type vLeft
struct vLeft__complete {
vLeft__subobject __sub;
Top Top__base;
};
// non virtual calls to vLeft::foo
void vLeft__real__foo (vLeft__complete *__this);
// virtual function implementation: call via base class
// layout is vLeft__complete
void Top__in__vLeft__foo (Top *__this) {
// inverse .Top__base member access
char *cp = reinterpret_cast<char*> (__this);
cp -= offsetof (vLeft__complete,Top__base);
vLeft__complete *__real = reinterpret_cast<vLeft__complete*> (cp);
vLeft__real__foo (__real);
}
void vLeft__foo (vLeft *__this) {
vLeft__real__foo (reinterpret_cast<vLeft__complete*> (__this));
}
// Top vtable for objects of real type vLeft
const Top__vtable Top__in__vLeft__real = {
/*foo__ptr =*/ Top__in__vLeft__foo
};
// vLeft vtable for objects of real type vLeft
const vLeft__vtable vLeft__real = {
/*Top__offset=*/ offsetof(vLeft__complete, Top__base),
/*foo__ptr =*/ vLeft__foo
};
void vLeft__complete__ctor (vLeft__complete *__this) {
// construct virtual bases first
Top__ctor (&__this->Top__base);
// construct non virtual bases:
// change dynamic type to vLeft
// adjust both virtual base class vptr and current vptr
__this->Top__base.__vptr = &Top__in__vLeft__real;
__this->__vptr = &vLeft__real;
vLeft__subobject__ctor (&__this->__sub);
}
既知のタイプのオブジェクトの場合、ベースクラスへのアクセスは通過します vLeft__complete
:
struct a_vLeft {
vLeft m;
};
void f(a_vLeft &r) {
Top &t = r.m; // upcast
printf ("%p", &t);
}
翻訳されています:
struct a_vLeft {
vLeft__complete m;
};
void f(a_vLeft &r) {
Top &t = r.m.Top__base;
printf ("%p", &t);
}
ここでは、実際の(動的)タイプのタイプ r.m
サブオブジェクトの相対的な位置は、コンパイル時に知られています。しかし、ここで:
void f(vLeft &r) {
Top &t = r; // upcast
printf ("%p", &t);
}
実際の(動的)タイプのタイプ r
不明なので、アクセスはVPTRを介して行われます。
void f(vLeft &r) {
int off = r.__vptr->Top__offset;
char *p = reinterpret_cast<char*> (&r) + off;
printf ("%p", p);
}
この関数は、異なるレイアウトで派生クラスを受け入れることができます。
// this is what a subobject of type vBottom looks like
struct vBottom__subobject {
vLeft__subobject vLeft__base; // primary base
vRight__subobject vRight__base;
// data members go here
};
// this is a complete object of type vBottom
struct vBottom__complete {
vBottom__subobject __sub;
// virtual base classes follow:
Top Top__base;
};
に注意してください vLeft
基本クラスは、aの固定場所にあります vBottom__subobject
, 、 それで vBottom__subobject.__ptr
全体のVPTRとして使用されます vBottom
.
セマンティクス:
継承関係は、すべての派生クラスによって共有されます。これは、オーバーライドする権利が共有されることを意味します。 vRight
オーバーライドできます vLeft::foo
. 。これにより、責任の共有が作成されます。 vLeft
と vRight
カスタマイズ方法に同意する必要があります Top
:
struct Top { virtual void foo(); };
struct vLeft : virtual Top {
override void foo(); // I want to customise Top
};
struct vRight : virtual Top {
override void foo(); // I want to customise Top
};
struct vBottom : vLeft, vRight { }; // error
ここに紛争があります: vLeft
と vRight
唯一のfoo仮想関数の動作を定義しようとすると、 vBottom
一般的なオーバーライダーがない場合、定義は誤っています。
struct vBottom : vLeft, vRight {
override void foo(); // reconcile vLeft and vRight
// with a common overrider
};
実装:
非仮想ベースクラスを備えた非仮想ベースクラスを備えたクラスの構築には、メンバー変数に対して行われたのと同じ順序で基本クラスのコンストラクターを呼び出し、CTORに入るたびに動的タイプを変更します。建設中、基本クラスのサブオブジェクトは、完全なオブジェクトであるかのように実際に機能します(これは不可能な抽象的な基本クラスのサブオブジェクトでも真実です。これらは未定義の(純粋な)仮想関数を持つオブジェクトです)。仮想関数とRTTIは、構築中に呼び出すことができます(もちろん純粋な仮想関数を除く)。
仮想ベースを備えた非仮想ベースクラスを備えたクラスの構築はより複雑です: :構築中、動的タイプはベースクラスのタイプですが、仮想ベースのレイアウトはまだ構築されていない最も派生タイプのレイアウトであるため、この状態を説明するためにより多くのvtableが必要です。
// vtable for construction of vLeft subobject of future type vBottom
const vLeft__vtable vLeft__ctor__vBottom = {
/*Top__offset=*/ offsetof(vBottom__complete, Top__base),
/*foo__ptr =*/ vLeft__foo
};
仮想関数はの機能です vLeft
(建設中、vbottomオブジェクトの寿命は始まっていません)、仮想ベースの場所は vBottom
(で定義されています vBottom__complete
翻訳されたオブジェクト)。
セマンティクス:
初期化中、オブジェクトが初期化される前に使用しないように注意する必要があることは明らかです。 C ++は、オブジェクトが完全に初期化される前に名前を提供するため、簡単に行うことができます。
int foo (int *p) { return *pi; }
int i = foo(&i);
またはコンストラクターのこのポインターで:
struct silly {
int i;
std::string s;
static int foo (bad *p) {
p->s.empty(); // s is not even constructed!
return p->i; // i is not set!
}
silly () : i(foo(this)) { }
};
の使用がかなり明白です this
CTOR-Init-Listで注意深く確認する必要があります。すべてのメンバーの初期化後、 this
他の機能に渡すことができ、いくつかのセットに登録できます(破壊が始まるまで)。
あまり明白ではないのは、共有仮想ベースを含むクラスの構築の場合、サブオブジェクトが構築されるのを停止することです。 vBottom
:
最初に仮想ベースが構築されます:いつ
Top
構築されており、通常の主題のように構築されています(Top
それが仮想ベースであることさえ知らない)その後、基本クラスは左から右の順序で構築されます:
vLeft
サブオブジェクトは構築され、通常として機能するようになりますvLeft
(しかし、vBottom
レイアウト)、だからTop
基本クラスのサブオブジェクトは、を持っていますvLeft
動的タイプ;vRight
サブオブジェクトの構造が始まり、基本クラスの動的なタイプがVrightに変わります。しかしvRight
派生したものではありませんvLeft
, 、何も知りませんvLeft
, 、だからvLeft
ベースが壊れています。の本体
Bottom
コンストラクターが始まり、すべてのサブオブジェクトのタイプが安定し、vLeft
再び機能します。
この答えを、アライメントやパディングビットについて言及せずに、完全な答えとしてどのように採用できるかはわかりません。
アラインメントの背景を少し説明させてください。
「メモリアドレスaは、aがnバイトの倍数(nは2のパワー)である場合にnバイトが並べられていると言われています。この文脈では、バイトはメモリアクセスの最小単位です。別のバイト。Nバイトアラインドアドレスは、バイナリで表現するとLog2(n)最小重要なゼロを使用します。
代替の言葉遣いBビットアライメントされたAB/8バイトアラインドアドレス(例64ビットアライメントされたAlignedは8バイトがアラインされています)。
メモリアクセスは、アクセスされているデータムが長さがnバイトで、データムアドレスがnバイトに合わせたときに整列すると言われます。メモリアクセスが調整されていない場合、それはずれていると言われます。定義上、バイトメモリアクセスは常に整列されていることに注意してください。
nバイトのプリミティブデータを指すメモリポインタは、nバイトアラインのアドレスのみを含めることが許可されている場合、整列すると言われています。データ集約(データ構造または配列)を指すメモリポインターは、集約内の各プリミティブデータムが整列されている場合(および場合にのみ)整合されます。
上記の定義は、各プリミティブデータムが2バイトの長さのパワーであると仮定していることに注意してください。これが(x86の80ビットのフローティングポイントのように)そうでない場合、コンテキストは、データムが整列していると見なされる条件に影響します。
データ構造は、境界として知られる静的サイズのスタックのメモリに保存できます。
アライメントを維持するために、コンパイラは、構造/クラスオブジェクトのコンパイルされたコードにパディングビットを挿入します。 「コンパイラ(またはインタープリター)は通常、アラインド境界に個々のデータ項目を割り当てますが、データ構造には異なるアラインメント要件を持つメンバーがしばしばあります。適切なアライメントを維持するために、翻訳者は通常、追加の無名のデータメンバーを挿入して、各メンバーが適切に整列するように挿入します。データ構造全体に、最終的な名前のないメンバーがパッドにされている場合があります。これにより、一連の構造の各メンバーが適切に整列することができます。........ ....
パディングは、構造メンバーの後に、より大きなアラインメント要件を持つメンバーまたは構造の終わりに挿入された場合にのみ挿入されます」-wiki
GCCがどのように行うかについての詳細情報を入手するには、
http://www.delorie.com/gnu/docs/gcc/gccint_111.html
テキスト「Basic-Align」を検索します
さて、この問題になりましょう:
サンプルクラスを使用して、64ビットUbuntuで実行されているGCCコンパイラ用にこのプログラムを作成しました。
int main() {
cout << "!!!Hello World!!!" << endl; // prints !!!Hello World!!!
A objA;
C objC;
cout<<__alignof__(objA.a)<<endl;
cout<<sizeof(void*)<<endl;
cout<<sizeof(int)<<endl;
cout<<sizeof(A)<<endl;
cout<<sizeof(B)<<endl;
cout<<sizeof(C)<<endl;
cout<<__alignof__(objC.a)<<endl;
cout<<__alignof__(A)<<endl;
cout<<__alignof__(C)<<endl;
return 0;
}
そして、このプログラムの結果は次のとおりです。
4
8
4
16
16
32
4
8
8
今説明させてください。 A&Bの両方に仮想関数があるため、それらは個別のvtableを作成し、VPTRはそれぞれオブジェクトの先頭に追加されます。
したがって、クラスAのオブジェクトには、VPTR(aのVtableを指す)とIntがあります。ポインターの長さは8バイトで、INTは4バイトの長さになります。したがって、コンパイルする前にサイズは12バイトです。しかし、コンパイラは、パディングビットとしてint aの最後に追加の4バイトを追加します。したがって、コンパイル後、Aのオブジェクトサイズは12+4 = 16になります。
同様に、クラスBのオブジェクトについて。
Cのオブジェクトには、2つのVPTR(各クラスA&クラスBに1つ)と3つのINT(A、B、C)があります。したがって、サイズは8(vptr a) + 4(int a) + 4(パディングバイト) + 8(vptr b) + 4(int b) + 4(int c)= 32バイトである必要があります。したがって、Cの合計サイズは32バイトになります。