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

Robert Schmidt

1999 年 5 月 10 日

Robert Schmidt は初めてのコラムで、C++ で例外を処理する方法を解説します。

はじめに

ご機嫌いかがでしょうか。今度新たに MSDN Online Voice に加わった Robert(Bobby)Schmidt です。C と C++ を使うプログラマを対象とした私の連載コラム Deep C++ へようこそ。このコラムの前置きとして、まずは自己紹介と、このコラムが MSDN の中で占める位置について説明させていただきます。

収入を得ることがプロの証なら、私のプログラマとしてのキャリアは 18 年前に故郷のオハイオ州デイトンで始まりました。より刺激的な景色とそれほど刺激的でない気候を求めて、1987 年に Seattle に移りました。そして、その 2 年後に Microsoft で働き始めたのです。しかし、やがて会社組織に所属することの束縛に耐えられなくなり、1995 年にフリーランスになりました(Microsoft が今もなお私の重要な顧客であるのも皮肉なことかもしれません)。その間、オーストラリアのシドニー、ニューヨーク州のライムストーンという小さな町、そしてアリゾナ州のセドナという素晴らしい場所に住んだ経験があります。

コラム憲章

名前が示すとおり、このコラムでは個々のテクノロジではなく、C と C++ の言語そのものについて取り上げています。私が今までに見たところ、プログラマはあまりにも簡単に、「クール」なテクノロジに飛びつく傾向があり、テクノロジを成立させているのがプログラミング言語であることを忘れているようです。このコラムの方針は、今までの MSDN の使命から外れているでしょうか。私はそうは思いません。

表現手段の習熟度は、その手段を通した表現の質に直接的に反映します。しかし、かなりの数のプログラマがその表現手段の仕組みについて不完全、または間違った知識を持っているようで、欠陥だらけの設計や、場当たり的な実装という結果をもたらしています。そのため、このコラムでは言語、つまり表現手段そのものについて取り上げたいと思います。

私は性格の面でも、職業的にも技術不可知主義です(とはいえ、マッキントッシュが好きなことは認めざるをえません。私が自宅で使っているコンピュータはすべてマッキントッシュです)。しかし、人はみな特定のテクノロジに対して、他のテクノロジより、強い関心を寄せます。たとえば、MSDN でこのコラムを読んでいる読者は、とくに Microsoft のテクノロジに関心があることでしょう。しかし、この世にある Win32®に関する知識を総動員しても、基盤にある言語の使い方が間違っていれば何の役にも立ちません。C と C++ はとても強力な支援者になり得ます。しかし、あらゆる強力なものがそうであるように、今日の友も扱い方を間違うと、明日の敵になる可能性があるのです。

まずは、言語を徹底的に理解することを目指し、それから、そこで得た知識を具体的なテクノロジの領域に適用していきます。その中で、言語の規則を定義する ISO 標準規格についてはたびたび言及します。また、ウィザードやラッパーの謎を解き明かし、その基盤となるコードの仕組みを示します。特定のテクノロジが興味深く、わかりにくい、言語の標準以外の機能に基づいている場合には、それも明らかにします。そしてあらゆる機会をとらえて、より強力で、保守がしやすく、移植性の高いプログラムを書く手助けもします。異端者と呼ばれるかもしれませんが、読者が一生 Microsoft のテクノロジだけでプログラムを書くとは、私には考えられないのです。

「Deep C++」のコラムでは、読者が C 言語について少なくとも中級レベルの知識を持っている(そして使い慣れている)ことを前提としています。C++ のトピックでも、その言語について同様の知識レベルを前提としています。必要に応じて、両言語に共通する内容と、C++ 特有の内容を区別します(標準 C 言語の次期バージョンであるバージョン 2.0 である C9X を取り上げない限り、C 言語固有の内容はないでしょう)。

解き明かされる例外

専門家である Dr. GUI のアドバイスにしたがって、第 1 回目のシリーズもののコラムをプログラム例外に捧げます。さて、「例外」という言葉は何かしらあいまいで、とくに C++ 標準の例外と Microsoft の構造化例外処理などを考えると、文脈によって意味が異なることに気が付きました。残念ながら、言語の標準規格の中でも、一般のプログラムについての文献でも、例外の概念は神聖視されています。私は新しい用語を作ることが好きではないので、シリーズの各部では、どういう意味でこの言葉を使っているのかを明確にすることに最善の努力を払います。

  • 第 1 部では、例外の一般的な性質と、標準 C ライブラリでの処理方法を説明します。

  • 第 2 部では、それら処理方法に対する Microsoft の拡張(特殊マクロと構造例外処理)を検討します。

  • 第 3 部とそれ以降では、標準 C++ 例外処理を取り上げます。

(C を専門とする方は第 2 部以降は離脱したくなるかもしれませんが、我慢して最後までつきあってもらいたいと思っています。私が紹介するさまざまな考え方は、間接的ではありますが、C 言語にも十分に適用できます)。

プログラム例外とは要するに頻度が低く予期しないときに発生する状態であり、一般的にプログラム エラーを示します。そして、プログラムによる既定の応答を必要とします。この応答に従えないと多くの場合、当該プログラムは実行不能または完全停止の状態となり、ときにはシステム全体がダウンします。残念ながら、伝統的な防御テクニックを使う堅牢なコードを書いても、多くの場合は 1 つの問題(クラッシュを起こす例外)がもう 1 つの問題(さらに複雑な設計とコード)にすりかわるだけです。

非常に多くのプログラマが、このすりかえには頭を悩ませるだけの価値はないと判断して、危険な状態を放置することを選んでいます。この状況を認識した C++ 標準規格の作成者らは、洗練された例外処理の仕組みをほとんど目にみえない形で言語に追加しました(少なくとも理論上では)。第 4 部以降で紹介するように、理論は大枠では成功したものの、ちょっとしたところで失敗する場合があります。

例外の段階

このシリーズでは、例外を処理するための C と C++ における様々な手法を紹介し、例外の発生から処理の終了までの各段階に、それぞれの手法を適用してみます。

  1. ソフトウェアのエラーが起きます。このエラーは、下位レベルのドライバまたはカーネルが、ハードウェアで発生したイベント(たとえばゼロによる除算など)をソフトウェア エラーにマップしたものであることもあります。

  2. エラーの原因と性質は例外オブジェクトによって示されます。このオブジェクトは、単一の整数値という単純なものから、本格的な C++ クラス オブジェクトまで複雑なものまであります。

  3. プログラムは例外オブジェクトをポーリングして検出するか、能動的に通知してもらうようにします。

  4. 検出コードは例外を処理する方法を決めなくてはなりません。その応答方法は通常、次の 3 つのカテゴリに分かれます。

    • 例外オブジェクトを無視して、ほかで処理されるのを期待する。

    • オブジェクトに対して何らかの処理を実行する。ただし、後でほかでもオブジェクトを処理できるようにする。

    • 例外の完全な所有権を握る。

  5. 例外を処理したプログラムは通常、回復して実行を継続します。回復の方法は 2 とおりあります。

    • 再開型例外の場合は、例外が生成されたところから実行を継続します。

    • 終了型例外の場合は、例外が処理されたところで実行を継続します。

終了型例外がプログラムの外部(ランタイム ライブラリまたは OS)で処理される異例のケースでは、回復はほとんど不可能で、プログラムが異常終了します。

本稿ではあえてハードウェア エラー イベントについては取り上げません。なぜなら、それらはプラットフォーム固有の下位レベルのコードで処理すべきものだからです。その代わりに、ソフトウェアで検出可能なエラーによって、第 1 段階のソフトウェア例外オブジェクトが生成されたものと想定します。

標準 C ライブラリのメカニズム

標準 C ライブラリは、例外処理のためのメカニズムをいくつか提供します。どれも標準 C++ でも利用できますが、関連するヘッダ名が変わっています。従来の <name.h> という形式の C の標準ヘッダは、<cname> という新しい形式の C++ 標準ヘッダにマップされています(ヘッダ名の先頭の c は覚えやすいように付けられおり、これらがすべて C ライブラリ ヘッダであることを示します)。

従来の C ヘッダ ファイルは C++ でも、上位互換性を確保するために残されています。しかし、できるだけ新しいヘッダを使うことをお勧めします。実用的な観点から、もっとも大きな変更点は、新しいヘッダにおける宣言が std 名前空間に含まれているという点です。たとえば、C 言語での次の宣言は、

  #include <stdio.h>

FILE *f = fopen("blarney.txt", "r");

標準 C++ では次のようになります。

  #include <cstdio>

std::FILE *f = std::fopen("blarney.txt", "r");

あるいはもっと C 言語に似た形で書くと次のようになります。

  #include <cstdio>
using namespace std;

FILE *f = fopen("blarney.txt", "r");

残念ながら、Microsoft の Visual C++®は、C++ 標準規格(D.5 節)で要求されているにもかかわらず、これらの新しいヘッダの内容を std 名前空間内で宣言しません。Visual C++ がこれらのヘッダの中で std を正しくサポートするようになるまでは、私のコラムでは従来の C 形式の名前を使います。

(Microsoft などのライブラリ供給者に公平を期すために付け加えると、これらの C ライブラリ ヘッダを正しく実装するには、2 つのまったく違うコード ベースの保守とテストが必要になると予想できます。きわめてたいへんな作業であり、その代償に見合うだけの利点が得られるとは言えません。)

完全なる終了

例外を完全に無視することの次にもっとも簡単な応答はおそらく自己破壊です。場合によっては、この手っ取り早い応答が、実際には正しい答えであることもあります。

鼻で笑う前に、適切な回復がいずれにしても不可能なほど致命的な状態を示す例外もあることを考えてみてください。おそらく、もっともわかりやすい例は、小さなオブジェクトのために malloc を実行したら NULL が返されたという場合でしょう。空き記憶域管理機能がわずか数バイトの連続領域も確保できなければ、プログラムの堅牢性は大きく損なわれ、整然と回復できる見込みはほとんどありません。

C ライブラリ ヘッダ <stdlib.h> は 2 つのプログラム停止関数、abortexit を提供します。これらの関数は例外の寿命の第 4 段階と第 5 段階を実装します。どちらも呼び出し元には戻らず、プログラムを終了します。つまり、これらは終了型例外ハンドラの究極なのです。

両者の考え方は関連してますが、その効果は異なります。

  • abort:プログラムの異常終了。標準では、abort を呼び出すと、ランタイム診断とプログラムの自己破壊が起きます。この破壊処理は、コンパイラの実装によって、開いているファイルをフラッシュして閉じたり、一時ファイルを削除したりするものとそうしないものがあります。

  • exit:洗練されたプログラムの終了。開いているファイルを閉じ、実行環境にステータス コードを戻すほか、atexit を使ってインストールされたすべてのハンドラを呼び出します。

一般的に、abort はプログラムの失敗が致命的な場合に呼び出します。abort の標準の動作はプログラムの即時終了なので、abort を呼び出す前に、重要なデータは保存しなくてはなりません(<signal.h> の説明の中でも紹介しますが、abort にクリーン アップ コードを自動的に呼び出させることができます)。

これとは対照的に、exit は登録されている atexit ハンドラを使ってユーザー定義のクリーン アップを実行します。これらのハンドラは、登録とは逆の順番で呼び出されます。これらを擬似デストラクタと考えることもできます。必須のクリーン アップ コードをこれらのハンドラに組み込むことで、プログラムが必ず必要な処理を実行して終了することを保証できます。例を示します。

  #include <stdio.h>
#include <stdlib.h>

static void atexit_handler_1(void)
   {
   printf("within 'atexit_handler_1'\n");
   }

static void atexit_handler_2(void)
   {
   printf("within 'atexit_handler_2'\n");
   }

int main(void)
   {
   atexit(atexit_handler_1);
   atexit(atexit_handler_2);
   exit(EXIT_SUCCESS);
   printf("this line should never appear\n");
   return 0;
   }

/* 実行すると以下を出力
      within 'atexit_handler_2'
      within 'atexit_handler_1'
    
   そして、呼び出しもとの環境に成功を示すコードを返す。
*/

(プログラムが main から戻るときに明示的に exit を呼び出さなかった場合でも、登録されている atexit ハンドラが呼び出されます。)

abortexit はどちらも呼び出し元には戻らず、プログラムを終了します。つまり、これらは終了型例外ハンドラの究極なのです。

条件付終了

abortexit を使えばプログラムを無条件に終了できます。プログラムを条件付きで終了することもできます。これをサポートするメカニズムが、すべてのプログラマが愛用している診断ツール、<assert.h> で定義されている assert マクロです。このマクロは一般的に次のように実装されています。

  #if defined NDEBUG
   #define assert(condition) ((void) 0)
#else
   #define assert(condition) \
       _assert((condition), #condition, __FILE__, __LINE__)
#endif

この定義が示すように、NDEBUG マクロが定義されているとアサーションは無効になります。これは、アサーションがデバッグ版のコード専用のものであることを示します。つまり、デバッグ版でないコードでは、アサート状態が評価されません。そのために、同一のプログラムでもデバッグ版と非デバッグ版の間に大きな違いが生じることがあります。

  /* debug version */
#undef NDEBUG
#include <assert.h>
#include <stdio.h>

int main(void)
   {
   int i = 0;
   assert(++i != 0);
   printf("i is %d\n", i);
   return 0;
   }
/* 実行すると以下を出力

      i is 1
*/

ここで、NDEBUG を定義して、コードをデバッグ版からリリース版に変えます。

  /* release version */
#defing NDEBUG
#include <assert.h>
#include <stdio.h>

int main(void)
   {
   int i = 0;
   assert(++i != 0);
   printf("i is %d\n", i);
   return 0;
   }
  
/* 実行すると以下を出力

      i is 0
*/

このような違いを避けるには、assert が評価する式に重要な処理が含まれていないことを確認します。

デバッグ専用の定義では assert は、_assert 関数への呼び出しになります。といってもこれは私が考えた架空の名前です。実際のライブラリの実装は、任意の内部関数を参照することができます。その名前が何であるにせよ、その関数は一般的に次のような形になっています。

  void _assert(int test, char const *test_image,
   char const *file, int line)
   {
   if (!test)
      {
      printf("Assertion failed: %s, file %s, line %d\n",
      test_image, file, line);
      abort();
      }
   }

つまり、アサートが失敗すると、失敗したときの詳しい状況を示す診断情報のほか、問題が起きたソース ファイルの名前と行番号が出力され、その後に abort が呼び出されます。ここで示した診断情報の出力の仕組み(printf)はきわめて初歩的です。実際のライブラリの実装は、より高度な方法でフィードバックを生成することもあります。

アサーションは、例外の第 3 段階から第 5 段階を実装します。これらは実際には事前の条件検査を伴なった条件付きの abort です。検査が失敗すると、プログラムが停止します。アサーションは一般的にロジック エラー、すなわち正しいプログラムでは発生することのない間違いをデバッグするために使うものです。

  /* 'f' はほかのプログラムから決して呼ばれることがない */
static void f(int *p)
   {
   assert(p != NULL);
   /* ... */
   }

ロジック エラーを、正しいプログラムで起こり得るほかのラン タイム エラーとは比較してみます。

  /* ... ユーザーからファイル 'name' を取得 ... */
FILE *file = fopen(name, mode);
assert(file != NULL); /* 使い方に問題あり */

こうしたエラーは例外的な状態を示しますが、バグではありません。こうしたラン タイム例外の場合、assert はおそらく適切な応答ではありません。代わりに、以下に示す仕組みのいずれかを選択するべきです。

非ローカルな goto

過激な abortexit に比べると、goto ステートメントはもう少しスケーラブルな例外管理方式に見えるでしょう。しかし残念まがら、goto はローカルでしか利用できません。goto ステートメントが含まれている関数の内部にあるラベルにしかジャンプできないのです。そのため、プログラムの任意の処理に制御を移すことができません(もちろん、すべてのコードを main に詰め込んでいる場合は話しが別です)。

この制限を回避するために、C ライブラリは setjmplongjmp という 2 つの関数を提供します。これらはそれぞれ、非ローカルなラベルと非ローカルな goto として機能します。ヘッダ ファイル <setjmp.h> に、これらの関数と、関連する型である jmp_buf が宣言されています。仕組みはいたって簡単です。

  • setjmp(j) は、jmp_buf オブジェクトの j にプログラムの現在のコンテキスト情報を詰め込んで「ジャンプ」位置を「設定」します。通常このコンテキスト情報には、プログラム位置ポインタ、スタックポインタ、フレームポインタ、その他の重要なレジスタ値やメモリ値が含まれます。ジャンプ コンテキストを設定するために呼び出された setjmp は 0 を返します。

  • ジャンプ位置を設定した後で longjmp(j, r) を呼び出すと、j によって示されるコンテキスト(つまり j を設定した setjmp 呼び出しの位置)への非ローカルの goto、つまり長距離ジャンプが実行されます。長距離ジャンプのジャンプ先として呼び出された setjmpr、または r が 0 のときは 1 を返します(setjmp はこの文脈では 0 を返せないことを思い出してください)。

setjmp は 2 種類の戻り値を提供することで、どのように使われているかを判定できるようになっています。j を設定すると、setjmp は通常の期待どおりに動作します。しかし、長距離ジャンプのジャンプ先となる場合、setjmp は通常の文脈からはずれて起動します。

longjmp を使って終了型例外を生成し、setjmp を使って対応する例外ハンドラにマークを付けます。

  #include <setjmp.h>
#include <stdio.h>

jmp_buf j;
void raise_exception(void)
   {
   printf("exception raised\n");
   longjmp(j, 1); /* 例外ハンドラにジャンプ */
   printf("this line should never appear\n");
   }
  
int main(void)
   {
   if (setjmp(j) == 0)
      {
      printf("'setjmp' is initializing 'j'\n");
      raise_exception();
      printf("this line should never appear\n");
      }
   else
      {
      printf("'setjmp' was just jumped into\n");
      /* このコードが例外ハンドラ */
      }
   return 0;
   }
  
/* 実行する以下を出力:

   'setjmp' is initializing 'j'
   exception raised
   'setjmp' was just jumped into
*/

jmp_buf を埋める関数は、longjmp を呼び出す前に戻ってはなりません。さもなければ、jmp_buf から復元されたコンテキストは無効になります。

  jmp_buf j;

void f(void)
   {
   setjmp(j);
   }
  
int main(void)
   {
   f();
   longjmp(j, 1); /* ロジックの間違い */
   return 0;
   }

したがって、setjmp は、カレントの呼び出しコンテキストにおいてのみ非ローカルな goto として扱わなくてはなりません。

longjmpsetjmp の組み合わせは例外の寿命の第 2 段階と第 3 段階を実装します。longjmp(j, r) は例外オブジェクト r(単独の整数)を作成し、それを setjmp(j) からの戻り値として伝播します。その結果として、setjmp 呼び出しは例外 r の通知を受けたことになります。

シグナル

C 言語ライブラリには、統合的(だが原始的)なイベント管理パッケージもあります。このパッケージは、イベントもしくはシグナルとともに、それらを生成し処理するための標準メソッドを定義します。これらのシグナルは例外的状態または非同期的な外部イベントを知らせます。このコラムの目的に従って、ここでは例外シグナルのみを取り上げます。

このパッケージを使うには、標準ヘッダ ファイル <signal.h> をインクルードします。このヘッダは raisesignal 関数、sig_atomic_t 型、そして名前が SIG で始まるシグナル イベント マクロを宣言します。標準規格では、6 つのシグナル マクロを必須としていますが、使用しているライブラリの実装によってはほかにも追加されていることがあります。シグナルのセットは、<signal.h> に定義されているものに固定されます。つまり、独自のシグナルを定義して拡張することはできません。シグナルは raise への呼び出しによって生成され、ハンドラによって捕獲されます。ランタイム システムが標準のハンドラを提供しますが、signal を使って独自のハンドラをインストールすることができます。ハンドラは sig_atomic_t 型のオブジェクトを通じて外部とやり取りができます。型の名前が示すように、こうしたオブジェクトへの代入はアトミック、すなわち割り込まれることなく行われます。

シグナル ハンドラを登録するとき、通常はハンドラ関数のアドレスを渡します。それらの関数は int 型の値(処理対象シグナル イベント)を受け取って、void を返さなくてはなりません。受け取る唯一の例外コンテキストが単独の整数であるという点で言えば、シグナル ハンドラは setjmp に似ています。

  void handler(int signal_value);

void f(void)
   {
   signal(SIGFPE, handler); /* ハンドラを登録 */
   /* ... */
   raise(SIGFPE); /* ハンドラを起動し、'SIGFPE' を渡す */
   }

代わりに、2 つの特殊なハンドラをインストールすることもできます。

  • signal(SIGxxx, SIG_DFL):指定されたシグナルに対してシステムの標準のハンドラを登録します。

  • signal(SIGxxx, SIG_IGN):システムに対してシグナルを無視するように指示します。

いずれの場合でも、signal は直前のハンドラ(登録成功を示す)または SIG_ERR(登録失敗を示す)を返します。

ハンドラが呼び出されます。これは、シグナルが再開型例外であることを意味します。しかし、例外ハンドラの中では abortexit、または longjmp を自由に呼び出して、シグナルを実質的には終了型例外として解釈できます。興味深いことに、abort 自身も実は内部で raise(SIGABRT) を呼び出します。標準の SIGABRT ハンドラは診断情報を出力してプログラムを終了しますが、ユーザー独自の SIGABRT ハンドラをインストールしてその動作を変えることができます。

しかし、abort がプログラムを終了してしまうことだけは変えられません。abort は概念的には次のように書かれています。

  void abort(void)
   {
   raise(SIGABRT);
   exit(EXIT_FAILURE);
   }

つまり、独自に定義した SIGABRT ハンドラが返ってきても、結局 abort はプログラムを終了してしまうのです。

C 言語の標準規格はシグナル ハンドラの動作について、その他にも制限や解釈を課しています。C の標準規格が手元にあれば、7.7.1.1 節で詳細を確認しましょう(残念ながら、C の標準規格と C++ の標準規格はいずれもインターネットでは入手できません)。

<signal.h> 宣言は例外の生存時間の全ステージをゆりかごから墓場まで対象とします。C 標準のランタイム ライブラリでは、この宣言が完全な例外処理の解決方法にもっとも近いものです。

大域変数

<setjmp.h><signal.h> のルーチンは、例外検出のために通知方式を採用しています。すなわち、例外イベントの通知を受けるとハンドラが起動します。ポーリング方式を使いたい場合、標準ライブラリのヘッダ ファイル <errno.h> は参考になります。このヘッダでは errno のほか、errno が取り得るいくつかの値が定義されています。標準規格では EDOMERANGEEILSEQ という 3 つの値が必須と規定されています。それぞれ、ドメイン、範囲、マルチバイト シーケンス エラーを表します。しかし、コンパイラがほかにも名前が E で始まる値を追加している場合もあります。

errno と、それを設定するライブラリ コード、およびそれを調べるユーザーのコードとの組み合わせは、例外の寿命の第 1 段階から第 3 段階までを実装します。すなわち、ライブラリは例外オブジェクト(単独の整数)を生成し、例外オブジェクトの値を errno にコピーして、あとはユーザーのコードがポーリングをして例外を検出することに期待します。

ライブラリは errno を主に <math.h><stdio.h> の関数の中で使用します。errno はプログラムの開始時には 0 に設定されます。以後、ライブラリ ルーチンが errno を 0 に再設定することはありません。したがって、エラーを検出するには、errno を 0 に設定して、ライブラリ ルーチンを呼び出した後で、errno の値を検査します。

  #include <errno.h>
#include <math.h>
#include <stdio.h>

int main(void)
   {
   double x, y, result;
   /* ... 'x' と 'y' をどうにか設定する ... */
   errno = 0;
   result = pow(x, y);
   if (errno == EDOM)
      printf("domain error on x/y pair\n");
   else if (errno == ERANGE)
      printf("range error on result\n");
   else
      printf("x to the y = %d\n", (int) result);
   return 0;
   }

errno は必ずしもオブジェクトを参照しないことに注意してください。

  int *_errno_function()
   {
   static int real_errno = 0;
   return &real_errno;
   }
  
#define errno (*_errno_function())

int main(void)
   {
   errno = 0;
   /* ... */
   if (errno == EDOM)
      /* ... */
   }

errno とその値と同等のものを作って、これと同じテクニックを自分のルーチンに適用できます。C++ を使うことで、もちろん、クラスや名前空間内の関数とオブジェクトにまでこの方法を拡張できます(実際、C++ の専門家の間ではこのテクニックは、いわゆるシグルトン パターンの基盤となります)。

戻り値とパラメータ

errno のような例外オブジェクトには制限があります。

  • 関係する当事者全部が共通のオブジェクトを設定したりチェックしたりしなければなりません。

  • 関係外のものが誤ってオブジェクトを変更する可能性があります。

  • ルーチンを呼び出す前にオブジェクトをリセットしないと、あるいは以降に続くサブルーチンを呼ぶ前に検査をしないと、例外を失うことがあります。

  • 同じ名前を持つマクロやスコープの狭いオブジェクトによって、例外オブジェクトが隠れることがあります。

  • 静的な持続的オブジェクトは本質的にスレッドセーフではありません。

全体としては、これらのオブジェクトは脆弱です。使い方を間違う可能性がありますが、それでもコンパイラは何の警告も出さず、プログラムも何の兆候も見せません。

これらの問題を解消するには、次のようなオブジェクトが必要です。

  • 必ず 2 つの当事者からアクセスできること。1 つは例外を生成する方、もう 1 つはそれを検出する方です。

  • 必ず 1 つの値を取ること。

  • 名前が隠れないこと。

  • 一般的にスレッドセーフなこと。

関数の戻り値はこれらの条件を満たします。なぜなら、戻り値は関数呼び出しの生成する名前のない一時オブジェクトで、呼び出し元にのみアクセス可能だからです。呼び出しが完了すると、呼び出し元は戻されたオブジェクトの値を検査もしくはコピーします。その後に、戻されたオブジェクトのオリジナルは消滅し、再利用できなくなります。また、そのオブジェクトには名前がないので、隠されることもありません。

(C++ については、rvalue 関数呼び出し表現だけを想定します。つまり、参照を返さない呼び出しです。ここでは C 言語と互換性のあるテクニックだけに限定して説明しており、C には参照がないため、これは妥当な仮定と言えます。)

戻り値そのものは例外の寿命の第 2 段階のみを表します。しかし、呼び出し元と呼び出し先の関数と組み合わせれば、完全な例外の実装を構成することができます。

  int f()
   {
   int error;
   /* ... */
   if (error) /* 第 1 段階:エラー発生 */
      return -1; /* 第 2 段階:例外オブジェクト生成 */
   /* ... */
   }
  
int main(void)
   {
   if (f() != 0) /* 第 3 段階:例外検出 */
      {
      /* 第 4 段階:例外処理 */
      }
   /* Stage 5: 回復 */
   }

戻り値は標準 C ライブラリに適した例外伝播用のメソッドです。次の典型的な例を考えてみてください。

  if ((p = malloc(n)) == NULL)
   /* ... */
  
if ((c = getchar()) == EOF)
   /* ... */
  
if ((ticks = clock()) < 0)
   /* ... */

単独のステートメントで戻り値を捕獲して例外を調べることの両方を行う C 言語の伝統的な手法に注目してください。このような表現の効率化は、1 つのチャンネル(戻されたオブジェクト)を 2 つの異なる意味(正常なデータ値と例外値)でオーバーロードすることによってもたらされます。コードはこのチャンネルをどちらの意味が正しいかわかるまで、2 つの意味で解釈しなければなりません。

値を返す関数の考え方は多くの言語で共通しています。Microsoft はその事実を、言語に依存しない Component Object Model(COM)で利用しています。COM のメソッドは HRESULT 型のオブジェクト、すなわち Microsoft 式の特別な形式の 32 ビットの符号なしの値のオブジェクトを返すことで例外を通知します。今検討したばかりの例とは異なり、COM の戻り値はステータスと例外情報のみを返します。それ以外の外向けの情報はポインタのパラメータを通して伝達されます。

外向きポインタと C++ の参照パラメータは関数の戻り値のバリエーションです。次のような違いがあります。

  • 戻り値を無視するとそれらは失われます。しかし、外向きパラメータは対応する引数にバインドされています。そのため、それらを完全には無視することはできません。戻り値に比較して、パラメータは関数と呼び出し元との間に緊密な結び付きを形成できます。

  • 外向きのパラメータを使って任意の数の値を「返す」ことができます。しかし関数の真の戻り値は 1 つだけです。したがって、外向きのパラメータは複数の論理的な戻り値を提供します。

  • 戻り値は一時オブジェクトです。これらのオブジェクトは関数の呼び出し前には存在せず、呼び出しの完了時には消滅します。引数は関数呼び出しよりも長い寿命を持ちます。

終わりに

これで、例外とその標準 C 言語における従来のサポートの説明を終わります。第 2 部では、標準 C の方法に対する Microsoft の拡張、すなわち、特殊な例外処理マクロおよび SHE(Structured Exception Handling:構造化例外処理)について検討します。また、C 言語互換のすべての(SHE も含めた)方法の制限についても要約します。そして、第 3 部の C++ 例外の解説のための準備もします。

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

Deep C++用語集