こんにちは。田原です。
前回、例外の使い方とその便利良さを解説しました。
いつも退屈で憂鬱なエラー処理の記述とテストをかなり削減できる本当にありがたい機構なのですが、使い方が難しいです。その難しい部分(特に避けて通った方が良い部分)や各種の使用上のノウハウを解説します。
1.なるべくcatch(…)で例外を握りつぶさない
前回、catch(…)を最後に書くと、それまでのcatch()で捕まえることができなかった例外を捉えることができるけど、どんな例外かわからない旨、説明しました。
さて、例外はエラー処理の記述とテストを容易にするための仕組みです。
- エラーの内容を把握し、自動リカバリを可能とする
例えば、ユーザにファイル名が不適切であることをメッセージ表示し、適切なファイル名を入力して貰うなどが考えられます。ヒューマンエラーが頻発しないことは期待できますし、ヒューマンエラーが発生した時、処理継続は困難ですから例外で対処するのに向いています。 -
リカバリ出来ないケースではプログラマにエラーの内容を伝達する
例えばログにエラー内容を記録し、致命的エラーにて終了する旨表示して、プログラムを終了します。プログラマはそのログをみて問題点を把握し、それに対処できるようプログラムを修正することが目的です。
そのような例外としては、NULLポインタ・アクセスやメモリ不足エラー、0による除算など様々な要因が考えられます。できるだけ事前のテストで検出し現場で発生しないようにするべき不具合ですが、全てのケースのテストを行うことは不可能(*1)
なため、バグが顕在化した後の対処を短時間で行えるような仕込みを事前に仕込んでおくという考え方です。
どちらのケースでもエラーの内容を把握できることが重要です。
特に前者のケースで闇雲にリカバリすると、正常に動作できない状態になっているにも関わらず、無理やり動作継続することになり、何が起こるか分かりません。これは最悪被害を拡大し、非常に痛い問題を引き起こす可能性があります。実生活でもそうですが、間違いは早期に発見して対処するのが被害を最小限に食い止めるための王道です。闇雲なリカバリは避けるべきです。
後者のケースでも、catch(…)で分かることは何かエラーが発生したことだけです。これだけですと対処が難しい場合も少なくありません。できるだけエラーの内容も把握出来るようにすることが望ましいです。
ただし、下記のようにして再throwし、その先のtry-catchで捕まえて対処していれば問題ありません。
catch(...) { 各種処理 throw; }
つまり、catch(…)してそのまま再throwせずに例外を握り潰すと、エラーの内容を把握するべき人が把握できなくなるので、なるべく避けたいものです。
(*1) 全てのケースのテストを行うことは不可能
もし、完璧なテストを実施できるとお考えてであれば、即考えを改めましょう。それは無いものねだりなのです。
極々小規模なプログラムでも入力バターンの総数は多いです。例えば文字列1つを入力するプログラムに対して必要なテストケースを考えます。アルファベット26文字を長さ100文字まで入力できるプログラムの入力パターンの総数は26の100乗を超えます。これら全ての入力に対して正しい出力を定義し、テストを行えば完璧なテストです。
しかし、26の100乗は天文学的な数字ですので これは不可能ですね。そこで、全ての不具合を検出できる入力セットに絞るよう努力しますが、それを間違いなく抽出できるでしょうか? 大抵のバグは「思わぬ」入力に対して発生します。予想外のことを予想するのがプロとは言え、完璧は無理です。もちろん、そうは言ってもバグの流出を削減することは可能です。それにどこまで注力するのかはプロジェクトの性質によります。原子炉の制御を行うプログラムや人命を預かる医療機器のプログラム開発時は、かなり多くの工数が投入されます。しかし、ゲーム開発の場合はゲームとして楽しく遊べるものであればOKなので、原子炉制御プログラム程テスト工数を投入しないのが現実です。
そして、ゲーム等の場合はできるだけ少ない工数で高信頼性なプログラムを開発でき、かつ、仮に問題が発生しても短期間で修正できると嬉しいです。そのために、例外機構はたいへん役に立ちます。
2.例外は遅いので「処理を継続できなくなったことの通知」に用いる
最近の多くの処理系は、try-catch自身のオーバーヘッドはほとんどないと言われています。
しかし、throwした時の処理はたいへん重いです。
ちょっと実験してみました。
#include <iostream> #include <exception> #include "fine_timer.h" volatile int gCounter; bool foo(bool iThrow) { ++gCounter; if (iThrow) { throw std::runtime_error("exception!!"); } return false; } bool bar(bool iThrow) { bool ret=foo(iThrow); if (!ret) return false; std::cout << "bar() return true;\n"; return true; } int main() { gCounter=0; FineTimer ft; for (int i=0; i < 1000000; ++i) { try { if (bar(false)) break; } catch(std::exception&) { } } std::cout << "Time : " << ft.uSec() << " uSec\n"; std::cout << "gCounter : " << gCounter << "\n"; gCounter=0; ft.restart(); for (int i=0; i < 1000000; ++i) { try { if (bar(true)) break; } catch(std::exception&) { } } std::cout << "Time : " << ft.uSec() << " uSec\n"; std::cout << "gCounter : " << gCounter << "\n"; return 0; }
main()関数の前半は例外を投げずに1,000,000回ループし、その処理時間を計測しています。
後半は例外を投げる処理を1,000,000回ループし、その処理時間を計測しています。
次のコマンドでビルドし、実行します。(リリース・モードでビルドしています。)
> mkdir msvc > cd msvc > cmake .. > cmake --build . --config Release > Release\exception.exe Time : 2068 uSec gCounter : 1000000 Time : 7452418 uSec gCounter : 1000000
$ mkdir gcc $ cd msvc $ cmake .. -DCMAKE_BUILD_TYPE=Release $ make $ ./exception Time : 2628 uSec gCounter : 1000000 Time : 1719701 uSec gCounter : 1000000
結果をまとめると以下の通りです。例外、本当に遅いです。
コンパイラ | 例外無しの処理時間 | 例外有りの処理時間 |
---|---|---|
Visual C++ | 約2ミリ秒 | 約7.5秒 |
gcc | 約2.6ミリ秒 | 約1.7秒 |
奥の方のサブルーチンから、catchまで直接戻るようなある意味無茶なことをしたい時は、処理が破綻し継続することが困難な場合がほとんどです。
たまに多重ループを抜ける時に使いたい誘惑に駆られることもあります。それが頻発せず、時間がかかっても問題ないなら例外を使うことも可能です。
しかし、例外は慎重に設計するべきです。異常系処理の多くが例外に依存するため、後で設計変更をできるだけ避けたいです。ですので、安易に多重ループからの脱出等で使うことはあまりお勧めしません。現在実行中の処理を中断して破棄し、一度ニュートラルな状態に戻って最初からやり直す、もしくは、エラーを表示してプログラムを終了するような「大きな」エラーに対して用いることをお勧めします。
使用する場面をできるだけ1つの目的(異常系コード記述とテストの工数削減)に限定することで設計を単純化するのです。
3.例外処理をミスするとプログラムが強制終了します
発生した例外がなんなのか表示されないまま強制終了する処理系もあり、エラー原因を掴めず苦労します。できるだけこのような自体を発生させないよう避けましょう。
3-1.catchしなかった時
例外を投げたけどcatchしなかったらプログラムが異常終了します。
単純にcatchがない場合、もしくは、それをcatchするcatch文が無い場合の両方がありえます。
#include <iostream> #include <exception> class FileOpenException : public std::exception { std::string mMessage; public: FileOpenException(std::string const& iMessage) : mMessage(iMessage) { } char const* what() const noexcept { return mMessage.c_str(); } }; int main() { try { throw FileOpenException("test"); } catch(std::runtime_error& e) { std::cout << "std::runtime_error : " << e.what() << std::endl; } #if 0 catch(std::exception& e) { std::cout << "std::exception : " << e.what() << std::endl; } #endif }
Wandboxで試してみる(#if 0を#if 1へ変更してみて下さい。)
gccの場合は、投げたエラーがstd::exceptionを継承したクラスで仮想関数what()をオーバーライドしておけばwhat()で返却した内容が表示されます。
Visual C++の場合は、残念ながらエラーの内容不明で強制終了します。
3-2.C++11ではデストラクタから例外を投げた時
C++では、例外を投げない関数を宣言できるようになりました。noexceptを次のように付けます。
void foo noexcept { }
上記の例では、fooの中から例外が外に漏れ出てしまったら、プログラムが強制終了されます。
fooの内部で例外が発生してもtry-catchで捕捉され、そのまま例外を投げなければ漏れ出ません。
しかし、例外を投げないと宣言したにも関わらず、実際に例外が投げられるとプログラムが終了します。
この目的は最適化です。例外を投げないことが分かっている場合は、それを前提としてより効率の良い最適化を行えるのです。
ところで、デストラクタはデフォルトでnoexceptが暗黙指定されます。つまり、例外を投げないと宣言しているため、もしも投げてしまうとプログラムが強制終了します。
#include <iostream> #include <exception> struct Foo { ~Foo() { throwException(); // throw std::runtime_error("test"); } void throwException() { throw std::runtime_error("test"); } }; int main() { Foo foo; }
ですので、例外を投げる可能性があるクラスや関数を使っているデストラクタではtry-catchして適切に処理する方が望ましいです。
そして、std::string等のメモリをヒープから取ってくるクラスを使っているとこれに該当しますので、実は多くのデストラクが該当する可能性があります。しかし、現実問題、メモリ不足例外はまず発生することがなく、かつ、仮に発生しても自動リカバリは事実上不可能ですので、実際の運用ではメモリ不足が発生しないことを事前にテストするべきです。そして、メモリ不足例外が発生しないことを検証していた場合、try-catchによる対処は必要ではありません。不必要な処理を実装して無駄にロジックを複雑にするより、不要な処理を省略してロジックを単純に保つことは望ましいと思います。
3-3.例外が発生してリソース解放中に再度例外を投げた時
例外が発生することで引き起こされるリソース解放処理はデストラクタの処理です。
C++11では前節で述べたようにデストラクタ内部から例外を投げただけで強制終了ですが、デストラクタのnoexcept宣言を外すことができます。そのようにした上で、2回めの例外が発生すると強制終了します。
noexceptを外すには次のように記述します。
void foo noexcept(false) { }
さて、例外が発生することによるリソース解放中に例外を投げるには、例外により解放されるローカル変数のデストラクタで例外を投げることになります。aAutoReleaseがそのローカル変数です。
#include <iostream> #include <exception> struct AutoRelease { ~AutoRelease() noexcept(false) { throw std::runtime_error("double fault"); } }; int main() { try { AutoRelease aAutoRelease; throw std::runtime_error("first fault"); } catch(std::runtime_error& e) { std::cout << "std::runtime_error : " << e.what() << std::endl; } }
Wandboxで試してみる(AutoRelease aAutoRelease;をコメントアウトしてみて下さい。落ちなくなります。)
4.デストラクタから例外を投げない
4-1.デストラクタから例外を投げない
大事なことなので2回書きました。これが一番重要です。
例外機構は次の2つの機能を提供するのでたいへん強力です。
- 後続の処理を中断し、指定の場所(catch)へどんなエラーが発生したか伝達する。
- 後続の処理の中で解放される筈だったリソースを適切に解放する。
中断する後続の処理の内リソース解放だけは処理するって不思議ですよね。
それは前回解説したように、ローカル変数が解放される時に呼ばれるデストラクタで実行するようにプログラムすることで実現できます。各処理のために必要なリソース(メモリやファイル・ハンドル等)を、その処理を行っている間有効なローカル変数内に確保しておき、そのデストラクタで解放するだけなのです。
さて、(残念なことに)ここで問題があります。
あなたのプログラム実行中に致命的なエラーが発生し例外が投げられたとします。その結果、2.の処理が行われます。それはそれまでに確保されたローカル変数のデストラクタにより実行されます。
そのデストラクタで再度致命的エラーを検出した時に困ります。
この時、再度例外を投げたいところですが、例外を投げると例外機構の1.の機能により「後続の処理」が中断されます。しかし、現在実行中の処理は、例外機構の2.の機能により中断されない筈のリソース解放処理群です。
ああ、矛盾なのです。「後続の処理の中断」と「リソース解放処理の継続」、どちらが勝つか見ものです。
結論ですが、C++はこの勝負を放棄し、プログラムを強制終了させます。しかも困ったことに、発生した2つの例外の両方の内容が表示されることはありません。Visual C++はどちらも表示しませんし、gccは後から発生した方だけを表示します。(本当に知りたいのは、問題の引き金となった最初に発生した方の筈です。)
これに対するベストな対策はデストラクタでは例外を投げないことです。このルールはかなり広く受け入れられています。ある意味、C++の「常識」と言っても過言ではありません。
そして、そのためには、デストラクタではエラー報告が必要になるような処理を行わないことが重要です。
例えば、デストラクタでファイル・オープンする場合、そのファイルがなければエラーになります。そのファイルが無いとデストラクト処理が破綻してしまうような場合、ファイルがなかったことをオペレータやプロクラマに報告し、対処して貰うことが必須です。
しかし、デストラクタで例外を投げると矛盾が生じるため、エラーを報告できません。
であれば、そのような処理をデストラクタでは行わないことが強く推奨されるということなのです。
そこで、デストラクタでは主にメモリやファイル・ハンドル等のリソース解放だけを行い、仮に失敗しても無視できるような処理だけにすることが望ましいです。
先のようなファイル・オープンを伴う処理はデストラクタではなく「後処理関数」のようなものを追加し、ローカル変数のスコープからの脱出で自動的に呼ぶのではなく、明示的に呼ぶようにするしかありません。
デストラクタでは例外を投げない。
デストラクタの処理は主にリソース解放とする。
ところで、デストラクタから例外を投げたいと思うことは「悪」なのか?
現在のC++の機能の範囲では「悪」です。標準規格違反と言う意味ではないです。規格上は投げることは可能です。といいつつ、私はデストラクタで複雑な処理を書きたいことがたまにあります。
コンストラクタとデストラクタはちょうどペアになるため、開始処理と終了処理を書くのに大変適切です。処理を開始した後、終了処理に煩わされなくても自動的に適切なタイミングで終了処理をしてくれるので是非使いたい便利な仕組みなのです。
そして、それが複雑な処理の場合、大抵エラーが発生する可能性があり、例外を投げたくなるのです。しかし、現在のC++ではデストラクタから例外を投げてプログラムが破綻しないようにプログラミングすることは非常に難しく、一般論では可能ななレベルではありません。(個別のプログラムに限定すれば可能ですので、それで凌ぐこともあります。かなり苦労します。)
これがいつか一般的に可能になることを期待しています。ダブル・フォルトを抑制し、リソース解放中に発生した例外をリソース解放完了まで保留できればきっとできると思います。
5.おまけ
std::exceptionを何も説明しないまま使っていましたが、これはC++の標準ライブラリに含まれる標準的な例外の基底クラスです。
「例外」クラスは全てこれを継承するようにし、この中で定義されている仮想関数what()をオーバーライドしておけば、下記の構造で事実上全ての例外を補足し、発生したエラー・メッセージを表示できますので、メリットが大きいです。
catch(std::exception& e) { std::cout << e.what() << std::endl; // リカバリ処理; }
また、標準ライブラリで多数の例外が定義されています。それらに対して行うエラー・リカバリと同じエラー・リカバリを行う場合は、同じ例外を投げた方が好ましいです。
それらの標準例外の一覧表がここに有ります。
また、それらのクラスの継承関係は英語ですが、ここに記載が有ります。
6.まとめ
今回は例外の使用上の注意事項を解説しました。たいへん強力な例外ですが、強力なだけに使い方を誤ると痛いことになります。
例外は用法用量を守ってご使用下さい。
さて、第7回目で軽く説明したfor文は意外に高度な機能を持っています。次回(もしかすると2回くらい)で詳しく解説します。お楽しみに。