Microsoft Visual C++    製品情報    |    検索  |    サポート  |    フィードバック   |   ホーム  
Microsoft
   Visual C++ ホームページ   |   Visual Studio   |   開発関連製品   |   MSDN online   |   各国の開発者用サイト   |
  製品のご案内
  技術ドキュメント
  サポート情報
  よくある質問
  ダウンロード
  登録とオーナーエリア
  関連情報
 


Banner
Member of Visual Studio 技術ドキュメント

C++: 水面下の仕組み

Jan Gray


Jan Gray は、Microsoft Visual C++ Business Unit の Software Design Engineer です。彼は Microsoft Visual C++ コンパイラの設計とインプリメントに貢献しました。



はじめに

プログラミング言語がどのようにインプリメントされているかを理解することは重要です。このような知識があれば、「このコンパイラはいったい何をやっているんだ?」というような怖れと不安が解消され、新しい機能も自信を持って使うことができ、デバッグをするときや他の言語機能を学ぶときの手がかりが得られます。また、毎日の仕事の中で、最も効率的なコードを書くために必要な、コーディングの選択肢の相対的なコストを見極める感覚をつかむこともできます。

このペーパーでは、C++ の「水面下」の仕組みを紹介し、クラスのレイアウト テクニックや仮想関数呼び出しメカニズムといった C++ の「ランタイム」のインプリメンテーション上の細部について説明します。このペーパーでは以下のような疑問に対する答えを提供します。

  • クラスはどのようにレイアウトされているか?
  • データ メンバへのアクセスはどのように行われるか?
  • メンバ関数はどのように呼び出されるのか?
  • アジャスタ サンクとは何か?
  • 以下のテクニックのコストはどのようなものか:
    • 単一継承、多重継承、および仮想継承
    • 仮想関数と仮想関数呼び出し
    • 基本クラスおよび仮想基本クラスへのキャスト
    • 例外処理

まず、C 式の構造体のレイアウト、単一継承、多重継承、および仮想継承を扱い、次に仮想かどうかを問わず、データ メンバ アクセスとメンバ関数について説明します。その後、コンストラクタ、デストラクタ、および代入演算子の特殊メンバ関数と、配列の動的な作成と破棄について説明します。最後に、例外処理サポートの影響を簡単に論じます。

個々の言語機能のトピックについて、その言語機能が導入された理由と意味を簡単に示し (このペーパーは「C++ 言語入門」ではありませんが)、その言語機能が Microsoft® Visual C++® でどのようにインプリメントされているかを説明します。抽象的な言語の意味と、特定の具体的なインプリメンテーションの間の違いを忘れないようにしてください。他のベンダは、何らかの理由から、これとは異なるインプリメンテーションを選択している場合があります。少数のケースでは、Visual C++ のインプリメンテーションを他のインプリメンテーションと比較対照することにします。


クラスのレイアウト

このセクションでは、各種の継承に必要な記憶域のレイアウトについて説明します。


C 式の構造体

C++ は C をベースにしているため、「ほとんどの点」で C に対する上方互換性を持っています。特に、ワーキング ペーパーでは、C と同じ単純な構造体レイアウト規則を定めています。メンバは、宣言の順序で格納され、インプリメンテーション定義の位置合わせのための埋め込みが行われます。すべての C/C++ ベンダは、有効な C の構造体が、自社の C と C++ のコンパイラによって同じように格納されるようにしています。次に、A は単純な C の構造体の例を示します。メンバのレイアウトと埋め込みはあえて説明する必要はないでしょう。

struct A {
   char c;
   int i;
};

C++ の機能を持った C 式の構造体

もちろん、C++ はオブジェクト指向言語です。すなわち、C の構造体を継承、カプセル化、およびポリモーフィズムで拡張して、素晴らしい C++ のクラスを実現しています。C++ のクラスは、データ メンバに加えて、メンバ関数やその他のさまざまなものをカプセル化することができます。しかし、仮想関数と仮想継承をインプリメントするために導入される隠されたデータ メンバを除けば、インスタンスのサイズはクラス データ メンバと基本クラスだけによって決定されます。

次に示す B は、C++ の機能がいくつか追加された C 式の構造体です。public/protected/private のアクセス制御宣言、メンバ関数、静的メンバ、およびネストされた型宣言があります。各インスタンスでスペースを占有するのは、非仮想的なデータ メンバだけです。標準委員会のワーキング ペーパーでは、インプリメンテーションはアクセス宣言子によって区切られたデータ メンバの順序を入れ替えてもよいことになっているので、この 3 つのメンバは任意の順序で配置される可能性があります (Visual C++ では、C の構造体と同じように、メンバはつねに宣言の順序で配置されます)。


struct B {
public:
   int bm1;
protected:
   int bm2;
private:
   int bm3;
   static int bsm;
   void bf();
   static void bsf();
   typedef void* bpv;
   struct N { };
};

単一継承

C++ は、複数の型の共通する側面を抜き出し、共有するために、継承という機能を用意しています。継承機能を持つクラスのデータ型編成の例としては、生物学における生物の分類体系 (界、門、目、科、属、種など) があります。この編成では、「哺乳類はこどもを出産する」といった属性を、分類体系の中の最も適切なレベルで指定することができます。これらの属性は他のクラスによって 継承 されるため、われわれはそれ以上の指定がなくても、クジラ、リス、およびヒトがこどもを出産するということがわかるわけです。(哺乳類でありながら卵を産む) カモノハシのような例外では、派生クラスを使って、継承された属性または動作を オーバーライド する必要があります。これについては、後にさらに詳しく説明します。

C++ では、継承は、派生クラスを定義するときに ": base" の構文を使って指定します。次の D は、基本クラス C から派生しています。

struct C {
   int c1;
   void cf();
};

struct D : C {
   int d1;
   void df();
};

派生クラスは、その基本クラスのすべてのプロパティと動作を継承するので、派生クラスの個々のインスタンスは基本クラスのインスタンス データの完全なコピーを含んでいます。D の中では、C のインスタンス データが D のインスタンス データの前に置かれなくてはならないという制約はありません。しかし、D のレイアウトをこのようにすることで、D の中の C オブジェクトのアドレスが、D オブジェクトの最初のバイトのアドレスに対応することが保証されます。後に見るように、これによって、D に埋め込まれている C のアドレスを取得する必要が生じたときに、D* に調整を加える必要がなくなります。このレイアウトは、すべての既知の C++ インプリメンテーションで採用されています。このように、単一継承のクラス階層では、個々の派生クラスで導入された新しいインスタンス データは、単に基本クラスのレイアウトの末尾に追加されます。レイアウト図では、D の中の CD のオブジェクトへのポインタに「アドレス ポイント」というラベルを付けていることに注意してください。


多重継承

単一継承は、かなり柔軟かつ強力で、ほとんどの設計上の問題に含まれる、(一般には限られたレベルの) 継承を表現するのに適しています。しかし、ケースによっては、2 つ以上の動作のセットから、派生クラスの動作を取得したい場合があります。C++ は、複数のクラスの動作を組み合わせる多重継承というものを用意しています。

たとえば、クラス Manager(指示をする人) とクラス Worker(実際の作業を行う人) を持つ組織モデルがあったとしましょう。ここで、Worker のように自分のマネージャから仕事を受け取り、Manager のように自分の部下に仕事を与えるクラス MiddleManager をモデル化するにはどうすればいいでしょうか? これを単一継承で表現しようとすると大変です。MiddleManagerManagerWorker の両方から動作を継承するためには、両方が基本クラスとならなくてはなりません。そこで、MiddleManagerManager から継承を行い、ManagerWorker から継承を行うようにすると、Manager に誤って Worker の動作が割り当てられてしまいます (その逆でも同じ問題が生じます)。もちろん、MiddleManagerWorkerManager の片方だけから継承させ (またはどちらからも継承を行わず)、両方のインターフェイスを複製する (再宣言する) という方法をとることは可能ですが、これを行うとポリモーフィズムの意味がなくなり、既存のインターフェイスの再利用ができず、インターフェイスが時を経て進化したときに保守が困難になります。

その代わりに、C++ では、複数の基本クラスからの継承を許しています。

struct Manager ... { ... };
struct Worker ... { ... };
struct MiddleManager : Manager, Worker { ... };

これを図で表現するとどうなるでしょうか? これまでの「アルファベットのクラス」の例を使うと、次のようになります。

struct E {
   int e1;
   void ef();
};
  

struct F : C, E {
   int f1;
   void ff();
  
  
};
  

構造体 F は、CE から多重継承を行っています。単一継承の場合と同様に、F は個々の基本クラスのインスタンス データのコピーを含んでいます。単一継承とは異なり、個々の基本クラスの埋め込まれたインスタンスのアドレス ポイントを、派生クラスのアドレスに対応させることは不可能です。


F f;
// (void*)&f == (void*)(C*)&f;
// (void*)&f <  (void*)(E*)&f;
  

この例で、F に埋め込まれた E のアドレス ポイントは、F 自身のアドレスではありません。後のキャストとメンバ関数の項で説明しますが、このずれのせいで、単一継承では一般に存在しない小さなオーバーヘッドが生じます。

インプリメンテーションは、各種の埋め込まれた基本クラスのインスタンスと、新しいインスタンス データを任意の順序でレイアウトすることができます。Visual C++ は、基本インスタンスを宣言の順序で配置し、その後にやはり宣言の順序で新しいデータ メンバを配置するという特徴を持っています (後に述べるように、一部の基本クラスが仮想関数を持っていて、その他の基本クラスが持っていない場合は例外です)。


仮想継承

多重継承を導入するきっかけとなった MiddleManager の例に戻りますが、この方法でも問題が生じることがあります。もし ManagerWorker の両方が、Employee から派生しているとしたらどうなるでしょうか?


struct Employee { ... };
struct Manager : Employee { ... };
struct Worker : Employee { ... };
struct MiddleManager : Manager, Worker { ... };
  

WorkerManager はどちらも Employee から派生しているので、両方とも Employee インスタンス データのコピーを含んでいます。何らかの処置を行わないと、個々の MiddleManager は、Employee のインスタンスを 2 つ (各基本クラスから 1 つずつ) 含むことになります。Employee が大きなオブジェクトだった場合には、この重複のせいで生じる記憶域のオーバーヘッドが許容不可能な大きさになるかもしれません。より深刻なのは、Employee インスタンスの 2 つのコピーが別々に、あるいは一貫性のない形で変更される可能性があるということです。われわれに必要なのは、Manager または Worker が、やはり Employee 基本クラスを共有したいと考える他のクラスによって継承される場合に備えて、ManagerWorkerEmployee 基本クラスの単一の埋め込まれたインスタンスを共有させる手段です。

C++ では、この「共有継承」は (残念なことに) 仮想継承と呼ばれており、基本クラスが仮想クラスであることを示すことによって指定されます。


struct Employee { ... };
struct Manager : virtual Employee { ... };
struct Worker : virtual Employee { ... };
struct MiddleManager : Manager, Worker { ... };
  

仮想継承は、単一継承や多重継承よりも、インプリメントと使用のコストが格段に高くなります。単一継承 (および多重継承) された基本クラスと派生クラスでは、埋め込まれた基本クラスのインスタンスとその派生クラスは、共通のアドレス ポイントを共有する (単一継承の場合と、多重継承で継承された一番左の基本クラスの場合) か、埋め込まれた基本クラス インスタンスへの単純な固定されたずれを持つ (E のように、多重継承された一番左にない基本クラスの場合) かのどちらかになります。一方、仮想継承では、派生クラスのアドレス ポイントからその仮想基本クラスまでのずれは (一般に) 固定されていません。このような仮想クラスからさらに派生を行うと、その派生クラスは仮想基本クラスの共有されたコピーをさらに別の場所に置くことになり、オフセットの値がさらにずれます。次の例を考えてみてください。


struct G : virtual C {
   int g1;
   void gf();
};
  


struct H : virtual C {
   int h1;
   void hf();
};
  

struct I : G, H {
   int i1;
   void _if();
};
  

しばらく vbptr メンバを無視して、G オブジェクトの中では、埋め込まれた CG のデータ メンバの直後にあり、H の中では、埋め込まれた CH のデータ メンバの直後にあることに注目してください。さて、I のレイアウトでは、この両方の関係を保つことはできません。上に示した Visual C++ のレイアウトでは、GC の間のずれは、G インスタンスの中と I インスタンスの中で異なっています。クラスは一般にその派生のされ方に関する知識なしにコンパイルされるため、仮想基本クラスを持つ個々のクラスは、派生クラスのアドレス ポイントからの仮想基本クラスの相対的な位置を計算する手段を必要とします。

Visual C++ では、これは仮想基本クラスを持つクラスの個々のインスタンスに、隠された vbptr(仮想基本クラス テーブル ポインタ) を追加することによってインプリメントされています。このフィールドは、vbptr フィールドのアドレス ポイントから、クラスの仮想基本クラスまでの、クラスごとに共有されたずれのテーブルをポイントしています。

他にも、基本クラスごとに、派生クラスから仮想基本クラスへの埋め込まれたポインタを使用するインプリメンテーションがあります。この表現には、仮想基本クラスのアドレスを取得するためのコード シーケンスが短くて済むという長所があります (ただし、仮想基本クラスにアクセスするための計算が繰り返されていると、最適化コード ジェネレータは一般に共通部分式の消去を行います)。しかし、複数の仮想基本クラスを持つクラスのインスタンスのサイズが増え、(さらに隠されたポインタを追加するという手段をとらないかぎり) 仮想基本クラスの仮想基本クラスへのアクセス速度が低下し、メンバの参照解除が不規則な形になるという短所もあります。

Visual C++ では、G の隠された vbptr は、第 2 のエントリが GdGvbptrC である仮想基本クラス テーブルのアドレスをポイントしています (このエントリの表記は、「G の中での、Gvbptr から C までのずれ」を意味しています。(このドキュメントでは、すべての派生クラスで値が一定ならば、"d" の前のプレフィックスを省略しています))。たとえば、32 ビット プラットフォームでは、GdGvbptrC は 8(バイト) となります。同じように、I の中の埋め込まれた G のインスタンスは、I の中の G 用にカスタマイズされた vbtable のアドレスをポイントしており、IdGvbptrC は 20 になります。

GH、および I のレイアウトからわかるように、Visual C++ は仮想基本クラスを持つクラスを次のようにレイアウトします。

  • 最初に、仮想継承されていない基本クラスの埋め込みインスタンスを配置します。
  • 非仮想基本クラスから適切な vbptr が継承されていなければ、隠された vbptr を追加します。
  • 派生クラスで宣言された新しいデータ メンバを配置します。
  • 最後に、インスタンスの末尾に、仮想継承された個々の基本クラスのインスタンスを 1 つだけ配置します。

この表現により、仮想継承された基本クラスは派生クラス (およびそこからさらに派生されたクラス) の中で「移動」しますが、オブジェクトの中の仮想基本クラスに属していない部分は、1ヶ所にまとめられたまま、比較的一定したずれを保つことになります。


データ メンバへのアクセス

これでクラスのレイアウトがわかったので、次にこれらのクラスのデータ メンバにアクセスするコストを考えます。

継承なし。 継承がない場合、データ メンバへのアクセスは C の場合と同じです。すなわち、オブジェクトのポインタから何らかのずれを持ったアドレスを参照解除することになります。


C* pc;


pc->c1; // *(pc + dCc1);
  

単一継承。 派生オブジェクトからその埋め込まれた基本クラス インスタンスまでのずれは定数 0 なので、その基本クラスの中のメンバの固定されたオフセットに、定数 0 を加えます。

D* pd;
pd->c1; // *(pd + dDC + dCc1); // *(pd + dDCc1);
pd->d1; // *(pd + dDd1);
  

多重継承。 特定の基本クラスの、または基本クラスの基本クラスのずれはゼロではない場合がありますが、これは依然として定数なので、これらのずれを足し合わせたものを、オブジェクト ポイントからの 1 つの固定されたオフセットに加えます。


F* pf;
pf->c1; // *(pf + dFC + dCc1); // *(pf + dFc1);
pf->e1; // *(pf + dFE + dEe1); // *(pf + dFe1);
pf->f1; // *(pf + dFf1);
  

仮想継承。 仮想基本クラスを持つクラスの中では、データ メンバ、または仮想継承されていない基本クラスへのアクセスは、これまでと同様にオブジェクト ポインタに固定されたずれを加えるだけで行うことができます。しかし、仮想基本クラスのデータ メンバにアクセスするためには、データ メンバのアドレスを計算するだけでも、vbptr を取得し、vbtable エントリを取得し、そのずれを vbptr のアドレス ポイントに加えるという処理をしなければならないため、比較的コストがかかります。ただし、次の i.c1 の例に示すように、派生クラスの型が静的にわかっている場合には、レイアウトもわかっているため、仮想基本クラスのずれを調べるための vbtable エントリをロードする必要はありません。


I* pi;
pi->c1; // *(pi + dIGvbptr + (*(pi+dIGvbptr))[1] + dCc1);
pi->g1; // *(pi + dIG + dGg1); // *(pi + dIg1);
pi->h1; // *(pi + dIH + dHh1); // *(pi + dIh1);
pi->i1; // *(pi + dIi1);
I i;
i.c1; // *(&i + IdIC + dCc1); // *(&i + IdIc1);
  

では、推移的な仮想基本クラス (たとえば、仮想基本クラスの仮想基本クラスのメンバなど) の場合はどうなるでしょうか? 一部のインプリメンテーションは、中間の仮想基本クラスへの埋め込まれた仮想基本クラス ポインタを追いかけ、さらにその仮想基本クラスへの仮想基本クラス ポインタを追いかけるというような処理を行います。Visual C++ は、派生クラスから任意の推移的な仮想クラスへのずれを含んでいる vbtable エントリを使って、このようなアクセスを最適化しています。


キャスト

仮想基本クラスを持つクラスを除けば、ポインタを明示的に別のポインタ型にキャストするのは比較的低コストな処理です。クラス ポインタの間に、同じ基本クラスから派生しているという関係がある場合、コンパイラは 2 つの間のずれ (0 であることが多い) を単純な加算または減算で計算します。

F* pf;
(C*)pf; // (C*)(pf ? pf + dFC : 0); // (C*)pf;
(E*)pf; // (E*)(pf ? pf + dFE : 0);
  

C* のキャストでは、dFC が 0 であるため、計算は不要です。E* のキャストでは、ポインタにゼロでない定数 dFE を加える必要があります。C++ では、キャストの後も、ヌル ポインタ (0) がヌルでなくてはなりません。このため、Visual C++ は加算を実行する前にヌルのチェックを行います。このチェックは、ポインタが暗黙的または明示的に関連するポインタ型に変換される場合にのみ行われ、派生オブジェクトに対する基本クラスのメンバ関数の呼び出し時に、derived*が暗黙的に base*const this ポインタに変換される場合には行われません。

想像できるように、仮想継承のパスをたどって行われるキャストは比較的高コストで、仮想基本クラスのメンバにアクセスするコストとほぼ同じです。

I* pi;
(G*)pi; // (G*)pi;
(H*)pi; // (H*)(pi ? pi + dIH : 0);
(C*)pi; // (C*)(pi ? (pi+dIGvbptr + (*(pi+dIGvbptr))[1]) : 0);
  

一般に、多数の高コストな仮想基本クラス フィールドへのアクセスは、仮想基本クラスへの 1 回のキャストと、その基本クラスを基準としたアクセスに置き換えることで、コストを減らすことができます。

/* before: */             ... pi->c1 ... pi->c1 ...
/* faster: */ C* pc = pi; ... pc->c1 ... pc->c1 ...
  

メンバ関数

C++ のメンバ関数は、そのクラスのスコープに含まれているメンバに他なりません。クラス X の個々の (非静的な) メンバ関数は、X *const 型の特殊な隠された this パラメータを受け取ります。これは、メンバ関数の適用先のオブジェクトをもとに暗黙に初期化されています。また、メンバ関数の本体の中で、this ポインタを基準としたメンバ アクセスも暗黙的に処理されます。

struct P {
   int p1;
   void pf(); // new
   virtual void pvf(); // new
  
  
};
  

P は、非仮想的なメンバ関数 pf() と、仮想的なメンバ関数 pvf() を持っています。これでわかるように、仮想メンバ関数では仮想関数テーブル ポインタが必要なため、インスタンスのサイズが増大します。これについては後に説明することにしましょう。ここでは、非仮想的なメンバ関数の宣言には、インスタンスのサイズが増大するコストがないことに注意してください。さて、P::pf() の定義を見てみましょう。

void P::pf() { // void P::pf([P *const this])
   ++p1;   // ++(this->p1);
}
  

ここで、P::pf() は、コンパイラがすべての呼び出しに渡さなくてはならない隠された this パラメータを受け取っています。また、メンバ アクセスは this を基準として行われるため、メンバ アクセスは一見するよりも高コストになりうるという点に注意してください。コンパイラは一般に this の登録を行うので、メンバ アクセスのコストはローカル変数のアクセス程度に抑えられる場合もあります。一方、this が何らかの他のデータに別名化されている可能性があるため、コンパイラはインスタンス データそのものの登録は行えない可能性があります。


メンバ関数のオーバーライド

メンバ関数もデータ メンバと同じように継承されます。データ メンバとは異なり、派生クラスは、継承されたメンバ関数が派生インスタンスに適用されるときに、実関数定義をオーバーライドする、すなわち置き換えることができます。オーバーライドが静的であるか (メンバ関数呼び出しに関与している静的な型に基づいて、コンパイル時に決定される)、動的であるか (オブジェクト ポインタがアドレス指定している動的なオブジェクトに基づいて、実行時に決定される) は、メンバ関数が virtual として宣言されているかどうかに依存します。

クラス QP のデータ メンバと関数メンバを継承しています。P::pf() をオーバーライドして、pf() を宣言しています。また、P::pvf() をオーバーライドする仮想関数 pvf() を宣言し、新しい非仮想メンバ関数 qf() と、新しい仮想関数 qvf() を宣言しています。


struct Q : P {
   int q1;
   void pf();  // overrides P::pf
   void qf();  // new
   void pvf(); // overrides P::pvf
   virtual void qvf(); // new


};
  

非仮想関数呼び出しでは、呼び出されるメンバ関数は、-> 演算子の左辺にあるポインタ式の型に基づいて、コンパイル時に静的に決定されます。特に、ppqQ のインスタンスをポイントしているにもかかわらず、ppq->pf() を呼び出します (-> の左辺のポインタ式が隠された this パラメータとして渡されることにも注意してください)。


P p; P* pp = &p; Q q; P* ppq = &q; Q* pq = &q;
pp->pf();  // pp->P::pf();  // P::pf(pp);
ppq->pf(); // ppq->P::pf(); // P::pf(ppq);
pq->pf();  // pq->Q::pf();  // Q::pf((P*)pq);
pq->qf();  // pq->Q::qf();  // Q::qf(pq);
  

仮想関数呼び出しでは、呼び出されるメンバ関数は実行時に決定されます。-> 演算子の左辺にあるポインタ式の宣言されたデータ型にかかわらず、呼び出される仮想関数は、ポインタがアドレス指定している実インスタンスの型に応じたものになります。特に、ppq の型は P* ですが、Q のアドレスをポイントしているので、Q::pvf() が呼び出されます。


pp->pvf();  // pp->P::pvf();  // P::pvf(pp);
ppq->pvf(); // ppq->Q::pvf(); // Q::pvf((Q*)ppq);
pq->pvf();  // pq->Q::pvf();  // Q::pvf((P*)pq);
  

このメカニズムをインプリメントするために、隠された vfptr メンバが導入されます。クラスには、クラスの仮想関数テーブル (vftable) をアドレス指定する vfptr が追加されます (持っていない場合)。クラス内の個々の仮想関数は、そのクラスの vftable に対応するエントリを持っています。個々のエントリは、そのクラスに応じた仮想関数オーバーライドのアドレスを保持しています。このため、仮想関数を呼び出すためには、インスタンスの vfptr を取得し、そのポインタがアドレス指定している vftable エントリの 1 つを通して間接的な呼び出しを行う必要があります。これは、パラメータの受け渡し、呼び出し、および戻りの命令に必要な、通常の関数呼び出しのオーバーヘッドに加えて生じるオーバーヘッドです。次の例では、Qvftable をアドレス指定している qvfptr を取得しています。最初のエントリは &Q::pvf なので、Q::pvf() が呼び出されます。

PQ のレイアウトに戻ると、Visual C++ コンパイラが隠された vfptr メンバを、PQ のインスタンスの先頭に配置していることがわかります。これにより、仮想関数ディスパッチが可能な限り高速化されます。実際、Visual C++ のインプリメンテーションは、仮想関数を持つすべてのクラスの最初のフィールドとして必ず vfptr を配置します。このためには、インスタンス レイアウトの基本クラスの前に新しい vfptr を挿入したり、さらには、vfptr を持たない左の基本クラスの前に、vfptr で始まる右の基本クラスを配置しなければならないことがあります。

ほとんどの C++ のインプリメンテーションは、継承された基本クラスの vfptr を共有または再利用します。ここでは、Q は新しい仮想関数 qvf() のためのテーブルをアドレス指定する新たな vfptr を受け取っていません。その代わりに、Pvftable レイアウトの末尾に qvf エントリが追加されています。このようにすることで、単一継承のコストが抑えられています。すでにインスタンスが vfptr を持っていれば、新たな vfptr は不要なのです。新しい派生クラスはさらに仮想関数を導入することができますが、この場合にはクラスごとの vftable の末尾に、単にこれらの仮想関数の vftable エントリが追加されます。


仮想関数: 多重継承

インスタンスは、それぞれ仮想関数を持つ複数の基本クラスから継承を行う場合には、複数の vfptr を含むことができます。次の RS の例を考えてみましょう。


struct R {
   int r1;
   virtual void pvf(); // new
   virtual void rvf(); // new
};
  

struct S : P, R {
   int s1;
   void pvf(); // overrides P::pvf and R::pvf
   void rvf(); // overrides R::rvf
   void svf(); // new
};
  

この例の R は、いくつかの仮想関数を持つクラスです。SPR から多重継承を行っているので、それぞれの埋め込まれたインスタンスと、独自のインスタンス データである S::s1 を含んでいます。これは多重継承なので、右の基本クラスは PS とは異なるアドレス ポイントを持っていることに注意してください。S::pvf()P::pvf()R::pvf() の両方をオーバーライドし、S::rvf()R::rvf() をオーバーライドしています。以下に、pvf のオーバーライドに必要なセマンティクスを示します。


S s; S* ps = &s;
((P*)ps)->pvf(); // ((P*)ps)->P::vfptr[0])((S*)(P*)ps)
((R*)ps)->pvf(); // ((R*)ps)->R::vfptr[0])((S*)(R*)ps)
ps->pvf();       // one of the above; calls S::pvf()
  

S::pvf()P::pvf()R::pvf() の両方をオーバーライドしているため、Svftable の中の vftable エントリを置き換えなくてはなりません。ただし、pvf()PR のどちらとしても呼び出せることに注意してください。問題は、R のアドレス ポイントが PS のアドレス ポイントに対応していない点にあります。式 (R*)ps は、クラスの中の、P(*)ps と同じ部分をポイントしていません。関数 S::pvf() は隠された this パラメータとして S* を受け取ることを期待しているため、仮想関数呼び出し自身が、呼び出し側の R* を呼び出し先の S* に自動的に変換しなくてはなりません。このため、Rvftablepvf スロットの S のコピーは、R* ポインタを S* に変換するために必要なアドレス調整を適用するアジャスタ サンクのアドレスを取ります。

Visual C++ の仮想関数の多重継承では、アジャスタ サンクは、派生クラス仮想関数が複数の基本クラスの仮想関数をオーバーライドする場合にのみ必要となります。


アドレス ポイントと「論理的な this の調整」

次に、R::rvf() をオーバーライドする S::rvf() を考えます。ほとんどのインプリメンテーションでは、S::rvf()S* 型の隠された this パラメータを持たなくてはならないと定めています。これは、次の呼び出しが行われたときに、Rrvf vftable スロットが使用される可能性があるからです。


((R*)ps)->rvf(); // (*((R*)ps)->R::vfptr[1])((R*)ps)
  

ほとんどのインプリメンテーションは、rvf に渡された R*S* に変換するために、新たなサンクを追加します。さらに、一部のインプリメンテーションは、R* への変換を行わなくても ps->rvf() を呼び出せるように、Svftable の末尾に新たな vftable エントリを追加します。Visual C++ は、S::rvf() をコンパイルする際に、S オブジェクトではなく、S に埋め込まれた R のインスタンスをアドレス指定する this ポインタを受け取るように意図的に調整することで、サンクやエントリの追加を避けています (われわれはこのことを、「オーバーライドに、仮想関数を最初に導入したクラスと同じアドレス ポイントを与える」と呼んでいます)。これは、メンバ関数の中で起こるすべてのメンバ取得や this からの変換などの処理に「論理的な this の調整」を適用することによって透過的に行われます (この調整は、多重継承のメンバ アクセスの場合と同様に、メンバのずれを計算する他のアドレス計算に定数を折り畳むことによって行われます)。

もちろん、デバッガの中ではこの調整を考慮に入れて補正を行う必要があります。


ps->rvf(); // ((R*)ps)->rvf(); // S::rvf((R*)ps)
  

このように、Visual C++ は一般論として、左端以外の基本クラスの仮想関数をオーバーライドする際に、サンクや新たな vftable エントリの作成を避けています。


アジャスタ サンク

前に述べたように、呼び出し先の仮想関数に到達するために、(スタック上の戻りアドレスのすぐ下、またはレジスタ内に存在する)this を一定のずれの分だけ調整する目的でアジャスタ サンクが必要になることがあります。一部のインプリメンテーション (特に cfront をベースにしたもの) は、アジャスタ サンクを採用せず、個々の仮想関数テーブル エントリにずれのフィールドを追加するという方法をとっています。仮想関数が呼び出されると、ずれのフィールド (0 であることが多い) がオブジェクト アドレスに追加されます。これが this ポインタとして渡されることになります。


ps->rvf();
// struct { void (*pfn)(void*); size_t disp; };
// (*ps->vfptr[i].pfn)(ps + ps->vfptr[i].disp);
  

このアプローチの短所は、vftable が大きくなるということと、仮想関数を呼び出すためのコード シーケンスが長くなるということです。

今日の PC ベースのインプリメンテーションのほとんどは、調整した後にジャンプするというテクニックを使用しています。

S::pvf-adjust: // Visual C++
   this -= SdPR;
   goto S::pvf()
  

もちろん、次のコード シーケンスの方がもっと優れています (しかし、このようなシーケンスを生成するインプリメンテーションは現時点では存在しません)。

S::pvf-adjust:
   this -= SdPR; // fall into S::pvf()
S::pvf() { ... }
  

仮想関数: 仮想継承

次の TP を仮想継承し、いくつかのメンバ関数をオーバーライドしています。Visual C++ では、vftable エントリを取得する際の、仮想基本クラス P へのコストのかかる変換を避けるために、T の新しい仮想関数は新しい vftable の中のエントリを受け取るようになっています。このため、T の先頭に新しい vfptr が導入されます。

struct T : virtual P {
   int t1;
   void pvf();         // overrides P::pvf
   virtual void tvf(); // new
};
  

void T::pvf() {
   ++p1; // ((P*)this)->p1++; // vbtable lookup!
   ++t1; // this->t1++;
}
  

上に示したように、仮想関数の定義の中でも、仮想基本クラスのデータ メンバへのアクセスは、仮想基本クラスへのずれを取得するために依然として vbtable を使用する必要があります。これが必要なのは、この仮想関数が、仮想基本クラスと異なるレイアウトを持つ派生クラスにさらに継承される可能性があるためです。そのようなクラスの例を次に示します。


struct U : T {
   int u1;
};
  

この U では、新たなデータ メンバが追加されており、P へのずれである dP が変化しています。T::pvfT の中に P* が入っている状態で呼び出されることを期待しているため、呼び出し先で T::t1(T の中の P* のアドレス ポイント) がポイントされるように、アジャスタ サンクを使って this を調整する必要があります (何とも複雑な処理ではあります !)。


特殊なメンバ関数

このセクションでは、特殊なメンバ関数の中に (またはその周囲に) コンパイルされる隠されたコードについて説明します。


コンストラクタとデストラクタ

前に述べたように、作成と破棄の際に初期化されなくてはならない隠されたメンバが存在します。最悪のケースでは、コンストラクタは以下の作業を行う可能性があります。

  • 「末端の派生クラス」では、vbptr のフィールドを初期化し、仮想基本クラスのコンストラクタを呼び出します。
  • 直接の非仮想基本クラスのコンストラクタを呼び出します。
  • データ メンバのコンストラクタを呼び出します。
  • vfptr のフィールドを初期化します。
  • コンストラクタ定義の本体の中で、ユーザーが指定した初期化コードを実行します。

(「末端の派生クラス」のインスタンスとは、他の派生クラスの中に埋め込まれた基本クラス インスタンスではないインスタンスのことです。)

したがって、深い継承階層がある場合には、それが単一継承であっても、オブジェクトを作成するために、クラスの vfptr を何度も連続して初期化しなくてはならないことがあります (Visual C++ は、可能であれば、これらの冗長なストアを最適化して除去します)。

一方、デストラクタは、初期化とはまったく逆の順序でオブジェクトを破棄しなくてはなりません。

  • vfptr のフィールドを初期化します。
  • デストラクタ定義の本体の中で、ユーザーが指定した破棄コードを実行します。
  • データ メンバのデストラクタを (逆の順序で) 呼び出します。
  • 直接の非仮想基本クラスのデストラクタを (逆の順序で) 呼び出します。
  • 「末端の派生クラス」では、仮想基本クラスのデストラクタを (逆の順序で) 呼び出します。

Visual C++ では、仮想基本クラスを持つクラスのコンストラクタは、仮想基本クラスを初期化すべきかどうかを示す隠された「末端の派生クラスのフラグ」を受け取ります。デストラクタについては、「多層化デストラクタ モデル」が使用されています。このモデルでは、デストラクタが 1 つの「隠された」デストラクタ関数に統合され、仮想基本クラスを 含む クラス (「末端の仮想クラス」のインスタンス) の破棄と、仮想基本クラスを 除く クラスの破棄が行われます。前者は後者を呼び出した後に、仮想基本クラスを (逆の順序で) 破棄します。


仮想デストラクタと delete 演算子

次の構造体 VW を考えます。


struct V {


   virtual ~V();
};
  

struct W : V {
   operator delete();
};
  

デストラクタは仮想デストラクタにすることができます。仮想デストラクタを持つクラスは、通常どおりに、vftable をアドレス指定する隠された vfptr メンバを受け取ります。このテーブルには、そのクラスに応じた仮想デストラクタ関数のアドレスを保持しているエントリが含まれています。仮想デストラクタの特別な点は、クラスのインスタンスが削除されたときに暗黙のうちに呼び出されるということです。呼び出し側 (削除した側) は、破棄される動的な型を知りませんが、その型に応じた適切な delete 演算子を呼び出さなくてはなりません。たとえば、次の pvW をアドレス指定している場合、W::~W() が呼び出されたら、その記憶域は W::operator delete() を使って破棄されなくてはなりません。

V* pv = new V;
delete pv;   // pv->~V::V(); // use ::operator delete()
pv = new W;
delete pv;   // pv->~W::W(); // use W::operator delete()
pv = new W;
::delete pv; // pv->~W::W(); // use ::operator delete()
  

これらのセマンティクスをインプリメントするために、Visual C++ は「多層化デストラクタ モデル」を拡張して、「削除デストラクタ」という隠されたデストラクタ ヘルパ関数を自動的に作成します。この関数のアドレスが、仮想関数テーブルの中の「実際の」仮想デストラクタのアドレスを置き換えます。この関数は、クラスに応じた適切なデストラクタを呼び出した後に、オプションとして、そのクラスに応じた適切な delete 演算子を呼び出します。


配列

動的な (ヒープに割り当てられた) 配列があると、仮想デストラクタの責任はさらに複雑になります。この複雑さの原因は 2 つあります。第 1 に、ヒープに割り当てられた配列の動的なサイズを配列自体とともに格納しなくてはなりません。つまり、動的に割り当てられた配列は、配列要素の数を保持するための余分な記憶域を自動的に割り当てることになります。もう 1 つの複雑さは、派生クラスが基本クラスよりも大きくなる可能性があることから生じます。配列の delete は、配列のサイズが不明な場合でも、個々の配列要素を正しく削除しなくてはなりません。


struct WW : W { int w1; };
pv = new W[m];
delete [] pv; // delete m W's  (sizeof(W)  == sizeof(V))
pv = new WW[n];
delete [] pv; // delete n WW's (sizeof(WW) >  sizeof(V))
  

厳密に言えば、ポリモーフィックな配列削除は未定義の動作なのですが、これをインプリメントするように求めるカスタマからの声がいくつか寄せられました。このため、MSC++ では、「ベクタ削除デストラクタ」と呼ばれるもう 1 つの統合された仮想デストラクタ ヘルパ関数によってこの動作をインプリメントしています。この関数は (WW のような特定のクラスに合わせてカスタマイズされているため) 配列要素を (逆の順序で) 繰り返し処理し、個々の要素に応じた適切なデストラクタを呼び出すことができます。


例外処理

C++ 標準委員会のワーキング ペーパーの例外処理の案は、簡単に述べると、関数が呼び出し元に例外的な条件が発生したことを通知し、その状況に対処するために使われる適切なコードを選択できるような機能を提供しています。これは、関数呼び出しが戻るたびにエラー ステータスの戻りコードをチェックするという従来の方式の代替的なメカニズムとなります。

C++ はオブジェクト指向なので、例外状態を表現するためにオブジェクトが使用され、「スロー」された例外オブジェクトの静的または動的な型に基づいて適切な例外ハンドラが選択されるという仕組みになるのは当然のことでしょう。また、C++ では、スコープから外れたフレーム オブジェクトがつねに正しく破棄されることが保証されているため、インプリメンテーションはスロー側から「キャッチ」側への制御の転送 (スタック フレームの巻き戻し) の際に、(自動変数である) フレーム オブジェクトが正しく破棄されるようにしなくてはなりません。

次の例を考えます。


struct X { X(); }; // exception object class
struct Z { Z(); ~Z(); }; // class with a destructor
extern void recover(const X&);
void f(int), g(int);

int main() {
   try {
      f(0);
   } catch (const X& rx) {
      recover(rx);
   }
   return 0;
}

void f(int i) {
   Z z1;
   g(i);
   Z z2;
   g(i-1);
}

void g(int j) {
   if (j < 0)
      throw X();
}
  

このプログラムは、例外をスローします。main() は、f(0) の呼び出しのための例外ハンドラ コンテキストを設定しています。f(0) は、z1 を作成し、g(0) を呼び出し、z2 を作成し、g(-1) を呼び出します。g() は引数が負であるという条件を検出し、呼び出し元に X オブジェクトの例外をスローします。g()f() も例外ハンドラ コンテキストを設定していないので、main() によって設定された例外ハンドラは X オブジェクトの例外を処理できるものと考えられます。実際に、main() の例外ハンドラは X オブジェクトの例外を処理できます。ただし、制御が main() のキャッチ句に移る前に、g() の中のスロー サイトと main() のキャッチ サイトの間のフレーム上にあるオブジェクトを破棄する必要があります。この例では、z2z1 が破棄されます。

例外処理のインプリメンテーションは、スロー サイトとキャッチ サイトで、それぞれ、スローされたオブジェクトをキャッチできる型のセットと、この特定のキャッチ サイトでキャッチできるスローされたオブジェクトの型のセットを記述し、一般に、スローされたオブジェクトがキャッチ句の「実パラメータ」をどのように初期化するかを記述するテーブルを利用する場合があります。適切なエンコードを行えば、これらのテーブルが占有するスペースを小さく抑えることができます。

しかし、ここで関数 f() を見直してみましょう。たしかにこの関数は無害なものに見えます。trycatchthrow のどのキーワードも含んでいないため、例外処理は f() に大した影響を与えないように見えます。しかし、これは間違いです ! コンパイラは、いったん z1 が作成されたら、それ以降の呼び出し先の関数が例外を f() に対して、したがって f() の外へと提起 (「スロー」) した場合に、z1 オブジェクトが正しく破棄されるようにしなくてはなりません。同じように、いったん z2 が作成されたら、それ以降のスローで z2 が、さらに z1 が破棄されるようにしなくてはなりません。

これらの「巻き戻しのセマンティクス」をインプリメントするために、インプリメンテーションは、例外を提起している呼び出しの、呼び出し元の関数の中でのコンテキスト (サイト) を動的に決定するメカニズムを提供しなくてはなりません。このためには、個々の関数プロローグとエピローグにコードを追加したり、さらに悪いケースでは、オブジェクト初期化の個々のセットの間で状態変数を更新しなくてはなりません。たとえば、上の例では、z1 が破棄されなくてはならないコンテキストは、z2 とそれに続けて z1 が破棄されなくてはならないコンテキストと明らかに異なっているため、Visual C++ は z1 の作成後に状態変数を新しい値に更新 (格納) し、z2 の作成後にも再び更新を行います。

これらのテーブル、関数プロローグ、エピローグ、および状態変数の更新のために、例外処理機能のスペースと速度のコストはかなり高くなる可能性があります。すでに見たように、このコストは、例外処理コンストラクトを使用していない関数の中でも発生します。

幸いなことに、一部のコンパイラは、例外処理を必要としないコードから、例外処理とそのオーバーヘッドを除去するコンパイル スイッチやその他のメカニズムを用意しています。


要約

これで読者が自分でコンパイラを書く準備ができました。

まあそれは冗談として、このドキュメントでは C++ のランタイム インプリメンテーションの持つさまざまな重要な課題を検討してきました。素晴らしい言語機能の中に、ほとんどコストなしに利用できるものもあれば、かなりのオーバーヘッドがかかるものもあることがわかりました。これらのインプリメンテーション メカニズムは、言ってみれば水面下で自動的に適用されており、個々のコードを単独で見ると、それがどの程度のコストを持つのかは一般に判断しにくいものです。コスト意識の高いプログラマは、生成されたネイティブ コードをときどき確認し、個々のクールな言語機能にそのオーバーヘッドだけの価値があるかどうかを検討することをお勧めします。

謝辞。このドキュメントで説明している Microsoft C++ オブジェクト モデルの原型は、Martin O'Riordan と David Jones によって設計され、その後、インプリメンテーションを完成させるために、必要に応じてあちこちに細部が付け加えられたものです。


このドキュメントに関する注意

この資料は現状のまま、情報提供の目的でのみ提供されています。Microsoft とその供給者は、この資料の内容に関して、商品性および特定目的に対する適合性など暗示されている保証を含み、いかなる保証も行いません。一部の州/管区では暗黙の保証の除外を許していないため、上記の制限は適用されない場合があります

Microsoft とその供給者は、必然的、偶発的、直接的、間接的、および特別な損害や利益の損失を含む、いかなる損害についても一切責任を負いません。一部の州/管区では必然的または偶発的損害の除外を許していないため、上記の制限は適用されない場合があります。いかなる場合でも、Microsoft とその供給者が不法行為、契約、またはその他の行為を通して、これらの資料から生じた損害に対して行う賠償は、これらの資料の推奨小売り価格を超えないものとします。


(C) 2000 Microsoft Corporation. All rights reserved. Terms of Use.