いよいよC++の参考書の執筆も例外にまで到達した。例外は、規格の文面量だけで言えば短いが、詳細を解説するのは難しい。なにせ、まともに日本語で解説している本は皆無だからだ。ついでに、規格の文面のバグも発見した。これはすでに報告済みなので、次のC++規格では修正されるはずだ。
ちなみに、"C++ 例外"で検索して出てくる情報の大半が間違っているか、十分な詳細を解説していない。責任は規格違反な実装、特にMSVCにある。MSVCの挙動が全てだと信じる愚者が、規格を参照せずMSVCの挙動をもとに解説を書いているからだ。
たとえば、例外をハンドルしていない状態でオペランドのないthrow式を実行すると、std::terminateが呼ばれる。
int main() { throw ; // std::terminateが呼ばれる }
あるC++解説サイトでは、何故かstd::bad_exceptionが投げられると書いてある。これはおそらく、悪名高い不自由なC++コンパイラーであるMSVCの規格違反の実装をもとにしているのだろう。規格を参照せず、実装の挙動に頼るからこういう間違いを犯すのだ。コンパイラーはタイプミスなどの誤りを検出するためのものであって、規格検証のためのツールではない。ただし、Clangならだいぶいい線をいっているのだが。
俗に、例外中に例外を投げるとエラーとか、二重例外はエラーとか呼ばれていることについても誤解が多い。そこで、今回は二重例外について説明する。
まず、規格には「二重例外」なる言葉はない。「例外中に例外を投げる」というのはやや近いが、あまりに簡潔化し過ぎていて重要な意味が失われている。実は、現行のC++11の文面にはバグがある。そこで修正された最新のドラフトから引こう。ただし、これにもまだバグがある。
If the exception handling mechanism, after completing the initialization of the exception object but before activation of a handler for the exception, calls a function that exits via an exception, std::terminate is called
例外処理機構が、例外オブジェクトの初期化が完了した後、例外に対応するハンドラーが起動する前に、例外によって抜け出す関数を呼んだ場合、std::terminateが呼ばれる。
15.1 [except.throw] paragraph 7
まず、「エラー」の詳細であるが、「std::terminateが呼ばれる」ということだ。これは、実際のところ、C++におけるコンパイル時には検出できないコード上の誤りといってもいい。std::terminateは極めて限定的な条件でしか呼ばれず、その条件とは、ほとんどの場合コード上の誤りに起因するものだからだ。
さて、「例外中」の詳細は、具体的には、「例外オブジェクトの初期化が完了した後、例外に対応するハンドラーが起動する前」だ。問題となる条件は、「例外によって抜け出す関数を呼んだ場合」だ。これにより、例えば以下のようなコードは、std::terminateを呼び出す。
// デストラクターが例外を投げるクラス struct C { // デストラクターに明示的な例外指定がない場合、この文脈では暗黙にthrow()になるため // デストラクターの外に例外を投げるには例外指定が必要 ~C() noexcept( false ) { throw 0 ; } } ; int main() { try { C c ; throw 0 ; // C型のオブジェクトcが破棄される // 例外中に例外が投げられたため、std::terminateが呼ばれる } catch( ... ){ } }
デストラクターのnoexcept(false)に注意。C++11では、ユーザー定義のデストラクターに明示的な例外指定がない場合、暗黙の特別なメンバー関数に適用される暗黙の例外指定を受け継ぐ。これにより、デフォルトではthrow()となり、無例外関数だとみなされてしまうので、C++11でデストラクターの中から外に例外を投げたい場合は、明示的に例外指定を書かなければならない。
ただし、クラスCが以下のように書かれていた場合、std::terminateは呼ばれない。
struct C { ~C() { try { throw 0 ; } catch ( ... ) { } // デストラクターの外に例外を投げない } } ;
これは、「例外によって抜け出す関数を呼ぶ」という条件に合致しないからだ。
したがって、例外中に例外とか二重例外などという言い方は正しくない。例外が投げられ、まだハンドラーによってとらえられていないとしても、例外は問題なく使える。
初期化が完了した後という点にも注意。たとえば、以下のコードはstd::terminateを呼ばない。
struct X { X() { throw 0 ; } } ; int main( ) { try { // OK、初期化式の評価中の例外 // 例外オブジェクトの型はint throw X() ; } catch( X & exception ) { } catch( int exception ) { } // このハンドラーでとらえられる }
これは、X型の例外オブジェクトの初期化中の例外なので、条件に当てはまらない。ただしこの場合、例外オブジェクトの型はXではなくintになる。
ただし、初期化が完了した後という点にも注意。たとえば、以下のコードでは、C++実装次第でstd::terminateが呼ばれる。
struct X { X( X const & ) { throw 0 ; } } ; int main( ) { try { // 実装がコピーを省略しない場合、std::terminateが呼ばれる // コピーコンストラクターの実行は評価完了後 throw X() ; } catch( ... ) { } }
もし、C++11の実装が、例外オブジェクト構築の際に、Xのコピーコンストラクターを呼んだ場合、std::terminateが呼ばれる。賢い実装ならば、このような文脈ではコピーの省略ができるが、コピー、ムーブの省略は規格上許されているが保証されていないので、省略されない場合、std::terminateが呼ばれる。
ところで、冒頭で現在の規格の文面にはバグがあると書いた。この文面を厳密に解釈すると、以下のコードではstd::terminateが投げられる。
// 例外によって抜け出す関数 void f() { throw 0 ; } struct C { ~C() { // 例外によって抜け出す関数を呼ぶ try { f() ; } catch( ... ) { } } } ; int main() { try { C c ; throw 0 ; } catch( ... ){ } }
このコードは、現行規格の文面を厳密に解釈すれば、std::terminateを呼び出すはずである。これはどう考えてもおかしいし、既存の実装もそういう実装にはなっていない。これは規格の文面の誤りであり、C++参考書を執筆している際に発見した。すでに報告してあるので、次の規格改定までには修正されるはずだ。
と、このように、規格の文面を一字一句、厳密に解釈して書かれたC++の参考書は、もうすぐ、例外まで書き終わる。プリプロセッサーは私の脳内規格ではobsoleteなので解説しないのは当然だから、もうすぐ公開できる。
それにしても長かった。参考書の執筆中に、いくつものGCCのバグや規格の文面のバグを見つけた。まだもう少しかかるが、この参考書を執筆することで、すでにC++にはだいぶ貢献したと思う。
No comments:
Post a Comment