こんにちは。田原です。
今回から2~3回に分けて例外について解説します。C言語時代、例外がなかった頃のエラー処理はたいへんでした。一々関数の戻り値をチェックしてエラー処理を書く必要があったからです。それはもう退屈でテストは苦行でした。例外はそれらを大幅に楽にする優れた仕組みです。今回はまず例外の基本的な使い方とメリットについて解説します。
1.基本的な考え方
例外は2つの機能を持ちます。
- エラーが発生したことを通知する。
- エラーが発生した以降の処理を指定場所までスキップする。
1-1.古くからあるエラー処理方法
C言語時代は、関数の戻り値でエラーが発生したかどうかを返却し、呼び出し側で戻り値をチェックしてエラーが発生した時の処理を行っていました。
この方法はなかなか手間がかかります。まず、エラーが発生する関数を呼び出すたびにエラーチェックと、必要ならエラー処理を記述することになります。例えば次のようにです。
#include <iostream> int gErrorNo=0; bool foo() { return true; } bool bar() { gErrorNo=123; std::cerr << "bar() : Error!! gErrorNo=" << gErrorNo << "\n"; return false; } int process() { bool ret; ret=foo(); if (!ret) { std::cerr << "process() : foo() Error!! gErrorNo=" << gErrorNo << "\n"; return 1; } ret=bar(); if (!ret) { std::cerr << "process() : bar() Error!! gErrorNo=" << gErrorNo << "\n"; return 1; } return 0; } int main() { if (process() != 0) { std::cerr << "main() : process() Error!! gErrorNo=" << gErrorNo << "\n"; return 1; } return 0; }
bar() : Error!! gErrorNo=123 process() : bar() Error!! gErrorNo=123 main() : process() Error!! gErrorNo=123
1-2.例外を使うと
例外でエラー通知する場合は、次のように書くことが出来ます。
#include <iostream> #include <exception> void foo() { return; } void bar() { throw std::runtime_error("Error in bar()"); } void process() { foo(); bar(); } int main() { try { process(); } catch(std::exception& e) { std::cerr << e.what() << "\n"; return 1; } return 0; }
Error in bar()
1-3.比べてみると
1-1.と1-2.のプログラムを見比べてみましょう。
- 最大の差はprocess()関数にあらわれています。見ての通り関数を呼び出す度にエラー・チェックしなくて済むため、process()関数はほぼ「正常系」の処理だけを記述すればよく、非常に見通しが良くなりました。このくらい単純な処理でも結構変わります。もっと複雑な処理をする場合、更に効果的です。
-
また、foo(), bar(), process()関数は戻り値でエラーを返却しなくて済むので、正常系で返却したいデータを自由に返却できます。
-
更に、私はいつも悩ましかったのですが、1-1.では同じエラーを3回表示しています。分かりにくいし嫌なものです。そこで一番深いところだけ表示したいです。その場合、自分が呼び出している関数がエラー表示しているのか、きちんと管理しないといけません。例えば、1-1.のfoo()関数は何らかの理由でエラー表示出来ないものかもしれません。この場合、process()はfoo()の戻りに対してはエラー表示が必要ですが、bar()の戻りに対してはエラー表示するべきではありません。その制御を間違うと、エラーが複数表示されたりエラーがそもそも表示されないという事態に陥ります。後者は本当に嫌なものです。
例外を使うことで、これらの問題点が全部吹っ飛んで行きます。
2.基本的な例外の書き方
例外処理は、tryブロック、catchブロック、throw文で記述します。前者2つはセットですのでtry-catchと一言で表現することも多いです。
2-1.例外を受け取るための記述
例外を受け取るためには、次のように記述します。
try { 通常の処理をここに書きます; ここで発生した例外はcatchブロックでキャッチされます; } catch(型1 仮引数) { } catch(型2 仮引数) { } : catch(...) { }
tryブロックの中で投げられた例外は、catchブロックを頭から順に評価し、型が一致するcatchブロックが実行されます。
この時、全てのcatchブロックの型は異なっていることが必要です。同じものを2つ以上書いた場合の動作は未定義です。コンバイル・エラーや警告になったり、単に2つ目のcatchが無視されたりします。
最後のcatch(…)はそれ以前のcatchで捕まえられなかった例外を捕まえます。ただし、型も仮引数も書くことができませんので、どんな例外が発生したのかこのcatchブロック内では一切分かりません。
2-2.例外を投げるための記述
throwでインスタンスを投げます。
throw インスタンス;
インスタンスは例えばint型等の基本型でも良いですし、クラス型やenum型でも良いです。
大抵の型が許されてますが、通常はクラス型のインスタンスを投げます。
使い捨てのプログラムの場合は手抜きしてint型を投げることもあります。
2-3.例外の記述例1
たぶん、最も単純な記述です。
#include <iostream> int main() { try { throw 123; } catch(int e) { std::cerr << e << "\n"; } return 0; }
123
2-4.例外の記述例2
同じ型のcatchブロックが2つありますが、gccではエラーがでませんでした。注意しましょう。
Visual C++はエラーになりましたが、「error C2312: ‘int’: ‘int’ によってキャッチされました。」でした。(意味不明ですね。)
#include <iostream> int main() { try { throw 123; } catch(int e) { std::cerr << "a : " << e << "\n"; } catch(int e) { std::cerr << "b : " << e << "\n"; } return 0; }
2-5.例外の記述例3
catch(…)でキャッチする例です。short型とlong型をキャッチしてますが、int型を投げたのにint型をキャッチしていないため最後のcatch(…)でキャッチされます。
#include <iostream> int main() { try { throw 123; } catch(short e) { std::cerr << "a : " << e << "\n"; } catch(long e) { std::cerr << "b : " << e << "\n"; } catch(...) { std::cerr << "catch(...)\n"; } return 0; }
2-6.例外の記述例4
クラス型の例外をtryブロックから呼び出した関数から投げます。
throw文で直接生成した一時オブジェクトを投げ、catch文でそれを参照で受け取ると無駄なコピーが発生しにくいので好ましいです。
この形式の場合、throw文で生成した一時オブジェクトはcatchブロックの最後まで有効です。
#include <iostream> #include <string> class Exception { std::string mMessage; public: Exception(char const* iMessage) : mMessage(iMessage) { } std::string& what() { return mMessage; } }; void foo() { throw Exception("Exception!!"); } int main() { try { foo(); } catch(Exception& e) { std::cerr << e.what() << "\n"; return 1; } return 0; }
例外情報は「例外として投げるクラス」の中に全て記録しておくことをお勧めします。
例えば、例外情報へのポインタを記録することもできますが、そのポイント先が例外を投げた関数内のローカル変数だった場合、catchされた時には失われています。
特に文字列の場合、std::string等をローカル変数で獲得して、そのstd::stringにエラー・メッセージを組み立て返却したくなります。その際、そのローカル変数へのポインタを記録すると適切に伝達できません。例えば下記の例でデバッグして問題が発生せず、安心して使っていたけど、ある日、エラー・メッセージを加工する必要が発生し、#if 0側の使い方をするとメッセージの伝達に失敗します。これは結構痛いです。
#include <iostream> #include <string> class Exception { char const* mMessage; public: Exception(char const* iMessage) : mMessage(iMessage) { } char const* what() { return mMessage; } }; void foo() { #if 1 // 直接文字列定数を渡しているのでこれはfoo()を抜けても破壊されない throw Exception("Exception"); #else // ローカル変数に"Exception"が記録されるのでfoo()を抜けると破壊 std::string aMessage="Exception "; aMessage += std::to_string(__LINE__); throw Exception(aMessage.c_str()); #endif } int main() { try { foo(); } catch(Exception& e) { std::cerr << e.what() << "\n"; return 1; } return 0; }
Wandboxで試してみる #if 1を#if 0へ変更してみて下さい。
2-7.例外の記述例5
クラス型へのポインタを例外として投げます。
時々見かける方法ですが、正直あまりお勧めしません。delete漏れが怖いからです。
かと言ってローカル変数で獲得すると、当然foo()関数から抜けた時点で破棄されます。
例外自体は適切にコピーされますが、ポインタを投げた場合はポインタがコピーされるだけで、ポインタの指す先はコピーされないからです。ポインタを他のポインタへ代入してもポインタの指す先はコピーされないのと同じです。
#include <iostream> #include <string> class Exception { std::string mMessage; public: Exception(char const* iMessage) : mMessage(iMessage) { } std::string& what() { return mMessage; } }; void foo() { #if 0 Exception aException("Exception!!"); throw &aException; #define LOCAL #else throw new Exception("Exception!!"); #endif } int main() { try { foo(); } catch(Exception* e) { std::cerr << e->what() << "\n"; #ifndef LOCAL delete e; #endif return 1; } return 0; }
Wandboxで試してみる #if 0を#if 1へ変更してみて下さい。
2-8.例外の記述例6
派生クラスの例外を投げることができます。
受け取り側は、基底クラスで受け取ることもできるし、派生クラスで受け取ることもできます。
なお、この使い方をする時は、動的ポリモーフィズムの仕組みを使うことを強くお勧めします。
つまり、基底クラスは仮想関数を持つこと、および、catchは参照で例外を受け取ることです。
その際、エラー情報を返却する関数を仮想関数とすることで、基底クラスで受け取っても派生クラスのエラー情報を獲得できます。
#include <iostream> struct Base { virtual char const* what() { return "Base"; } }; struct Derived : public Base { virtual char const* what() { return "Derived"; } }; int main() { try { #if 0 throw Base(); #else throw Derived(); #endif } #if 0 catch(Derived& e) { std::cout << "catch(Derived&) : " << e.what() << "\n"; } #endif catch(Base& e) { std::cout << "catch(Base&) : " << e.what() << "\n"; } return 0; }
Wandboxで試してみる 2つの#if 0の組み合わせを試して見て下さい。
2-9.例外を再throwする
catchブロックで受け取った例外を再度投げる事ができます。throw;と書くだけです。
特にcatch(…)ブロックでこれができるのはありがたいです。
もちろん、型を指定したcatchブロックでも再throwできます。
#include <iostream> #include <exception> void foo() { return; } void bar() { throw std::runtime_error("Error in bar()"); } void process() { try { foo(); bar(); } #if 0 catch(std::exception& e) { std::cerr << "process() : " << e.what() << "\n"; throw; } #endif catch(...) { std::cerr << "process() : catch(...)\n"; throw; } } int main() { try { process(); } catch(std::exception& e) { std::cerr << "main() : " << e.what() << "\n"; return 1; } return 0; }
Wandboxで試してみる #if 0を#if 1へ変更してみて下さい。
3.素晴らしいC++例外機構
前章では、例外を使って奥深い関数で処理を中断し、エラーを伝達する方法を解説しました。
そして、適切にプログラムを作ると、例外を投げた時点で処理中断するだけでなく、途中で獲得しているリソースを自動的に解放できます。(逆に言うと、適切にプログラムを作らないとリソース・リークすると言うことでもありますが。)
C#やJavaにはtry-finallyと言うtry-catchに良く似た構文があります。
returnや例外等によりtryブロックの中から抜ける時、finallyブロックが実行されるものです。
しかし、C++にはありません。何故無いのでしょう? 実は必要がないからです。
Visual C++にはマイクロソフト固有の__try-__finallyがありますが、あくまでもマイクロソフト固有でC++標準にはありません。
3-1.C#やJavaとの相違
C#やJavaのクラス・オブジェクトの寿命は使わなくなる(参照しなくなる)までです。しかも、使わなくなった瞬間破棄されるのではなく、時々動作するガベージコレクション処理にて回収されるまで生きてます。
つまり、使っている間生きていることを保証するために、逆に使わなくなった瞬間破棄できなくなったのです。
これに対して、C++は寿命が尽きた瞬間破棄されます。(例え使っていたとしてもです。)
この差は言語の重点課題の差ですね。C#やJavaは生産性重視です。C++は生産性も重視してますが、性能を落とさない範囲で如何に生産性を上げるかですから性能をより重視しています。ガベージコレクションがあるとタイムクリティカルな処理には致命的なのです。
さて、C++のローカル変数の寿命は、定義されてから、そのブロック終わりまででした。
クラス型のローカル変数の場合、そのブロック終了時にデストラクタが自動的に呼ばれます。
そして、これはブロックの中から呼び出している関数で例外が発生し、その例外がcatchされる際にブロックから抜ける時も同じように解放されます。
3-2.デストラクタで解放すればよいです
つまり、あるブロックでメモリやファイル・ハンドル等のリソースを獲得したとします。
それを解放する必要がありますが、そのリソースの解放処理はローカル変数が解放される際に呼ばれるデストラクタに任せれば良いということです。
例えば、fstreamやstd::stringもこれらに該当します。
fstreamは内部でファイル・ハンドルをオープンしています。std::stringも内部でヒープ・メモリを獲得しています。そして、fstreamやstd::stringのインスタンスを定義しているブロックから抜ける時、それらのリソースも自動的に解放されます。
Bazクラスを作って確認してみましょう。
#include <iostream> #include <exception> void foo() { return; } void bar() { throw std::runtime_error("Error in bar()"); } struct Baz { int mData; Baz(int iData) : mData(iData) { std::cout << " Baz(" << mData << ")\n"; } ~Baz() { std::cout << "~Baz(" << mData << ")\n"; } }; void process() { { Baz baz0(123); foo(); Baz baz1(456); bar(); Baz baz2(789); std::cout << "Normal Processes\n"; } std::cout << "Block ended.\n"; } int main() { try { process(); } catch(std::exception& e) { std::cerr << "main() : " << e.what() << "\n"; return 1; } return 0; }
Baz(123) Baz(456) ~Baz(456) ~Baz(123) main() : Error in bar()
関数bar()の中で例外が発生します。この時、baz0, baz1は生成されており、baz2はまだ生成されていません。その状態で「例外」によりprocess()関数の内側のブロックから抜けてmain()関数のcatch()に飛んでいきます。ですので、baz0, baz1は自動的に解放され、生成前のbaz2は解放されません。
このように、リソースを適切にデストラクタで解放すれば例外でどんと戻ってもリソース・リークしないのです。
ついでに、リソースの確保をコンストラクタで行えば対応も取れてキレイですね。この構造はRAIIと呼ばれています。
4.まとめ
このように例外は本当に便利です。そして、下記のように考えることをお勧めします。
1. 発生したエラーの内容を受け取るのはtry-catch
2. リソースの獲得と解放をやり損なわないようにするにはRAIIを使う
C++についてよく分かっていなかった時、私は2.のリソースの解放をtry-catchでやってました。
それはもう面倒で何故try-finallyが無いんだって嘆いていました。
でも、RAIIを使えばtry-finallyは必要ないんだと判ってからは、正反対でした。
もうtry-finallyみたいな面倒なことはしたくないです。
さて、こんなに便利な例外とRAIIなのですが、実は落とし穴もあります。
デストラクタでエラーを検出するような設計をしてはいけないのです。
次回、その辺を解説します。是非お見逃しなく。