C と C++ での例外処理、第 4 部

Robert Schmidt

1999 年 7 月 1 日

この例外処理シリーズの第 4 部では、Visual C++ で書いたいくつかの短いコード例に含まれる EH を解剖します。

はじめに

これまでは C と C++ の世界から出ずに安全でしたが、今回のコラムでは、アセンブリ言語への細道に分け入るという危険を冒そうと思います。筆者のゴールは、Visual C++ において簡単なエラー処理(EH)のスローとキャッチがどのように実装されているのか、その基本を紹介することです。とはいっても、網羅的であることを意味しているのではありません。結局、基本的には言語そのものにまだ焦点を当てているのです。しかし、EH の実装を公開することはどんなに簡単なものであっても、EH を理解し、読者の設計の中で EH を安心して使っていただく手助けにはなるでしょう。

心配しなければならないのはただ 1 つ…

スローの後にスタックをさかのぼると、EH は破棄の必要があるローカル オブジェクトはどれかを追跡し、必要なデストラクタの呼び出しをスケジューリングして、正しい例外ハンドラに制御を渡します。この EH の情報整理と管理を実行するために、コンパイラは生成されたコードに暗黙のうちにデータ、命令、ライブラリ参照を挿入します。

残念なことに、多くのプログラマ(そして、その上司)はこうした挿入によって、コードが過度に膨れ上がるのを嫌います。彼らは、ほとんど妄想症なのかと思うほど、EH によって自分たちのプログラムが実用的でなくなるのではないかと心配しています。どうやら EH は人間に生まれつき備わっている未知のものに対する恐怖を誘発するようです。プログラムのソース コードを見ても、EH の内部の仕組みが見えないため、最悪の事態を想定するわけです。

こうした心配のいくつかを和らげるために、Visual C++ で書いたいくつかの短い EH の例を解剖してみましょう。

例 #1:基礎

以下のコードの含まれる新しい C++ ソース ファイル、EH.cpp を作成します。

  class C
   {
public:
   C()
      {
      }
   ~C()
      {
      }
   };

void f1()
   {
   C x1;
   }

int main()
   {
   f1();
   return 0;
   }

次に新しい Visual C++ コンソール アプリケーション プロジェクトを作成し、唯一のソース ファイルとして EH.cpp を指定します。IDE の C/C++ プロジェクトの設定内で、ソースとアセンブリが入り混じる .asm ファイルを生成するように設定します。プロジェクトのほかの設定は既定値のままとします。プロジェクトのデバッグ版をビルドします。筆者のシステムでは、結果として生じる EH.exe ファイルの長さは 23,040 バイトになりました。

EH.asm の中を見てみると、f1 が予想以上にがんばっていることが分かるでしょう。スタック フレームを設定し、x1 のコンストラクタとデストラクタを呼び出して、スタック フレームをリセットします。特に、EH に関連する処理や管理機能が一切ないことに気付くでしょう。驚くほどのことではありませんが、プログラムは例外を一切スローしないし、処理もしないからです。

例 #2:1 つのハンドラ

次に f1 を下のように変えます。

  void f1()
   {
   C x1;
   try
      {
      }
   catch(char)
      {
      }
   }

EH.exe をリビルドし、そのファイルの大きさに注目してください。筆者のシステムでは、このファイルは 23,040 バイトから 29,696 バイトになりました。EH によって、ファイル サイズが恐ろしいことに 29 パーセントも増えると聞いたら、心臓に悪いかもしれません。しかし、絶対増加量を見てみると、6,656 バイトしか変わっていないことがわかります。そしてその増加分のほとんどは、固定サイズのライブラリ オーバヘッドによるものだということがわかります。EH.obj に明示的に挿入された余分のコードとデータは比較的少ないのです。

EH.asm では、定数値として定義されたシンボル、__$EHRec$ が見つかります。このシンボルは、ローカルのスタック フレームへのオフセットを表します。生成されたコードで __$EHRec$ を参照する各関数には、コンパイラによって、ローカルな記憶用「EH レコード」隠しオブジェクトが暗黙のうちに定義されています。

EH レコードは一時的なものです。コード上で固定的な領域を占めることなく、関数に挿入されたときに生まれて、関数が終了すると消え、スタック上でその生涯を全うします。ローカル オブジェクトを早期に破棄する必要がある関数の場合は、コンパイラによって、EH レコード(とそれを管理するローカル コード)が追加されます。

したがって、言うまでもなく関数によっては EH レコードが必要ありません。これを理解するには、2 番目の関数を追加します。

  void f2()
   {
   }

これは、オブジェクトも例外もやり取りしません。これをリビルドします。EH.asm は、前のように EH レコードを含む f1 のスタック フレームを示しますが、f2 のレコードはありません。しかし、コードを次のように変えてみます。

  void f2()
   {
   C x2;
   f1();
   }

すると f2 は、f2 自体に try ブロックがなくてもローカルな EH レコードを定義するようになります。なぜでしょう。f2f1 を呼び出すからです。f1f2 を終了する例外をスローできるので、早期の x2 の破棄が必要になります。

教訓:ローカル オブジェクトを持つ関数が明示的に例外を処理しないにもかかわらず他の例外からスローされた例外を渡せる場合、その関数は EH レコードと関連する管理コードを必要とします。

心配だったら、例外チェーンをショートさせればよいのです。この例では、f1 の定義を変更します。

  void f1() throw()
   {
   C x1;
   try
      {
      }
   catch(char)
      {
      }
   }

f1 は、これで例外をスローしないようになります。その結果、f2f1 からの例外をリークすることができません。したがって、EH レコードを必要としません。これは、プロジェクトをビルドして、EH.asm を調べ、f2 のコードに __$EHRec$ がないことで確認できます。

例 #3:複数のハンドラ

EH レコードとそのサポート コードは、コンパイラが導入する唯一の管理機能ではありません。コンパイラは任意の try ブロックに含まれる各ハンドラごとに、ディスパッチ テーブル エントリも作成します。これをよりわかりやすくするために、現在の EH.asm を別の名前で保存し、f1 を次のように拡張します。

  void f1() throw()
   {
   C x1;
   try
      {
      }
   catch(char)
      {
      }
   catch(int)
      {
      }
   catch(long)
      {
      }

   catch(unsigned)
      {
      }
   }

リビルドしてから、EH.asm の 2 つのバージョンを比較します。

(警告-ウィル ロビンソン:以下の EH.asm コードでは、関係のないものを省いたり、省略記号に置き換えたりしています。また、お使いのシステムで Visual C++ によって生成される正確なラベル名は、ここに示すものとは異なる可能性があります。もちろん、コードを変えれば確実に違うものになります。最後に筆者は .asm については極めていると言えないので、これからお読みになるものをアセンブリ言語のそのもの解説として取らないようにしてください。)

各例外処理ハンドラに対して、コンパイラは .data セグメント内に一意の名前付きの記述子を生成します。各記述子は対応するハンドラの例外タイプに対応するタイプ名をエンコードします(これらはコンパイラがオーバーロードされた関数のために生成したのと同じコード化されたタイプ名です)。

筆者の EH.asm では、対応する名前、記述子、コメントは次の通りです。

  PUBLIC ??_R0D@8 ; char `RTTI Type Descriptor'
PUBLIC ??_R0H@8 ; int `RTTI Type Descriptor'
PUBLIC ??_R0J@8 ; long `RTTI Type Descriptor'
PUBLIC ??_R0I@8 ; unsigned int `RTTI Type Descriptor'

_DATA SEGMENT
??_R0D@8 DD FLAT:??_7type_info@@6B@ ; char `RTTI Type Descriptor'
         DD ...
         DB '.D', ...
_DATA ENDS

_DATA SEGMENT
??_R0H@8 DD FLAT:??_7type_info@@6B@ ; int `RTTI Type Descriptor'
         DD ...
         DB '.H', ...
_DATA ENDS

_DATA SEGMENT
??_R0J@8 DD FLAT:??_7type_info@@6B@ ; long `RTTI Type Descriptor'
         DD ...
         DB '.J', ...
_DATA ENDS

_DATA SEGMENT
??_R0I@8 DD FLAT:??_7type_info@@6B@ ; unsigned int `RTTI Type Descriptor'
         DD ...
         DB '.I', ...
_DATA ENDS

(コメントの「RTTI Type Descriptor」や「type_info」は、Visual C++ が RTTI 用と同じ EH 用のタイプ名記述子を使うことを示唆します。)

コンパイラは、xdata$x セグメントでこうしたタイプ記述子への参照も生成します。各タイプは、そのタイプをキャッチするハンドラのアドレスと組になっています。結果として生じる記述子 / ハンドラの組は、例外を振り分けるために EH ライブラリ コードによって使用されるディスパッチ テーブルを形成します。私の EH.asm を下に示します(コメント / 図を追加しています)。

  xdata$x SEGMENT

$T214 DD ...
      DD ...
      DD FLAT:$T217 ;---+
      DD ...        ;   |
      DD FLAT:$T218 ;---|---+
      DD 2 DUP(...) ;   |   |
      ORG $+4       ;   |   |
                    ;   |   |
$T217 DD ...        ;<--+   |
      DD ...        ;       |
      DD ...        ;       |
      DD ...        ;       |
                    ;       |
$T218 DD ...        ;<------+
      DD ...
      DD ...
      DD 04H        ; # of handlers
      DD FLAT:$T219 ;---+
      ORG $+4       ;   |
                    ;   |
$T219 DD ...        ;<--+
      DD FLAT:??_R0D@8 ; char RTTI Type Descriptor
      DD ...
      DD FLAT:$L206    ; catch(char) address

      DD ...
      DD FLAT:??_R0H@8 ; int RTTI Type Descriptor
      DD ...
      DD FLAT:$L207    ; catch(int) address

      DD ...
      DD FLAT:??_R0J@8 ; long RTTI Type Descriptor
      DD ...
      DD FLAT:$L208    ; catch(long) address

      DD ...
      DD FLAT:??_R0I@8 ; unsigned int RTTI Type Descriptor
      DD ...
      DD FLAT:$L209    ; catch(unsigned int) address

xdata$x ENDS

ディスパッチ テーブルのプリアンブル($T214$T217$T218 といったラベルと関連付けられているコード)は、関数 f1 に固有で、f1 のすべてのハンドラによって共有されます。しかし、$T219 ディスパッチ テーブルの各エントリは、f1 内の特定のハンドラに固有です。

より一般的には、コンパイラは try ブロックを含む各関数に 1 つのテーブル プリアンブルを生成し、try ブロックの各ハンドラにもう 1 つテーブル エントリを生成します。幸いなことに、タイプ記述子はプログラム内のすべてのディスパッチ テーブルで共有されます(たとえば、あるプログラム内のすべての catch(long) ハンドラは同じ ??_R0J@8 タイプ記述子を参照します)。

教訓:EH による領域のオーバーヘッドを少なくするために、例外をキャッチする関数の数、これらの関数に含まれるハンドラの数、およびこれらのハンドラによってキャッチされるタイプの数を最小限にしなければなりません。

例 #4:スローされた例外

実際に例外をスローして、これをまとめて見ましょう。 f1 の try 句を次のように変更します。

  try
   {
   throw 123; // type 'int' exception
   }

いつものようにリビルドして EH.asm を開き、新しいデータ(前と同様、筆者のコメント / 図でまとめてあります)に注目してください。

  ; in these exported names, 'H' is the RTTI Type Descriptor
;   code for 'int' -- which matches the data type of
;   the thrown exception value 123
PUBLIC __TI1H
PUBLIC __CTA1H
PUBLIC __CT??_R0H@84

; EH library routine that actually throws exceptions
EXTRN __CxxThrowException@8:NEAR

; new static data blocks used by library
;   when throwing 'int' exception
xdata$x SEGMENT

__CT??_R0H@84 DD ...                ;<------+
              DD FLAT:??_R0H@8      ;          |   ??_R0H@8 is RTTI 'int'
                                    ;          |    Type Descriptor
              DD ...                ;          |
              DD ...                ;          |
              ORG $+4               ;          |
              DD ...                ;          |
              DD ...                ;          |
                                    ;          |
__CTA1H       DD ...                ;<--+   |
              DD FLAT:__CT??_R0H@84 ;---|---+
                                    ;   |
__TI1H        DD ...                ;   |  __TI1H is argument passed to
              DD ...                ;   |   __CxxThrowException@8
              DD ...                ;   |
              DD FLAT:__CTA1H       ;---+

xdata$x ENDS

タイプ記述子と同様、これらの新しいデータ ブロックはプログラム全体で共有されます。たとえば、int をスローするすべてのコードは、__TI1H を参照します。また、ハンドラ用に参照されているタイプ記述子が throw ステートメントでも同様に使用されていることに注目してください。

次に f1 に移りましょう。関連する部分は次の通りです。

  ;void f1() throw()
;   {
;   try
;      {

       ...
       push $L224 ; Address of code to adjust stack frame via handler
                  ;   dispatch table.  Invoked by __CxxThrowException@8.
       ...

;      throw 123;

       push OFFSET FLAT:__TI1H       ; Address of data area diagramed
                                     ;   above
       mov DWORD PTR $T213[ebp], 123 ; 123 is the exception's value
       lea eax, DWORD PTR $T213[ebp]
       push eax
       call __CxxThrowException@8    ; Call into EH library, which in
                                     ;   turn eventually calls $L224
                                     ;   and $L216 a.k.a. 'catch(int)'
;      }
;   // ...
;   catch(int)

    $L216:

;      {

       mov eax, $L182 ; Return to EH library, which jumps to $L182
       ret 0

;      }
;   // ...

    $L182:

;   // Call local-object destructors, clean up stack, return
;   }

$L224:                         ; This label referenced by 'try' code.
    mov eax, OFFSET FLAT:$T223 ; $T223 is handler dispatch table, what
                               ;   had previously been label $T214
                               ;   before we added 'throw 123'
    jmp ___CxxFrameHandler     ; internal library routine

プログラムが実行されると、__CxxThrowException@8 EH ライブラリ関数は、catch(int) ハンドラのアドレスである $L216 を呼び出します。ハンドラが終了すると、プログラムの実行フローは EH ライブラリのまわりをさまよって、$L224 に移って、そのライブラリのまわりをまた少しばかりさまよってから、最終的には $L182 にジャンプします。このラベルは f1 の強制終了とクリーンアップのためのコードのアドレスです。またこのコードは x1 のデストラクタを呼び出します。デバッガでコードを順に見ていくことでこれをすべて検証できます。

終わりに

例外処理メカニズムはすべてオーバーヘッドが伴います。例外処理という安全策なしに、自分のコードを実行することを望んでいるのでなければ、自ら進んで多少なりとも速度と領域というコストを払うべきです。EH は言語レベルの機能であるという利点があります。すなわち、コンパイラが EH が実装される方法についてよく理解しており、その知識に基づいて最適化を行えるということです。

コンパイラによる最適化のほかに自分自身で最適化を行うこともできます。このシリーズを進めていくに連れ、EH のコストを最低限に押さえる方法について紹介します。こうした方法には標準 C++ には一般的なものもありますが、Visual C++ の実装について特定の前提条件を定めているものもあります。

Robert Shmidt は MSDN のテクニカル ライターです。この他に彼が寄稿している雑誌に、C/C++ Users Journal があります。そこでは、彼は編集補助およびコラムニストとして活躍しています。これまでのキャリアで、ラジオ DJ、野生生物飼育係、天文学者、ビリヤード場管理者、探偵、新聞配達夫、そして大学講師を経験しています。

Deep C++ 用語集