2013-04-29

例外中に例外を投げるとか二重例外はエラーという俗説について

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

1503. Exceptions during copy to exception object

まず、「エラー」の詳細であるが、「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: