例外処理、第 14 部
Robert Schmidt
Microsoft Corporation
2000 年 1 月 20 日
前回、私は例外安全性の解説を始めました。今回は、テンプレート安全性について説明しようと思います。
テンプレートは、引数として渡される型と相互に作用します。テンプレートに渡される型が持つ、完全なセマンティックの概要は、一般に知ることはできません。そのため、それらがどこで例外を生成するかについても、確実に知ることはできません。普通、期待できるベストと言えば、それらの型がスローする可能性のある場所を見つけだすことです。こういった発見は、とてつもなく困難な場合があります。
以下の簡単なクラス テンプレートの例を考えてみましょう。
template <typename T>
class wrapper
{
public:
wrapper()
{
}
T get()
{
return value_;
}
void set(T const &value)
{
value_ = value;
}
private:
T value_;
wrapper(wrapper const &);
wrapper &operator=(wrapper const &);
};
名前から想像できるように、wrapper は引数 T 型のオブジェクト 1 つを薄くラップします。get および set ルーチンは、ラップされた private の value_ の値を取り出したり、変更したりします。いつも登場するコピー コンストラクタとコピー代入演算子の 2 つは使われず、実装されていません。その一方で、3 番目のデストラクタは、コンパイラによって暗黙のうちに定義されています。
インスタンス化は簡単です。たとえば次は、1 つの int をラップします。
wrapper<int> i;
この i の定義により、翻訳系がテンプレートに基づいてクラス定義 wrapper<int> をインスタンス化させます。
template <>
class wrapper<int>
{
public:
wrapper()
{
}
int get()
{
return value_;
}
void set(int const &value)
{
value_ = value;
}
private:
int value_;
wrapper(wrapper const &);
wrapper &operator=(wrapper const &);
};
wrapper<int> は int だけを受け入れ、int だけを参照します。つまり、組み込みの型と、組み込みの型への参照だけを処理します。そのため、例外が発生する条件は成立しません。wrapper<int> は例外をスローしませんし、例外をスローするルーチンも、(直接的にも間接的にも)一切呼び出しません。具体的な解説は省きますが、私を信用してください。wrapper<int> は例外セーフなのです。
クラス型引数
今度は、次の場合を考えてみます。
wrapper<X> x;
ここでの X は、何らかのクラス型です。この定義を受けて、コンパイラは wrapper<X> クラスをインスタンス化します。
template <>
class wrapper<X>
{
public:
wrapper()
{
}
X get()
{
return value_;
}
void set(X const &value)
{
value_ = value;
}
private:
X value_;
wrapper(wrapper const &);
wrapper &operator=(wrapper const &);
};
一見、この定義は無害で、例外が発生しないように見えるかもしれません。しかし、以下を考えてください。
wrapper<X> には、X のサブオブジェクトが含まれています。そのサブオブジェクトは生成の必要がありますが、これは X のデフォルト コンストラクタを呼び出されることを意味します。そのコンストラクタが例外をスローする可能性があります。
wrapper<X>::get は、一時的な X オブジェクトを生成して返します。一時オブジェクトをつくるために、get は X のコピー コンストラクタを呼び出します。そのコンストラクタが例外をスローする可能性があります。
wrapper<X>::set は、式 value_ = value を評価します。これは実際には、X の代入演算子の呼び出しです。この演算子が例外をスローする可能性があります。
wrapper<int> ではスローすることのない組み込み操作だったものが、wrapper<X> ではスローする可能性のある関数呼び出しになっています。テンプレートも構文の見かけも同じですが、意味が大きく異なります。
この曖昧さを考慮して、用心深い戦略を採用しなければなりません。wrapper はクラス型に応じてインスタンス化されます。それらのクラス型のメンバについては、クラスに例外仕様がないと仮定し、メンバはすべて例外をスローする可能性があると想定しましょう。
wrapper を安全にする
また、wrapper のメンバは例外をリークしないことを wrapper の仕様が保証すると仮定します。少なくとも、それら wrapper のメンバに、throw() 例外仕様を加えなければなりません。また、例外をリークする可能性のある以下の点も修正しなければなりません。
wrapper::wrapper 内で value_ を生成する。
wrapper::get 内で value_ を返す。
wrapper::set 内で value_ を割り当てている。
さもなければ、throw() 仕様違反の罪で、std::unexpected に服すことになるでしょう。
リーク 1:デフォルト コンストラクタ
インターフェイス安全性のために、wrapper のデフォルト コンストラクタに関数 try ブロックを使うのは、誰もが考える方法だと思われます。
wrapper() throw()
try : T()
{
}
catch (...)
{
}
十分魅力的に見えますが、これは動作しません。C++ 標準規格(15.3/16 節「例外処理」)には次のようにあります。
コンストラクタ、またはデストラクタの関数 try ブロックでは、ハンドラの終端に制御が到達すると、処理されている例外が再スローされます。それ以外の場合、関数 try ブロックのハンドラの終端に制御が達すると、関数は戻ります。関数 try ブロックの終端を抜けていくことは、値を返さない return と同等になります。その場合、値を返す関数では、動作は未定義となります。
翻訳:上記の関数 try ブロックは、以下に記述したように振る舞います。
X::X() throw()
try : T()
{
}
catch (...)
{
throw;
}
しかし私は、こんなことがしたいのではありません。
実際にしたいのは次のとおりのことです。
X::X() throw()
try
{
}
catch (...)
{
return;
}
しかし、これは 15 節とぶつかります。
コンストラクタの関数 try ブロックのハンドラ内に return 命令があるプログラムは、文法違反になります。
標準規格を隅々まで調べ上げ、関数 try ブロックをサポートするいくつかの翻訳系でもテストをしてみました。しかし、私が望んでいるような動作をさせる方法は見つかりませんでした。どのような方法を使っても、キャッチされた例外は依然として再スローされ、throw() 仕様に違反していまい、私の目標であるインターフェイス安全性は無効になるのです。
**教訓:**関数 try ブロックは、インターフェイス安全性を実施するために使用することはできません。
**付随的教訓 1:**使える場所では、コンストラクタがスローしない、基本とメンバのサブオブジェクトを使用します。
**付随的教訓 2:**よき市民として正しく振る舞い、他が付随的教訓 #1 に応えられるように、いかなるコンストラクタからもスローしてはいけません(この教訓は、第 13 部で示した意見の矛盾を解消します)。
標準規格のルールは非常に奇妙です。というのも、関数 try ブロックの真価、つまり、サブオブジェクトのコンストラクタ(例えば T::T() など)によってスローされた例外を、サブオブジェクトが含まれているコンストラクタ(wrapper::wrapper())に渡る前に捕らえるという役割が損なわれるからです。実のところ、関数 try ブロックは、こういった例外をキャッチできる、唯一の方法です。それなのに、その例外をキャッチしても、内々に処理できないのです!
関数 try ブロックは例外をマップしたり、変換したりできます。
X::X()
try
{
throw 1;
}
catch (int)
{
throw 1L; // map int exception to long exception
}
この点に関して、このブロックは unexpected ハンドラにとても良く似ています。実際これが、このブロックの設計された目的なのではないかと疑っています。つまり、少なくともコンストラクタについては、これは例外ハンドラというよりはむしろ例外フィルタです。これについては、もっと調査して、これらのルールに隠された論理的な説明を発見したいと思っています。見つけたことは、みなさんに報告します。
少なくとも現時点では、あまり直接的でない解決法を採ることが宿命のようです。
template <typename T>
class wrapper
{
public:
wrapper() throw()
: value_(NULL)
{
try
{
value_ = new T;
}
catch (...)
{
}
}
// ...
private:
T *value_;
// ...
};
wrapper::wrapper() に入る前に生成されていた被ラップ オブジェクトは、ここでは wrapper::warapper() の本体の中で生成されています。このように変更すれば、関数 try ブロックのない状態で「通常の」形で例外をキャッチできます。
ここでは value_ が T から T * に変わったので、get と set を、それぞれポインタを処理するように変えなければなりません。
T get()
{
return *value_;
}
void set(T const &value)
{
*value_ = value;
}
リーク 1A:operator new
コンストラクタの try ブロックには次のステートメントがあります。
value_ = new T;
これは、operator new を暗黙のうちに呼び出して、*value_ のメモリを割り当てます。この operator new 関数はスローする可能性があります。
幸い私達の wrapper::wrapper() は、T コンストラクタと operator new のどちらからのスローでもキャッチします。このお陰でインターフェイス安全性が維持されます。しかし、以下の重要な違いに注意してください。
T コンストラクタがスローすると、operator delete が暗黙のうちに呼び出され、割り当てられたメモリを解放します(ここでは配置 new に対応する operator delete が存在すると仮定しています。これは第 8部 と第 9部 で説明しています)。
operator new がスローしても、operator delete が暗黙のうちに呼び出されることはありません。
一般に、この 2 番目の点が問題になることはありません。operator new がスローする一般的な原因はメモリ割り当ての失敗です。したがって、operator delete が解放すべきものが何もないということです。しかし、operator new がメモリを割り当てて、別の何らかの理由でスローした場合、operator new には割り当てたメモリを解放する責任があります。言い換えれば、operator new 自体に実装安全性が備わっていなければならない、ということです。
(operator new[] による配列の割り当ての場合も同様です。)
リーク 1B:デストラクタ
wrapper に実装安全性を与えるためには、new による割り当てを解放するためのデストラクタが必要です。
~wrapper() throw()
{
delete value_;
}
たやすいことのように思えます。でも、ちょっと待ってください!結局 delete value_ は、*value_ のデストラクタを呼び出します。そのデストラクタがスローする可能性があります。~wrapper のインターフェイスを安全にするためには、try ブロックを追加しなければなりません。
~wrapper() throw()
{
try
{
delete value_;
}
catch (...)
{
}
}
これでもまだ十分ではありません。*value_ のデストラクタがスローした場合には、operator delete は呼び出されないので、*value_ のメモリも解放されません。このため、実装安全性を加える必要があります。
~wrapper() throw()
{
try
{
delete value_;
}
catch (...)
{
operator delete(value_);
}
}
これでもまだ完成ではありません。標準ライブラリでは、operator delete が以下のように宣言されています。
void operator delete(void *) throw();
これはスローしません。しかし、スローする operator delete を誰かが定義してしまうことを、防げるものは何もありません。さらに安全を考えて、以下のように記述しなければなりません。
~wrapper() throw()
{
try
{
delete value_;
}
catch (...)
{
try
{
operator delete(value_);
}
catch (...)
{
}
}
}
しかし、これでも危険です。以下のステートメントを考えてみてください。
delete value_;
これは、暗黙のうちに operator delete を呼び出します。その関数がスローすると、例外は catch 句に到着します。そして・・・また戻って同じ operator delete を呼び出してしまうのです!その結果、プログラムは、例外が発生する原因となっている状況に立て続けに 2 回もさらされることになります。これでは、堅牢なソフトウェアの作り方とはいえません。
最後に、new によって生成されるオブジェクトのコンストラクタがスローすると operator delete が暗黙のうちに呼び出されるということを思い出してください。この暗黙のうちに呼び出された operator delete もスローしてしまうと、プログラムの例外処理が二重になり、terminate を呼び出すことになります。
**教訓:**先行するスローの処理が進行中に呼び出される可能性がある関数からは、スローしてはいけません。特に、以下からのスローは絶対禁止です。
デストラクタ
operator delete
operator delete[]
では、勉強家のためにちょっとした演習です。value_ を auto_ptr に置き換えてみてください。wrapper コンストラクタを書き換えて、wrapper デストラクタの役割を(もしあれば)解決してください。この間、例外安全性は維持します。
延長戦
元々は、1 回のコラムで例外安全性を終わらせるつもりでした。ところが、これが既に 2 回目のコラムで、十分 3 回目が書けるほどの材料もまだ持っています。次回を最後にすることを誓います!次回は get と set に関連した例外安全性の問題についてです。また、約束の推薦図書についても書く予定です。
Robert Schmidt は MSDN のテクニカル ライターです。このほかに彼が寄稿している雑誌に、C/C++ Users Journal があります。そこでは、彼は編集補助およびコラムニストとして活躍しています。これまでのキャリアで、ラジオ DJ、野生生物飼育係、天文学者、ビリヤード場管理者、探偵、新聞配達夫、そして大学講師を経験しています。