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

Robert Schmidt

1999 年 6 月 3 日

このコラムでは、Robert Schmidt は標準の例外処理に対する Microsoft の拡張である Structured Exception Handling(SEH)と Microsoft Foundation Class(MFC)の例外処理について説明します。

はじめに

筆者の前回のコラムでは、例外の分類とそれらを管理するための標準 C ライブラリのサポートについて検討しました。今回は、こうした標準のメカニズムに対する Microsoft の拡張である構造化例外処理(SEH)と Microsoft Foundation Class(MFC)の例外処理について説明します。SEH は C と C++ の両方で使用できますが、MFC のメカニズムは(MFC 全般と同様)C++ でしか使用できません。

(ちょっと脱線:次回のコラムを皮切りに、Deep C++ の連載は毎月第一および第三木曜日に発行されます。したがって、各コラムはこのコラムの約半分の長さになりますが、総量としての深さと広さは変わりません。)

構造化例外処理

構造化例外処理とは、実は任意の言語のプログラムで利用できる一連の Windows®のサービスです。Visual C++®では、Microsoft はこれらのサービスを非標準のキーワードとライブラリ ルーチンを使ってパッケージングして、抽象化しています。Windows 用のトランスレータを開発しているほかのベンダーは、同様の結果が得られる別の方法を選択している場合がありますが、このコラムの一連の特集では、「構造化例外処理」および「SEH」は、基本となる Windows 例外サービスを Visual C++ で表現したもの指します。

キーワード

SEH をサポートするために、Microsoft は C と C++ 言語を次の 4 つの新しいキーワードで拡張しました。

  • __except

  • __finally

  • __leave

  • __try

これらのキーワードは非標準の言語拡張なので、こうした拡張が使えるようにしてコンパイルする必要があります(/Fa オプションをオフにする)。

これらのキーワードにアンダースコアが付いているのはなぜでしょう。C++ の標準規格(17.4.3.1.2 節「グローバル名」)では次のように規定されています。

ある特定のセットの名前と関数シグネチャは、[トランスレータまたはコンパイラの]実装によって常に予約されています。

  • 二重のアンダースコア(__)が含まれる名前、または後ろに大文字が付くアンダースコアで始まる名前は、任意の目的のために実装によって予約されています。

  • アンダースコアで始まる名前は、グローバル ネームスペースで使う名前として実装によって予約されています。

C の標準規格でも、C の実装について同様の説明が記載されています。

SEH キーワードは上記の規則に準拠しているので、Microsoft はこれらを使用することができます。明示的には規定されていませんが、予約されている名前を移植可能なプログラムで使うことはできません。つまり、__MYHEADER_H___FatalError といった名前を持つ識別子を定義しないようにしなければならないということです。

興味深い一方で残念なことには、Visual C++ アプリケーション ウィザードは、予約された識別子を不正に使用するソース コードを生成できます。たとえば、ATL COM AppWizard で新しいサービスを作成する場合、結果として生成されるスケルトン コードは _Handler_twinMain などの名前を定義できます。標準規格で移植可能なプログラムで使用してはならないと規定されている名前です。

標準に準拠しないこの振る舞いを緩和するために、ウィザードによって生成されたソースを手作業で変更することができます。運の良いことに、疑わしい名前の多くは定義されているクラスの外のコードからはアクセスできないプライベートなクラス メンバです。影響を受ける .h および .cpp ファイルでこれらの名前をグローバルに置換するだけで十分です。残念なことに、ある関数(_twinMain)とあるオブジェクト(_Module)が extern 宣言されているため、同じプログラムのほかの部分がこれらの名前に依存する可能性があります(実際、Visual C++ のライブラリである libc.lib は、_twinMain という名前がリンク時に使用できるようになっていることを要求します)。

ウィザードで生成された名前はそのまま残すことをお勧めします。それ以外では、コードの中で移植性のない名前を定義してはいけません。さらに、プログラムの保守に役立つように、移植性のない定義をすべて文書化しておく必要があります。将来のバージョンの Visual C++(そして他の C++ トランスレータの現在のバージョン)では、同じ名前が違う目的で使われる可能性がありますが、その場合にはコードが動作しなくなってしまいます。

識別子

Microsoft はまた、windows.h によってインクルードされる非標準ヘッダ excpt.h で SEH 識別子をいくつか定義しています。内部で使用するもののほかに、特に注目すべき定義として次のものを挙げておきます。

  • __except フィルタ式の値を定義するマクロ。

  • 例外の情報と状態を問い合わせる Win32 オブジェクトと関数に対するエイリアスとして機能するマクロ。

  • 上記の 4 つのキーワードと同じ名前と意味を持つが最初にアンダースコアの付かない擬似キーワード マクロ(たとえば leave というマクロは展開すると SEH キーワードである __leave になります)。

これらのマクロを使って、Microsoft は筆者の頭が変になってしまうようなことをやってのけました。同じ関数に対して複数のエイリアスを定義したのです。たとえば、excpt.h には以下の宣言と定義があります。

  unsigned long __cdecl    _exception_code(void);
#define GetExceptionCode _exception_code
#define exception_code   _exception_code

つまり、同じ関数を呼び出すの方法が 3 通りあるということです。どのエイリアスを使えばいいのでしょう。そして、それはプログラムの所有者が期待しているエイリアスなのでしょうか。

ドキュメントでは、Microsoft は他のグローバルな Windows API 関数の形式と一致する名前である GetExceptionCode を推奨しているようです。筆者が MSDN Online を検索した結果、GetExceptionCode に一致する項目は 33 も見つかりました。一方、_exception_code は 2 つだけで、exception_code にいたっては 1 つもありませんでした。筆者は Microsoft に倣って GetExceptionCode 形式のエイリアスをお勧めします。

_exception_code エイリアスの 2 つもマクロなので、同じ名前を再使用することはできません。筆者はこのコラム用のコード サンプルを開発しているときにこの問題に突き当たりました。exception_code という名前のローカル オブジェクトを定義したと思ったのですが、実際に定義したのは _exception_code という名前のローカル オブジェクトでした。というのは、私が(不注意に)使用した exception_code というマクロが展開された結果がそれだったからです。この問題を認識したときにとった解決方法はいたって簡単で、オブジェクトの名前を exception_code から code に変えるというものでした。

最後に、excpt.h は 1 つの特別なマクロ、try を定義します。これは C++ では純粋なキーワードです。この結果、try マクロを #undef しない限り、SEH と標準 C++ 例外ブロックを、excpt.h がインクルードされているトランスレーション ユニットで簡単には混在させることができないことを意味します。try マクロを未定義にすれば実際の try キーワードが使えるようになりますが、SEH に詳しい保守担当者を混乱させるリスクもあります。一方、標準 C++ に詳しいプログラマは、try がマクロではなくキーワードであることを期待します。

ヘッダをインクルードすることで(たとえそれが excpt.h のように非標準のものであったとしても)、標準に準拠していたコードの振る舞いが変わってしまうようなことがあってはならないと筆者は信じています。また、標準規格で定義されているキーワードをマスクしたり再定義したりするのは悪い習慣だと強く感じています。ここで、筆者のアドバイスを。#undef try して、他の擬似キーワード マクロを使用しないで、実際のキーワード(__try など)を使用してください。

構文

構造化例外処理における基本的な「構造化された」構文要素は try ブロックです。こうしたブロックの基本的な形式は次の通りです。

__try compound-statement handler

handler は

__except ( filter-expression ) compound-statement

__finally compound-statement

のどちらかです。

もっと図式化すると、任意の try ブロックは次のどちらかのように見えなくてはなりません。

  __try
   {
   ...
   }
__except(filter-expression)
   {
   ...
   }
  

あるいは

  __try
   {
   ...
   }
__finally
   {
   ...
   }

try ブロックの __try 部分では、leave ステートメントを使うことができます。

  __try
   {
   ...
   __leave;
   ...
   }

もっと大きな構文コンテキストでは、try ブロック全体が 1 つのステートメントとみなされます。したがって、

  if (x)
   {
   __try
     {
     ...
     }
   __finally
     {
     ...
     }
   }
  

上のコードは下のコードと同じです。

  if (x)
   __try
     {
     ...
     }
   __finally
     {
     ...
     }

その他の注意点は次の通りです。

  • 任意の try ブロックには必ず 1 つだけハンドラがなければなりません。

  • ステートメントはすべて複合ステートメントでなければなりません。__try__except、または __finally の後に続くステートメントが単独のステートメントであっても、そのステートメントを { } で囲まなければなりません。

  • 例外ハンドラでは、対応する filter-expression は int 型であるか、それに変換できなければなりません。

基本的なセマンティックス

前回のコラムで、例外の寿命を 5 つの段階にわけて説明しました。構造化例外処理の範囲では、それらの檀家が次のように実装されます。

  • オペレーティング システム(OS)はハードウェア エラーをトラップするかソフトウェア エラーを検出します。またはユーザー コードがエラーを検出します(第 1 段階)。

  • おそらく Win32 関数の RaiseException へのユーザーの呼び出しによって呼び出された OS は例外オブジェクトを生成し、発行します(第 2 段階)。オブジェクトは構造体として実装され、例外ハンドラが利用できるプロパティを持っています。

  • これらのハンドラは例外を「見て」、これを処理するチャンスを得ます(第 3、第 4 段階)。ハンドラのセマンティックスに応じて、例外は再開型か終了型となります(第 5 段階)。

簡単な例を以下に示します。

  int filter(void)
   {
   /* 第 4 段階 */
   }
int main(void)
   {
   __try
     {
     if (some_error) /* 第 1 段階 */
       RaiseException(...); /* 第 2 段階 */
     /* 再開型例外の第 5 段階 */
     }
   __except(filter()) /* Stage 3 */
     {
     /* 終了型例外の第 5 段階 */
     }
   return 0;
   }

Microsoft は、「__except 例外ハンドラ」で定義されたハンドラと「__finally 終了ハンドラ」で定義されたハンドラを呼び出します。

例外が発行されたら、__except で始まる例外ハンドラは、例外の発生地点から外向きに、関数の呼び出しチェーンを呼び出し元の方向にさかのぼりながら検索されます。各例外ハンドラが発見されるたびに、そのフィルタ式が評価されます。フィルタ評価の後に何が起こるかは、フィルタの結果値によって異なります。

excpt.h は 3 つのフィルタ値用のマクロを定義します。どれも型は int です。

  • EXCEPTION_CONTINUE_EXECUTION = -1

  • EXCEPTION_CONTINUE_SEARCH = 0

  • EXCEPTION_EXECUTE_HANDLER = 1

このコラムの最初で、フィルタ式は 3 つのマクロの値と一致できるように int 型と互換性がなければならないと説明しました。この忠告が保守的過ぎると思われたかもしれません。しかし筆者の経験では、Visual C++ はフィルタ式としてあらゆる整数値型、ポインタ型、構造体型、配列型、そして void 型さえも受け入れます(ただし、浮動小数点フィルタを試したところ、見事に内部コンパイラ エラーが帰ってきました)。

さらに、整数型ならあらゆるフィルタ値が有効のようです。符号ビットの付かないゼロ以外の値は EXCEPTION_EXECUTE_HANDLER のように動作しますが、符号ビット付きのゼロ以外の値は EXCEPTION_CONTINUE_EXECUTION のように動作します。おそらく重要なのは値を構成するビットのパターンなのでしょう。

例外ハンドラのフィルタが EXCEPTION_CONTINUE_SEARCH と評価された場合、ハンドラは実質的に例外のキャッチを拒否したことになり、別の例外ハンドラを求めて検索が継続されます。

フィルタが(EXCEPTION_CONTINUE_SEARCH 以外の値として評価されることによって)例外をキャッチしたら、プログラムが回復されます。どのように回復されるかも、フィルタの値によって異なります。

  • EXCEPTION_CONTINUE_EXECUTION:再開型の例外を表します。例外の発生地点の直後から実行が再開します。例外ハンドラのコードそのものは実行されません。

  • EXCEPTION_EXECUTE_HANDLER:終了型の例外を表します。例外の発生地点からコール スタックを逆進行しながら、途中で遭遇した終了ハンドラをそれぞれ実行します。この逆進行は例外をキャッチしたフィルタを持った例外ハンドラのところで停止します。そこでハンドラに入って実行が継続されます。

__finally で始まる終了ハンドラは、その名前の示す通り、終了型例外が発生すると呼び出されます。クリーンアップを行うコードという点では、これらのハンドラは標準 C ライブラリの atexit ハンドラと C++ のデストラクタに似ています。しかし、終了ハンドラはハンドラ以外のコードと同様、通常の実行フローの中でも実行することができるので、似ているとは言っても一部分だけです。これとは対照的に、例外ハンドラは常に実際のハンドラとして動作します。これらは対応するフィルタが EXCEPTION_EXECUTE_HANDLER と評価された場合にのみ実行されます。

終了ハンドラは、通常のフローから実行されたのか try ブロックが以上終了したために実行されたのかを知る手段は組み込まれていません。実行された理由を知るために、ハンドラの中で AbnormalTermination を呼び出します。このルーチンは、int を返します。値がセロの場合、ハンドラは通常の実行フローの一部として実行されたということです。それ以外の値の場合、ハンドラは異常終了が原因で実行されています。

AbnormalTermination は実は _abnormal_termination に対する別名マクロです。面白いことに、Visual C++ は _abnormal_termination をほとんどキーワードのように文脈依存の組み込み関数として扱います。この関数はどこからでも呼び出せるというわけではありません。これは終了ハンドラ内から呼び出さなければなりません。つまり、残念ながらハンドラから別の関数をはさんで _abnormal_termination を間接的に呼び出すことはできません。そのようにしようとしてもコンパイル時にエラーとなります。

プログラム例

以下の C 言語の例は、様々なフィルタ値とハンドラ型との相互作用を示します。例 1 は、小さい完全なプログラムです。以降の各例は、例 1 に少しずつ手を加えたものです。どの例にもそれぞれ注釈が付いているので、制御フローと振る舞いを見ることができます。

このプログラムは RaiseException を使って例外オブジェクトを発行します。この関数の最初のパラメータは、例外コードで、型は 32 ビットの符号なし整数(DWORD)です。Microsoft はユーザー定義エラーを表すために 0xE0000000~ 0xEFFFFFFF までの範囲の例外コードを予約しています。RaiseException のほかのすべてのパラメータは通常の使用では 0 を受け入れます。

ここで使用した例外フィルタは、かなり原始的なものです。「現実」には、フィルタは発行された例外オブジェクトのプロパティを問い合わせるために、GetExceptionCodeGetExceptionInformation を呼び出すことになるでしょう。

例 #1:終了型例外

Visual C++ で、すべて標準の設定を使用して、SEH_test という名前の空の Win32 コンソール アプリケーション プロジェクトを作成します。このプロジェクトに以下の C ソース ファイルを追加します。

  #include <stdio.h>
#include "windows.h"
#define filter(level, status) \
   ( \
   printf("%s:%*sfilter => %s\n", \
         #level, (int) (2 * (level)), "", #status), \
   (status) \
   )
#define termination_trace(level) \
   printf("%s:%*shandling %snormal termination\n", \
         #level, (int) (2 * (level)), "", \
         AbnormalTermination() ? "ab" : "")
static void trace(int level, char const *message)
   {
   printf("%d:%*s%s\n", level, 2 * level, "", message);
   }
extern int main(void)
   {
   DWORD const code = 0xE0000001;
   trace(0, "before first try");
   __try
      {
      trace(1, "try");
      __try
         {
         trace(2, "try");
         __try
            {
            trace(3, "try");
            __try
               {
               trace(4, "try");
               trace(4, "raising exception");
               RaiseException(code, 0, 0, 0);
               trace(4, "after exception");
               }
            __finally
               {
               termination_trace(4);
               }
         end_4:
            trace(3, "continuation");
            }
         __except(filter(3, EXCEPTION_CONTINUE_SEARCH))
            {
            trace(3, "handling exception");
            }
         trace(2, "continuation");
         }
    __finally
         {
         termination_trace(2);
         }
      trace(1, "continuation");
      }
   __except(filter(1, EXCEPTION_EXECUTE_HANDLER))
      {
      trace(1, "handling exception");
      }
   trace(0, "continuation");
   return 0;
   }
  

次にコードをビルドします(未使用のラベル、end_4 について警告メッセージが出るかもしれません。ここではそのメッセージは無視してください)。

実装に関しては、以下の点に注意してください。

  • このプログラムにはネストした 4 つの try ブロックが含まれています。このうち 2 つには例外ハンドラが、残りの 2 つには終了ハンドラが含まれています。練習用の例ということで、ネスティングと制御フローがよりわかりやすいよう、ここでは、すべてのブロックを 1 つの関数に入れてしまいました。実際のコードでは、おそらく複数の関数やトランスレーション ユニットにこれらのブロックを分散して実装するでしょう。

  • 実行が追跡され、結果の出力に現在のブロックのネストの深さを表すレベルが表示されます。

  • 例外フィルタがマクロ フィルタによって実装されます。マクロの最初のパラメータは、try ブロックのネストのレベルであり、2 番目のパラメータがフィルタによって実際に生成された値です。

  • 終了ハンドラは termination_trace マクロを使って実行を追跡します。このマクロは、終了ハンドラに入った理由を出力します(1 つの例外もアクティブになっていない場合でも、終了ハンドラが実行される場合もあることを思い出してください)。

このプログラムを実行すると、以下の出力が得られます。

  0:before first try
1:  try
2:    try
3:      try
4:        try
4:        raising exception
3:      filter => EXCEPTION_CONTINUE_SEARCH
1:  filter => EXCEPTION_EXECUTE_HANDLER
4:        handling abnormal termination
2:    handling abnormal termination
1:  handling exception
0:continuation

処理は次のように進みます。

  • レベル 4 の try ブロックが例外を発行します。これにより、例外をキャッチする例外フィルタを求めて、レベルのチェーンを後ろ向きにさかのぼって検索が行われます。

  • 最初に現れる例外フィルタ(レベル 3)は EXCEPTION_CONTINUE_SEARCH と評価されます。これは、フィルタが例外をキャッチすることを拒否したことを意味します。別のハンドラを求めて検索が続きます。

  • 次に現れる例外フィルタ(レベル 1)は EXCEPTION_EXECUTE_HANDLER と評価されます。今度はフィルタが例外をキャッチします。フィルタの値のために、例外は、終了する例外として処理されます。

  • 制御は例外が発行された場所に戻され、スタックがさかのぼられます。途中で遭遇する終了ハンドラはすべて実行され、異常終了が起きたことが認識されます。例外を実際にキャッチする例外ハンドラ(レベル 1)に達すると、検索が停止します。検索の途中では終了ハンドラだけが実行されます。間のレベルにあるほかのコードはすべて無視されます。

  • 実際にキャッチする例外ハンドラ(レベル 1)に制御が渡されると、通常の状態で実行が継続されます。

制御が同じレベルで 2 回後ろ向きに通過することに注目してください。最初が例外フィルタの評価のため、2 回目がスタックをさかのぼって終了ハンドラを実行するためです。しかしこの場合、例外フィルタによって終了ハンドラが予期していない形で何らかの変更が加えられると問題が発生するもととなります。そこで、一般的な規則として、フィルタの内部で何らか二次的な処理をしないようにします。二次的な処理が必要ということであれば、それらを終了ハンドラで行うようにします。

例 #2:キャッチされない例外

次のプログラム例では、前のプログラムの

  __except(filter(1, EXCEPTION_EXECUTE_HANDLER))

という行を次のように変えています。

  __except(filter(1, EXCEPTION_CONTINUE_SEARCH))

これによって、どの例外フィルタも例外をキャッチしません。修正したプログラムを実行すると、次のようになります。

  0:before first try
1:  try
2:    try
3:      try
4:        try
4:        raising exception
3:      filter => EXCEPTION_CONTINUE_SEARCH
1:  filter => EXCEPTION_CONTINUE_SEARCH

この後に次のダイアログ ボックスが現れます。

図 1:[ユーザー例外(User Exception)]ダイアログボックス

[詳細(Details>>)]をクリックすると、次が表示されます。

図 2:[ユーザー例外]ダイアログボックスの詳細

このエラーメッセージで、SEH_TEST は問題のプログラムであり、e0000001H はもともと RaiseException に渡された例外コードです。

この例外はプログラムの外にまで波及し、最終的に OS によってキャッチされ処理されます。まるで元のプログラムが次のように書かれていたようにです。

  __try
   {
   int main(void)
      {
      ...
      }
   }
__except(exception_dialog(), EXCEPTION_EXECUTE_HANDLER)
   {
   }

ダイアログボックスで[閉じる]をクリックすると、制御がキャッチを行うハンドラに返されるまで、すべての終了ハンドラが実行され、スタックはプログラム例 1 と同様にさかのぼられます。これは、次の行を見ればわかります。

  4:        handling abnormal termination
2:    handling abnormal termination

これらの行は、ダイアログボックスを閉じると表示されます。次の行が表示されないことに注目してください。

  0:continuation

これは、この行を出力するコードが終了ハンドラの外にあり、さかのぼっている途中で実行されるのは終了ハンドラだけだからです。

このテスト プログラムで残念なのは、キャッチを行うハンドラが main の外にあることです。これはつまり、例外処理後の実行がプログラムを越えて継続するということです。その結果、プログラムは強制終了されます。

例 #3:再開する例外

この例では、

   __except(except_filter(3, EXCEPTION_CONTINUE_SEARCH))

という行を次のように変えました。

  __except(except_filter(3, EXCEPTION_CONTINUE_EXECUTION))

リビルドして、再実行すると、次の出力が得られます。

  0:before first try
1:  try
2:    try
3:      try
4:        try
4:        raising exception
3:      filter => EXCEPTION_CONTINUE_EXECUTION
4:        after exception
4:        handling normal termination
3:      continuation
2:    continuation
2:    handling normal termination
1:  continuation
0:continuation

レベル 3 のフィルタが例外をキャッチしたので、レベル 1 フィルタは無視されます。このキャッチを行うフィルタは EXCEPTION_CONTINUE_EXECUTION と評価されます。その結果、例外は再開型例外として扱われます。例外ハンドラには入らず、例外が生じた地点から通常の実行が再開されます。

終了ハンドラに注目してください。その出力が示すように、すべては通常終了(例外でない終了)です。例外がない場合は、終了ハンドラは他と同じように実行される通常のコードであるということを示すものです。

例 #4:その他の異常終了

次を見てください。

  __try
   {
   /* ... */
   return;
   }

あるいは

  __try
   {
   /* ... */
   goto label;
   }
__finally
   {
   /* ... */
   }
/* ... */
label:

これらは、try ブロックの異常終了とみなされます。以降の AbnormalTermination への呼び出しは、例外が起きたかのように、ゼロ以外の値を返します。

この効果を見るには、次のようにします。

  trace(4, "raising exception");
RaiseException(exception_code, 0, 0, 0);

上記の 2 行を次のように変えます。

  trace(4, "exiting try block");
goto end_4;

これでレベル 4 の try ブロックは、例外ではなく、goto によって終了するようになります。以降、実行時の結果は次のようになります。

  0:before first try
1:  try
2:    try
3:      try
4:        try
4:        exiting try block
4:        handling abnormal termination
3:      continuation
2:    continuation
2:    handling normal termination
1:  continuation
0:continuation

レベル 4 の終了ハンドラは、例外が 1 つも発行されないにもかかわらず、異常終了を処理している認識します(実際に例外が起きていたとしたら、少なくとも 1 つの例外フィルタからのトレース出力が表示されているはずです)。

教訓:例外が発生したかどうかを知るのに AbnormalTermination だけに頼ることはできません。

例 #5:他の通常終了

try ブロックを通常どおりに終了させながらも、AbnormalTerminationFALSE にしたければ、Microsoft 固有のキーワード __leave を使います。これを行うには、次の行

  goto end_4;

を次のように変えます。

  __leave;

プログラムをリビルドして再実行します。結果は次のようになります。

  0:before first try
1:  try
2:    try
3:      try
4:        try
4:        exiting try block
4:        handling normal termination
3:      continuation
2:    continuation
2:    handling normal termination
1:  continuation
0:continuation

この出力は、例 4 の出力と同一ですが、1 つだけ違いがあります。今度はレベル 4 の終了ハンドラが通常の終了を処理している認識するということです。

例 #6:明示的な例外

今まで紹介したサンプル プログラムは、ユーザーによって生成された例外を処理するものばかりですが、SEH は Windows 自体がスローする例外も管理できます。

次の行

  trace(4, "exiting try block");
__leave;

を次のように変えます。

  trace(4, "implicitly raising exception");
*((char *) 0) = 'x';

これで、Windows 内でメモリ アクセスの例外が生じます(ヌル ポインタの逆参照)。また、

  __except(except_filter(3, EXCEPTION_CONTINUE_EXECUTION))

という行を次のように変えます。

  __except(except_filter(3, EXCEPTION_EXECUTE_HANDLER))

これで、このプログラムは例外をキャッチし、処理します。

例外の結果は次のようになります。

  0:before first try
1:  try
2:    try
3:      try
4:        try
4:        implicitly raising exception
3:      filter => EXCEPTION_EXECUTE_HANDLER
4:        handling abnormal termination
3:      handling exception
2:      continuation
2:    handling normal termination
1:  continuation
0:continuation

期待通り、Windows はレベル 4 で例外を発行します。これを私たちのハンドラがレベル 3 でキャッチしました。

どの例外コードがキャッチされたかを正確に知りたいという好奇心旺盛な人なら、例 2 で行ったように例外を main の外まで波及させます。仕上げに、次の行

  __except(except_filter(3, EXCEPTION_EXECUTE_HANDLER))

を次のように変えます。

  __except(except_filter(3, EXCEPTION_CONTINUE_SEARCH))

詳細(Details>>)]をクリックすると表示されるダイアログボックスは、熟練 Windows ユーザーでなくても見慣れているものです。

'

図 3:[メモリ例外(Memory Exception)]ダイアログボックス

具体的な例外コードが表示された例 2 のダイアログボックスとは異なり、このダイアログボックスは、ユーザーにわかりやすいとされている「不正なページ フォールト(invalid page fault)」としか表示されません。

C++ で考慮すべきこと

C と互換性のあるエラー処理のメカニズムの中で、SEH は(少なくとも Windows の世界では)ずば抜けて完成度が高く柔軟性があります。しかし、皮肉なことに、SEH は Windows の世界以外では、最も柔軟性がないのです。なぜなら、これには特定のターゲット アーキテクチャと Visual C++ とソース レベルで互換性のあるトランスレータの両方に依存するからです。

完全に C だけでプログラミングする場合、あるいはコードを Windows 以外のターゲットに移植する可能性がまったくない場合は、SEH を使うことを検討しましょう。しかし、C++ でプログラミングしたり、コードに移植性を持たせたい場合は、可能な限り標準 C++ の例外を使うことを強くお勧めします。同じプログラムで SEH と標準 C++ の両方の例外を使うことができます。ただし、1 つ注意があります。SEH の try ブロックを含む関数の中でオブジェクトが定義し、そのオブジェクトに標準以外のデストラクタがある場合、コンパイラからエラーが出されます。こうしたオブジェクトを同じ関数内で __try と一緒に使うには、標準 C++ の例外を無効にしなければなりません。

(Visual C++ では標準設定で標準 C++ 例外をオフにします。これをオンにするには、コマンドライン オプション、/GX を使うか、Microsoft Visual Studio®の[プロジェクト(Project Settings)]ダイアログ ボックスで設定します。)

今後、このコラムで、標準 C++ の例外での SEH について再度説明します。特に、構造化された例外と Windows ライブラリ サポートを C++ 例外と標準 C++ ライブラリ サポートにマップすることによって、SEH を C++ のメインストリームに統合したいと思っています。

MFC の例外処理

注意:この節では、ちょっと先を見て、次のコラムで紹介する標準 C++ 例外処理について触れる必要があります。これから説明することは、避けて通ることのできず、驚くにあたらないものです。なぜなら、Microsoft は MFC 例外の構文とセマンティックスを、標準 C++ で使用される例外のマクロと意味に基づいているからです。

これまでに説明してきたすべての例外処理の方法は、C と C++ の両方で使用できます。これらのほかにも、Microsoft は、C++ プログラムのユーザーにソリューションを提供しています。MFC 例外処理クラスとマクロです。Microsoft は今、これらの MFC のメカニズムを時代遅れのものと考えており、代わりに標準 C++ の例外処理を使用するよう呼びかけています。しかし、Visual C++ は上位互換性を維持するために、まだ MFC クラスとマクロをサポートしているので、MFC のメカニズムについて、比較的簡単に概要をご紹介しましょう。

Microsoft は、標準 C++ の例外を使って、MFC のバージョン 3.0 以降を実装します。したがって、MFC を使うには、標準 C++ の例外を明示的に使用しなくても、この例外をアクティブにする必要があります。今述べたように、SEH を使う際に標準 C++ を無効にしたい場合もあります。これは、MFC マクロと SEH を同じプログラムでアクティブにすることができない場合があるということを意味しています。Microsoft はさらに、2 つの例外メカニズムが相互に排他的で、同じプログラムで一緒に使うことはできないと文書に記しています。

SHE はコンパイラのキーワード set を拡張するのに対して、MFC は次のマクロ ファミリーを定義します。

  • TRY

  • CATCH、AND_CATCH、END_CATCH

  • THROW と THROW_LAST

これらのマクロは C++ 例外のキーワード trycatchthrow によく似ています。

さらに、MFC は例外クラスの階層を提供します。すべて CXXXException という形式の名前を持ち、CException という抽象基本クラスから派生しています。このモデルは、std::exception から派生し <stdexcept> で宣言されている標準 C++ ライブラリの階層に似ています。しかし、標準 C++ がほとんどのタイプの例外オブジェクトを管理できるのに比べ、MFC マクロは CException から派生したオブジェクトだけを管理できます。

各 MFC 例外クラス CXXXException には、それに対応するクラス タイプのオブジェクトを作成し、初期化し、スローするグローバル ヘルパー関数 AfxThrowXXXException があります。これらのヘルパー関数をあらかじめ定義されている例外タイプに使用しますが、THROW マクロは自分で定義したオブジェクト(これも CException から派生しなければなりません)について使用します。

デザインの基本的な方針は次の通りです。

  • TRY ブロック内で例外を生成する可能性のあるコードをラップします。

  • CATCH によって例外を検出し、処理します。ハンドラは実際にはオブジェクトをキャッチしませんが、その代わり以前に割り当てられていたオブジェクトへのポインタをキャッチします。MFC は C++ の動的タイプによって例外オブジェクトを識別します。例外コードの実行時に検索して例外を識別する SEH と比較してみてください。

  • 1 つの TRY ブロックに複数のハンドラをアタッチできます。それぞれのハンドラで異なる静的な C++ の型をキャッチします。TRY ブロックの最初のハンドラは、マクロ CATCH を使用します。同じブロックの次のハンドラは、AND_CATCH を使用します。END_CATCH を使ってハンドラ シーケンスを終了します。

  • MFC は、内部的に例外を発行する可能性があります。さらに、THROW または MFC ヘルパー関数のどちらかを経由して、例外を明示的に発行する可能性もあります。例外ハンドラでは、THROW_LAST は最も最近キャッチされた例外を再度スローします。

  • 例外が発行されると、例外ハンドラは SEH と同様、内から外に向かって検索されます。適切なタイプのハンドラが見つかると、検索は停止します。すべての例外が終了型です。しかし、SEH とは異なり、MFC は終了ハンドラの概念を持ちません。したがって、その代わりにローカル オブジェクトのデストラクタを利用します。

上記をほとんど含んだ簡単な MFC の例を下に示します。

  #include <stdio.h>
#include "afxwin.h"
void f()
   {
   TRY
      {
      printf("raising memory exception\n");
      AfxThrowMemoryException();
      printf("this line should never appear\n");
      }
   CATCH(CException, e)
      {
      printf("caught generic exception; rethrowing\n");
      THROW_LAST();
      printf("this line should never appear\n");
      }
   END_CATCH
   printf("this line should never appear\n");
   }
int main()
   {
   TRY
      {
      f();
      printf("this line should never appear\n");
      }
   CATCH(CFileException, e)
      {
      printf("caught file exception\n");
      }
   AND_CATCH(CMemoryException, e)
      {
      printf("caught memory exception\n");
      }
 /* ... CException から派生したほかの型のハンドラ ... */
   AND_CATCH(CException, e)
      {
      printf("caught generic exception\n");
      }
   END_CATCH
   return 0;
   }
/*
   実行すると以下を出力します
   raising memory exception
   caught generic exception; rethrowing
   caught memory exception
*/

ハンドラは実際のオブジェクトではなく、オブジェクトのポインタをキャッチすることを覚えておきましょう。したがって、次のハンドラは、スローされた例外オブジェクトを参照するローカル ポインタ、CException *e を定義します。

  CATCH(CException, e)
   {
   // ...
   }

C++ のポリモーフィズム(多態性)のおかげで、このポインタは CException から派生したどのオブジェクトも参照できます。

同じ try ブロックに複数のハンドラがある場合、ハンドラは上から下への順番で一致します。その結果、CException のハンドラよりも前に、さらに派生したオブジェクトのハンドラを定義しなければなりません。そうしないと、より限定されたハンドラは、例外を検出することができません(これもポリモーフィズムのおかげです)。

一般的には、CException をキャッチしたいでしょうから、MFC は標準マクロに対してこれらの代わりとなる複数の CException 固有のマクロを定義します。

  • CATCH_ALL(e) と AND_CATCH_ALL(e) は、CATCH(CException, e) と AND_CATCH(CException, e) と同じです。

  • END_CATCH_ALL は、CATCH_ALL... AND_CATCH_ALL シーケンスを終了します。

  • END_TRY は CATCH_ALL(e);END_CATCH_ALL と同等です。これにより TRY... END_TRY は、空のハンドラまたはすべてのスローされた例外のシンクとして動作します。

ポイントされた例外オブジェクトは、MFC によって暗黙のうちに割り当てが解除されます。これは、キャッチされたポインタに対し、誰の所有権も想定しない標準 C++ の例外ハンドラとは異なります。したがって、MFC と標準 C++ の両方のメカニズム使って、同じ例外オブジェクトを処理してはなりません。さもなければ、メモリ リークを起こしたり、割り当て解除されたオブジェクトを参照したり、同じオブジェクトを何度も割り当て解除したりするおそれが生じます。

終わりに

MSDN Online では、構造化例外処理と MFC 例外マクロの両方に関する文献をこのコラムのほかにもいくつかご覧いただけます。

次回のコラムでは、標準 C++ 例外について紹介し、その機能と論理的根拠の概略を説明します。また、これまで見てきたその他のメソッドの比較対照も行います。

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

Deep C++ 用語集