いよいよ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