C と C++ での例外処理、第 6 部
Robert Schmidt
1999 年 8 月 5 日
この例外処理についての連載の第 6 部では、標準ライブラリ ヘッダ <new> で宣言されているグローバル関数の Microsoft 版の実装について説明します。
前回は、標準ライブラリ ヘッダ <new> の中で宣言されている 12 のグローバル関数の例外動作を説明しました。今回からは、これらの関数の Microsoft 版の実装について吟味していくことにします。
Visual C++® version 5 の標準ライブラリ ヘッダ <new> の中では、以下の宣言が提供されます。
namespace std
{
class bad_alloc;
struct nothrow_t;
< extern nothrow_t const nothrow;
};
void *operator new(size_t) throw(std::bad_alloc);
void operator delete(void *) throw();
void *operator new(size_t, void *);
void *operator new(size_t, std::nothrow_t const &) throw();
第5部 で説明した規格の要件に照らしてみると、Microsoft 版の <new> では以下のものが欠けています。
operator new[] の 3 つの形が全部
operator delete[] の 3 つの形が全部
配置 operator delete(void *, void *)
配置 operator delete(void *, std::nothrow_t const &)
また、このライブラリは operator new を宣言していますが、これは std::bad_alloc をスローします。この関数の動作は規格に適合していません。
Visual C++ version 6 を使っている場合は、その <new> で operator delete(void *, void *) が宣言されていることを除き、同じ制限があります。
配列
Visual C++ に実装されている標準ライブラリでは、operator new[] と operator delete[] のオーバーロードは宣言されていません。幸い、ライブラリで省かれていても、コンパイラが与えてくれます。自分で以下の関数を作成できるからです。
#include <stdio.h>
void *operator new(size_t)
{
printf("operator new\n");
return 0;
}
void operator delete(void *)
{
printf("operator delete\n");
}
void *operator new[](size_t)
{
printf("operator new[]\n");
return 0;
}
void operator delete[](void *)
{
printf("operator delete[]\n");
}
int main()
{
int *p;
p = new int;
delete p;
p = new int[10];
delete[] p;
}
/* When run should yield
operator new
operator delete
operator new[]
operator delete[]
*/
なぜ Visual C++ の標準ライブラリにはこれらの関数が欠けているのでしょう。本当のところはわかりませんが、おそらく「旧版との互換性」がその理由でしょう。
operator new[] と operator delete[] が C++ の規格に加えられたのは比較的遅く、何年にも渡ってコンパイラはこれらをサポートしていませんでした。オブジェクトの割り当てをカスタマイズしたい人は、配列オブジェクトの場合にも使われ可能性のあるる operator new と operator delete を定義する必要がありました。
コンパイラが、かつては欠けていた operator new[] と operator delete[] のサポートを、標準ライブラリで提供し始めれば、ユーザー定義のグローバルな operator new 関数と operator delete 関数は配列オブジェクトのためには呼び出されなくなるでしょう。コードをビルドして実行することはできても、その動作は違ったものになるでしょう。コンパイラは何の問題も示さないので、何かが変わったことに気がつかないかもしれません。
静かな変更
このような静かな変更は、Microsoft のようなコンパイラ メーカにとって大きなジレンマとなります。C++ の規格がほとんど 10 年に渡って進化してきたことを思い出してください。この進化の間、コンパイラ ベンダは、規格の変更を追い続け、最新の規格にできるだけ適合するようにしてきました。それと同時にユーザーは、その機能が標準化過程を生き延びる保証がなくても、利用できる言語機能に依存してきました。
規格への明らかな変更が規格以前のプログラムの動作に対する静かな変更として現れる場合、コンパイラ ベンダには次の 3 つの選択肢があります。
古い動作にこだわり、規格に適合するコードを不正とする。
新しい動作に切り替え、規格以前のコードを不正とする。
ユーザーがどちらの動作を望むか指定できるようにする。
operator new[] と operator delete[] を提供する標準ライブラリに関しては、Microsoft は選択肢 1 を選びました。筆者個人としては、上記を含めた Visual C++ の逸脱のすべてについて、選択肢 3 を選んで欲しかったと思っています。そのようなユーザーの決定をコンパイラ オプション #pragmas や環境変数を使って実現できたはずなのです。
Visual C++ は長らく、コンパイラ スイッチ /Za の形で、便利な選択肢 3 をサポートしてきましたが、このスイッチには、規格に適合した機能を無効にし、ほかのものを有効にしてしまうという、ドキュメントに書かれていない副作用があります。筆者が望み、また、読者の多くが望むと思われるのは、標準適合機能を意図どおりに細かくオンオフできる手段です。
(operator new[] と operator delete[] の場合に限っては、配列の代わりに vector のようなコンテナ クラスを使い始めるべきだと思っていますが—これは別の回のネタにとっておきましょう)。
例外の指定
Microsoft の <new> では非配置の operator new は次のように正しく宣言されています。
void *operator new(std::size_t) throw(std::bad_alloc);
ライブラリの operator new をオーバーライドする operator new を定義できるので、次のような工夫をすることができます。
void *operator new(std::size_t size) throw(std::bad_alloc)
{
void *p = NULL;
// ... try to allocate '*p' ...
if (p == NULL)
throw std::bad_alloc();
return p;
}
上の関数を同一の翻訳単位に保存してコンパイルすると、Visual C++ は、デフォルトのプロジェクト設定であればエラーを報告しません。しかし、警告レベルを 4 に設定して再コンパイルすると、次のメッセージが表示されます。
warning C4290: C++ Exception Specification ignored
ふむ。私たちの例外指定が使えないのならば、ライブラリのも使えないはずです。警告レベル 4 のまま、次の行を含めてコンパイルしてみましょう。
#include <new>
これが私たちのものと同じプロトタイプを持つ関数を宣言することはすでにわかっています。
驚きももの木、なんということでしょう。。レベル 4 でも警告なしでコンパイルできてしまいました。ライブラリの宣言には私たちのコードにはない魔法のプロパティでもあるのでしょうか?いいえ、実際にはここで Microsoft がインチキをやっているのです。
<new> は標準ライブラリ ヘッダ <exception> をインクルードします。
<exception> は非標準ヘッダ xstddef をインクルードします。
xstddef は別の非標準ヘッダ yvals.h をインクルードします。
yvals.h は #pragma warning(disable:4290) 命令をインクルードします。
#pragma はまさに私たちのコードで見たレベル 4 の警告をオフにします。
教訓:Visual C++ はコンパイル時には例外指定を尊重しますが、実行時には無視します。関数に throw(std::bad_alloc) のような例外指定を付加すれば、コンパイラはこれを正しく解析できますが、実行時にはこの指定が無視され、もともと指定のなかったコードのように動作します。
なぜこれが問題なのか
この連載の第 3部 で、実際の動作と目的を説明せずに、例外指定の形式を説明しました。Visual C++ でこれらの指定が正しくサポートされていないことは、これを今説明するための申し分のない理由になります。
例外指定は、関数とその呼び出し元との約束事の一部です。指定ではその関数がどの例外をスローするかを完全に列挙します(規格の表現では、関数は指定された例外を「許す」ということになります)。
暗に、関数は指定にない例外を許さない(スローしないという取り決めになっている)ことを示しています。指定が存在していても空であれば、関数は一切の例外を認めません。逆に、指定が省略された場合には、関数はすべての例外を認めることになります。
この関数 / 呼び出し元の約束が強制できるのでなければ、その約束事のために使われているビットの無駄です。では、コンパイラは翻訳時に関数が嘘をつかないようにするのでしょうか。
void f() throw() // 'f' promises to throw no exceptions...
{
throw 1; // ... yet it throws one anyway!
}
驚いたことに、Visual C++ ではこれがコンパイルできてしまうのです。
Visual C++ の動作が誤っているという評価を下す前に、この例が C++ 規格に適合するどのような翻訳系でもコンパイルできる点に注目してください。規格から引用します(15.4 節 10 ページ)。
実装は、式を包含している関数が許可していない例外をスローする、またスローする可能性があるからという理由だけで、その式を排除してはならない。次に例を示す。
extern void f() throw(X, Y);
void g() throw(X)
{
f(); //OK
}
たとえ f が g によって認められていない例外 Y をスローする可能性があったとしても、f への呼び出しは適正形式です。
おもしろいですね。では、コンパイラが約束事を強制できないというのならば、何がそれを強制できるのでしょう?
ランタイム システムです。
関数が、スローされないことになっている例外をスローした場合には、ランタイム システムは標準ライブラリ関数 unexpected を呼び出します。ライブラリのデフォルトの unexpected の実装は terminate を呼び出してプログラムを終了します。set_unexpected を呼び出して、デフォルトの動作を変える新しい unexpected_handler をインストールできます。
いずれにしても、論理的にはこのように動作するはずです。しかし、先に示した Visual C++ の警告が暗に示すように、コンパイラは例外指定を無視します。その結果、Visual C++ のランタイム システムは関数がその約束を果たさなくても unexpected を呼び出しません。
お気に入りの翻訳系の動作をテストするために、次の短いプログラムをビルドして実行してみましょう。
#include <exception>
#include <stdio.h>
using namespace std;
void my_unexpected_handler()
{
throw bad_exception();
}
void promise_breaker() throw()
{
throw 1;
}
int main()
{
set_unexpected(my_unexpected_handler);
try
{
promise_breaker();
}
catch(bad_exception &)
{
printf("Busted!");
}
catch(...)
{
printf("Escaped!");
}
return 0;
}
プログラムの出力が
Busted!
ならば、ランタイム システムは promise_breaker がその約束に違反していることを正しく検出しています。逆に、出力が
Escaped!
ならば、ランタイム システムは promise_breaker の違反を検出するのに失敗しています。
このプログラムで、ライブラリのデフォルトの unexpected ハンドラに優先する my_unexepected_handler をインストールしています。このカスタム ハンドラは std::bad_exception 型の例外をスローします。この型は魔法のプロパティを持っています。unexpected ハンドラがこの型をスローした場合には、例外はキャッチされ、プログラムは終了せずに続行できます。実際、もともとスローされたオブジェクトが外に向かって伝播するときに、bad_exception オブジェクトによって置き換えられます。
この記述は、コンパイラが予期せぬ例外を正しく検出することを前提としています。Visual C++ の場合、my_unexpected_handler は決して呼び出されず、もともとの int 例外が promise_breaker の外にスローされます。
例外指定のシミュレーション
設計がいくらかエレガントでなくなることを厭わなければ、Visual C++ で例外指定のシミュレーションができます。次のコードの動作を考えてみましょう。
void f() throw(char, int, long)
{
// ... whatever
}
何が起こるはずなのでしょう。
f が例外を生成しなければ、正常にリターンします。
f が許される例外の 1 つを生成すると、例外が f の外に出されます。
f がほかの何らかの例外を生成すると、ランタイム システムは unexpected を呼び出します。
この動作を Visual C++ で実現するには、関数を次のように変更します。
void f() throw(char, int, long)
{
try
{
// ... whatever
}
catch(char)
{
throw;
}
catch(int)
{
throw;
}
catch(long)
{
throw;
}
catch(...)
{
unexpected();
}
}
Visual C++ が例外指定を正しくサポートし始めたら、その裏で動くコードは、ここで示したコードのように動作するはずです。これは、例外指定がこのコラムの第 4 回で示したものに似ているほかの try/catch ブロックと同じオーバーヘッドをもたらすことを意味します。
このため、例外指定を適用するには、ほかの例外処理の足がかりを適用するのと同じだけ賢明でなければなりません。例外指定を見たら常に、それを心の中で類似の try/catch シーケンスに置き換えて効果とコストを正しく理解する必要があります。
次回は
配置 delete の解説は次回を待たなければなりません。そこでは例外に強い設計にするためのより一般的な戦略を説明します。
Robert Schmidt は MSDN のテクニカル ライターです。このほかに彼が寄稿している雑誌に C/C++ Users Journal (http://www.cuj.com/) があります。そこでは、彼は編集補助およびコラムニストとして活躍しています。これまでのキャリアで、ラジオ DJ、野生生物飼育係、天文学者、ビリヤード場管理者、探偵、新聞配達夫、そして大学講師を経験しています。