例外処理、第 16 部
Robert Schmidt
2000 年 2 月 17 日
勤勉な読者のみなさんから、数多くの反響と、少なからぬ量のご指摘を頂きました。そこで私は、第 14部 にある、以下に示す一対の教訓を部分的に改めます。
使える場所では、コンストラクタがスローしない、基本とメンバのサブオブジェクトを使用します。
いかなるコンストラクタからもスローしてはいけません。
今回のコラムで、私はみなさんからのコメントや、様々な C++ の著名人による知識、新たに改善した私自身の解釈などについて、よく考えてみようと思います。その後で、考察をガイドラインに(作り)変えます。このガイドラインは、元々の教訓を明確に、細部に渡って説明したものになります。
(用語についての注意:以下では、「サブオブジェクト」と「被包含オブジェクト」という言葉を、配列要素、無名の基本メンバ、または名前付きのデータ メンバを示す用語として使用します。また、「包含オブジェクト」という言葉を、配列、派生オブジェクト、またはデータ メンバを持つオブジェクトを示す用語として使用します。)
C++ の精神
みなさんの中には、エラーに遭遇したコンストラクタはスローしなければならず、こうしたエラーによって包含オブジェクトの生成が妨げられると感じた方もいるようです。Herb Sutter からの私信にもそのように書かれていました。
オブジェクトの存続期間は、そのコンストラクタが完了したときに始まります。
必然的結果:コンストラクトが完了しなかったオブジェクトは存在しません。
必然的結果:生成の失敗を報告する唯一の手段は、例外を使ってコンストラクタを終了することです。
私が思うに、あなたが試行していることは概念の面で間違っていて(ここで言う「間違い」は、「C++ の精神に反する」ということです)、それ故に難しいことになっているのではないでしょうか。
「C++ の精神」とは、口頭で伝承されている、C++ 創成の神話です。これは、ISO 標準規格と一般的な推奨手法の両方の基礎を形成する公理、すなわち第 1 原則です。この精神を示した実際の法典は存在しないため、何がこの精神に反し、何が反しないかについては、エキスパートと思われる人々の間でさえ意見が一致していません。
C と C++ に共通する精神の 1 面に、「プログラマを信じろ」というものがあります。私は Herb 宛に、以下のように書きました。
結局、私の「完全な」目的は、設計者が望めばそうできるように、例外を他のエラー伝搬手法にマップすることでした。これが常に最良の方法だというわけではありませんが、できるということを示したかったのです。C++ が兼ね備える強みと欠点の 1 つに、必要とあれば、推奨されている道筋から外れてもかまわないということがあります。他にも、プログラマが自分のやっていることを理解しているという前提の上で、この言語が大目に見ている危険な振る舞いがいくつかあります。
標準規格は潜在的に危険な振る舞いをしばしば許容したり、実行可能にしたりすることさえあります。しかし、ここでは違います。プログラマの裁量を認めるのは、より高度な目的のためのようです。Herb はその高度な目的が、C++ の精神の第 2 の側面であるとしています。オブジェクトは、完全に生成されるまで(その構成要素もすべて生成されるまで)現実のオブジェクトとして存在していないため、使用することができません。
以下の例を考えてみてください。
struct X
{
A a;
B b;
C c;
void f();
};
try
{
X x;
x.f();
}
catch (...)
{
}
A、B、C はそれぞれ異なるクラス型です。x.a と x.b は問題なくオブジェクトを生成しますが、x.c のコンストラクタはスローすると仮定してください。以前のコラムで示したように、言語の規則によって以下の順序で処理が進みます。
x のコンストラクタがスローする。
x.b のデストラクタが呼び出される。
x.a のデストラクタが呼び出される。
制御は再びハンドラ内に戻る。
この結果は、C++ の精神に合致します。x.c は生成を終えていないので、x.c がオブジェクトになることは絶対にありません。x も、構成要素の 1 つ(x.c)が存在しないので、オブジェクトになりません。どちらのオブジェクトも正式に存在していないので、両者とも正式に破棄されるべきではありません。
では、x のコンストラクタが元々の例外を、なんらかの方法で抑制できたと仮定してください。このシナリオでは、以下のような順序になるはずです。
x.f() が呼び出される。
x.c のデストラクタが呼び出される。
x.b のデストラクタが呼び出される。
x.a のデストラクタが呼び出される。
x のデストラクタが呼び出される。
制御はハンドラの直後の位置に戻る。
例外を吸収すると、完全には生成されなかったオブジェクト(x.c と x)の破棄が可能になってしまいます。これは、生まれることのなかったオブジェクトの死という、明らかなパラドックスを生んでしまいます。この言語規則は、コンストラクタが例外をスローするようにすることで、このパラドクスを巧みに回避します。
C++ の亡霊
上記では、オブジェクトの構成要素がすべて完全に生成された場合にのみ、そのオブジェクトが存在すると想定しています。しかし、オブジェクトが存在するということと、完全に生成された状態を同義としなければならないのでしょうか?具体的に言うと、x.c を完全に生成できなかったことは、x が実際に生まれる前に、無条件で死ななければならないほど、絶対的に忌まわしい出来事なのでしょうか。
C++ にまだ例外が規定されていなかった頃なら、x の定義は成功していたでしょう。そして、おそらくは x.f() の呼び出しが実行されていたはずです。このとき、例外をスローする代わりに、以下のように状態取得関数を呼び出していたでしょう。
X x;
if (x.is_OK())
x.f();
または、外部公開の状態値を以下のように問い合わせたでしょう。
bool is_OK;
X x(is_OK);
if (is_OK)
x.f();
当時私たちは、オブジェクトが存在しないということを主張せずとも、x.c のようなサブオブジェクトの生成の失敗を乗り切っていました。では今日の状況では許されないほど、私達の設計は根本的に破綻していたのでしょうか。C++ の精神は、その時から変わってしまったのでしょうか。それとも、私達は幻を見ていて、x が本当には未完の、存在しないオブジェクトだということを理解できていなかったのでしょうか。
公平さを期すために付け加えておくと、当時の C++ と現在の C++ は同じ言語とは言えないので、上記の疑問は少々オーバーかもしれません。古い(例外を持つ前の)C++ を現在の C++ のように扱うことは、C を C++ のように扱うのと同じです。構文の要素は共通の部分もありますが、セマンティックは同じではありません。以下を考えてみてください。
struct X
{
X()
{
p = new T; // assume 'new' fails
}
void f();
};
X x;
x.f();
new 式が T オブジェクトの割り当てに失敗したと仮定してください。例外を持つ前のコンパイラや、例外を使用禁止にした最近のコンパイラでは、new は NULL を生成し、x は生成に成功して、x.f() が呼び出されます。しかし、例外が使用可能になっていると、new はスローし、x は生成に失敗し、x.f() は呼び出されません。同じコードですが、意味がまったく違います。
以前、オブジェクトには自己破棄の選択肢がありませんでした。オブジェクトを生成したら、その後の状態を調べるのはプログラマに任されていました。生成に失敗したサブオブジェクトには対応しませんでした。さらに、スローする可能性のある標準ライブラリのルーチンを呼び出すこともありませんでした。要するに、昨日のプログラムと今日のプログラムは、別の世界に生きているのです。いつでも同じ方法でエラーに対応する、などということは期待できないのです。
それが結論ですか?
今はもう、私は C++ 標準規格の振る舞いが正しいと信じています。コンストラクタが例外をスローした場合には、そのホスト オブジェクトと、包含オブジェクトのすべてを破棄すべきです。この振る舞いに対する C++ 標準化委員会の明確な理由付けは知りませんが、これまでに学んだことに基づいて推測することはできます。
部分的に生成されたオブジェクトは、ユーザーが実際以上に生成が進んでいるものと想定するため、原因の分かりにくいエラーが発生します。同じクラスのオブジェクトでも、振る舞いが予測に反したり、予測できなかったりする可能性があります。
コンパイラは、余分な管理作業をしなければならなくなります。部分的に生成されたオブジェクトが消去される際に、コンパイラはそのオブジェクトとその構成要素となるサブオブジェクトのデストラクタが呼び出されないようにする必要があります。
オブジェクトが生成されていることと、オブジェクトの存在との間にある等価関係は、分断されることになり、尊い C++ の精神に反します。
オブジェクト クライアントのガイダンス
例外は、オブジェクトのインターフェイス契約の一部です。できれば、インターフェイスがスローする可能性のある例外のセットを確定しましょう。インターフェイスが例外仕様を特定せず、それ以外の方法で例外の振る舞いを知ることができないとしたら、そのインターフェイスは、いつでも、何でもスローする可能性があると仮定してください。
それ以外の場合には、考えられる例外をすべて処理できるようにするか、少なくともフィルタ処理できるようにしてください。予期しない例外をあなたのコードに入って来させたり、そのコードから外に伝搬させたりしないようにしてください。たとえ、単に例外を伝搬したり再スローしたりするだけでも、意識して行うようにしてください。
コンストラクタによるスロー
全サブオブジェクトのコンストラクタによってスローされる可能性のある例外の和集合を作成してください。そして、コンストラクタの中でその和集合を処理できるようにしておきます。以下に例を示します。
struct A
{
A() throw(char, int);
};
struct B
{
B() throw(int);
};
struct C
{
C() throw(long);
};
struct X
{
A a;
B b;
C c;
X();
};
全サブオブジェクトのコンストラクタが生成する例外の和集合は、{char, int, long} です。この集合は、X コンストラクタが遭遇する可能性のあるすべての例外を正確に記述しています。そのコンストラクタが例外をフィルタ処理せずにそのまま伝搬する場合、コンストラクタの適切な宣言は次のとおりです。
X() throw(char, int, long);
しかし、関数 try ブロックにより、コンストラクタはそれらの例外を別の型にマップすることができます。
X() throw(unsigned)
try
{
// ... X::X body
}
catch (...)
{
// map caught sub-object exceptions to another type
throw 1U; // type unsigned
}
以前のコラムで既に説明したように、クライアント コンストラクタは、サブオブジェクトの例外がリークすることを防げません。しかし、リークする例外の型は制御できます。これは、入って来る例外を、特定の外部向けの型(ここでは **unsigned)**にマップすることで行います。
コンストラクタがスローしない場合
サブオブジェクトのコンストラクタが 1 つも例外をスローしなければ、例外の和集合は空集合となり、包含コンストラクタは例外に遭遇しません。スローしないサブオブジェクトだけを含めることが、コンストラクタにスローさせないことを保証する唯一の手段なのです。
スローするサブオブジェクトを含める必要がある一方で、コンストラクタからスローすることを防ぎたいなら、いわゆる Handle Class(ハンドル クラス)、すなわち Pimpl を使うことを考えてください(「Pimpl」は、pImpl、つまり「pointer to implementation(実装へのポインタ)」の語呂合わせです)。コンパイル時間を短縮するための手法として昔から知られているこの方法は、例外安全性も向上できます。
前の例に戻ってみます。
class X
{
public:
X();
// ...other X members
private:
A a;
B b;
C c;
};
実装ポインタを使うには、X を 2 つの部分に分割しなければなりません。1 つめは、X クライアントに参照される「public」ヘッダになります。
struct X_implementation;
class X
{
public:
X() throw();
// ...other X members
private:
struct X_implementation *implementation;
};
一方、2 つめは「private」実装になります。
struct X_implementation
{
A a;
B b;
C c;
};
X::X() throw()
{
try
{
implementation = new X_implementation;
}
catch (...)
{
// ... Exception handled, but not implicitly rethrown.
}
}
// ...other X members
X コンストラクタは、*implementation を作成中に、つまり a、b、c の作成中にスローされるあらゆる例外を処理します。さらに、メンバの規準が変更されても、X クライアントを再コンパイルする必要はありません。これは、ヘッダを定義している X が変更されないからです。
(皮肉な話ですが、X::X が例外をキャッチした場合、*implementation、つまり a/b/c サブオブジェクトの少なくとも 1 つは、完全には生成されていません。それでもなお、包含 X オブジェクトは、有効なエンティティとして生存し続けます。この、部分的に作成された X オブジェクトの存在は、C++ の精神に反しているのでしょうか?)
この方法については、C++ に関する多くの文献で論じられているので、ここでは詳しく説明しません。とりわけ Herb Sutter の『Exceptional C++』(これについては、前回紹介しました)の Item 26 から 30 には、徹底的な解説が収録されています。
オブジェクト プロバイダのガイダンス
例外を、単なる別のエラー処理技法のように扱ってはいけません。エラー コードを返したり、グローバル変数の設定したりすることと同レベルだと思ってはいけません。例外は、それを取り巻くコードの構造と意味を、根底から覆します。例外は、プログラムの実行時セマンティックを一時的に繋ぎ変え、通常実行しているコードを迂回し、こういう状況でなければ決して実行されないコードを動作させます。例外は、エラー状態を認知させ、プログラムの死という罰則を用いてその状態を改めようとします。
このように、例外には単純なエラー処理を超えた特性があります。これらの特性を必要としない、理解しない、あるいは文書化したくないなら、例外をスローしてはいけません。例外以外のエラー処理技法を探してください。
スローすることに決めたなら、すべての因果関係を理解してください。あなたの設計が、あなたのコードを使用する他のユーザーに、潜在的に多大な影響を与えることを承知していてください。例外はインターフェイス契約の一部なのです。どの種類の例外をインターフェイスからスローするのか、どのような場合に例外をスローするのか、なぜスローするのか、これらを完全に文書化しなければなりません。そして、その文書を例外仕様として、コードの中に記述することを十分考えてください。
コンストラクタによるスロー
コンストラクタがスローするか、(直接的に、または再帰的に)そこに含まれているサブオブジェクトのどれかがスローすると、そのオブジェクトを含んでいるクライアント オブジェクトもスローします。そのため、生成が失敗します。これこそ、あなたの作成したものを再利用するクライアント オブジェクトが支払える、最大の代価です。この代価に見合うことをしなければなりません。
必ずしもコンストラクタからスローする必要はありません。従来の方法も有効なのです。コンストラクタは、エラーに遭遇したとき、それが致命的なものなのか、単に不正なだけなのかを判断しなければなりません。コンストラクタから例外をスローするということは、オブジェクトが完全に破壊されており、修復不能であるというきわめて強いメッセージを送ることを意味します。コンストラクタから状態コードを返せば、オブジェクトは壊れていますが、機能しているということを知らせられます。
これが今風のやり方だ、というだけでスローしないでください。オブジェクトの自己破壊は、実際にオブジェクトが存続できない、あるいは存続すべきでないときのためにとっておきましょう。
過剰指定
インターフェイスで過剰指定をしてはいけません。インターフェイスがスローできる例外のセットが正確に分っているなら、そのセットを例外仕様の中に列挙してください。さもなければ、例外仕様を省略してください。虚偽の仕様があるよりも、仕様が欠けている方が、ユーザーを欺かないだけましです。
このルールの適用を免除する可能性があるのは、テンプレート例外です。過去 3 回のコラムで示したように、一般にテンプレートを記述する人は、スローすることになる例外を知ることができません。テンプレートが例外仕様を省略すると、クライアントの安全性は低下し、値も決まりにくくなります。テンプレートに例外仕様がある場合は、以下に示すどちらかを守らなければなりません。
これまでに見てきた例外安全性の手法を使って、仕様が正しいことを保証する。
テンプレートが、特定の型の引数しか受け付けず、それ以外の型の場合にはインターフェイス契約の違反が生じ、制御できない場合があることを文書化する。
必要と十分の対立
将来の可能性をすべて先取りするためにクラスをわざわざ複雑にしてはいけません。すべてのオブジェクトが再利用されるわけではありません。Pete Becker からのお便りにも次のように書いてありました。
今のプログラマは、単純に排除すべき可能性に向けた設計に時間をかけすぎています。例外をスローすべき十分な理由があるなら、進んでその例外をスローして、それを文書化すればよいのです。例外をスローするのが当然の場面で、例外のスローを回避する方法を発明する必要はありません。複雑にすることで、制限されたバージョンのクラスを誰かが誤用する危険よりもかえってひどい保守の悪夢が生まれてしまいます。
私はコンストラクタに専念していましたが、Pete のコメントはデストラクタにも適用できます。第 14 部にもある、以下の教訓について考えてみてください。
デストラクタからのスローは絶対禁止。
他の条件が等しいものとして、この教訓を尊重するほうが無視するよりもよい結果が得られます。しかし、時として他の条件がそれほど等しくないことがあります。
あなたのオブジェクトを他のオブジェクトに含めてもらいたい場合、あるいは、少なくとも他があなたのオブジェクトを含めることを阻みたくないなら、デストラクタからスローしてはいけません。
スローする理由が本当にあり、そのスローによって安全な包含が妨げられることを理解しているなら、進んでスローします。そして、その正当性を文書化しておきます。
設計に例外処理を意識的に取り入れなければならないのと同様に、再利用についても考慮しなければなりません。デストラクタで throw() を指定することは、すぐれたサブオブジェクトを作成するために必要かもしれません。しかし、決してこれだけでは、十分とはいえません。コードの中でどのようなコンテキストを推奨し、どれを許し、どれを積極的に否定するかをあらかじめ決めておく必要があります。設計を複雑にするつもりなら、その複雑さは意識的な戦略の一部でなければなりません。実質のない、単なる「万一に備えて」の措置であってはいけません。
謝辞と続き
Dan Saks、Herb Sutter、Pete Becker、そして Scott Meyers。コンストラクタ / デストラクタのスローに関する私の考察の方向を定め、焦点を合わせることを手伝ってくれた 4 人のみなさんに、心からお礼を申し上げます。
また、勤勉な読者のみなさん、特に Todd Greer と Tim Butler にも、彼らがここと Usenet 上へ投稿してくれた(好意的だったり、そうでなかったりの)コメントに対して、感謝の言葉を贈ります。今度こそは正しく理解できていますように。少なくとも、以前よりは理解できていますように。
特に他の異常な状態を考慮に入れなければ、例外安全性についてはこれでおしまいです!実際のところ、例外そのものについても、少なくとも独立した話題としては、説明はほぼ終わりです。次回はお休みですが、3 月中旬からは、ずっと以前から約束していた、C++ の例外と Visual C++ SHE の組み合わせについて取り上げます。
Robert Schmidt は MSDN のテクニカル ライターです。このほかに彼が寄稿している雑誌に、C/C++ Users Journal があります。そこでは、彼は編集補助およびコラムニストとして活躍しています。これまでのキャリアで、ラジオ DJ、野生生物飼育係、天文学者、ビリヤード場管理者、探偵、新聞配達夫、そして大学講師を経験しています。