C++での例外処理について

(2005.4.3) 新規公開

(2017.6.18) 最近の言語仕様に更新. 大幅に加筆。

C++には例外機構が備わっているが、C言語との互換性を確保するためなのか、不可解な動作をするところが多い。

例外を送出しない関数宣言 -- 動的例外指定

(この節は、2000.6.25の日記に加筆したもの。)

関数(またはメソッド)宣言で throw () を付けると, その関数からは例外を投げないという意味になる。にもかかわらず、中で, 例外を投げることができる。

次のソースコードは、関数 f() のなかで例外を投げる。gcc 3.4.2 (Fedora Core 3) では、コンパイル時にエラーも警告も出ない。

[2017-06] gcc 6.3.1 (Fedora 25 Linux) では, f() throw() では警告が出ないが, f() noexcept で、かつブロック内に直接 throw 文があるときには警告が出る。

C++
[RAW]
  1. #include <exception>
  2. #include <cstdio>
  3. class E: public std::exception {
  4. // ここの noexcept は必須. 付けないとコンパイルエラー
  5. virtual const char* what() const noexcept { return "my exception"; }
  6. };
  7. // f() throw() と書くと, 何も警告すら出ない
  8. // noexcept だと, gcc 6.3.1 では警告:
  9. // 警告: throw will always call terminate() [-Wterminate]
  10. // void f() noexcept { throw E(); }
  11. // ^
  12. void f() noexcept { throw E(); }
  13. void h() { throw E(); }
  14. // これは警告が出ない.
  15. void g() noexcept { h(); }
  16. int main() {
  17. try {
  18. g();
  19. } catch (E& e) {
  20. printf("exception E thrown.\n");
  21. } catch (...) {
  22. // 例外を送出する前に失敗するので, catch できない
  23. printf("something thrown.\n");
  24. }
  25. return 0;
  26. }

これを実行すると、例外が捕捉されず、プログラムが終了 (abort) する。

$ ./a.out 
terminate called after throwing an instance of 'E'
  what():  my exception
Aborted (コアダンプ)

C++では、関数宣言に throw (, ...) (動的例外指定という。) または noexcept を付けないと, どのような例外も投げることができる。ある関数が, 潜在的に例外を投げうる (potentially-throwing) 関数を呼び出していると、その呼び出すほうの関数もあらゆる例外を送出する可能性がある。

逆に, 例外を投げるコードがあっても, 実際に例外を投げうるのかをコンパイル時に確実に判定することは難しい。そのため, 動的例外指定に列挙していない例外を投げるような字面でも, コンパイルエラーにはならない。上の例のように, 例外を投げるコードの有無で, 警告を出すのがせいぜい。

実際の動作だが、動的例外指定にない例外を送出しようとすると、実行時に, std::unexpected() が内部で呼び出される。デフォルトの動作は std::terminate() を呼ぶようになっている。noexcept に反した場合は, いきなり std::terminate() が呼び出される。

std::terminate() は, プログラムを abort させる。結局, 終了することになる。

一応, std::set_unexpected() std::set_terminate() でハンドラを変更することができる。次のようにすると、自分のハンドラが呼び出されるようになる。

C++
[RAW]
  1. #include <exception>
  2. #include <iostream>
  3. // 指定した型と異なる型の例外を投げる例.
  4. // => std::unexpected() が呼び出される。
  5. // なお, f() noexcept の場合は, 違反した場合, std::unexpected() ではなく, 直接
  6. // std::terminate() が呼び出される
  7. void throwable() throw(int /*, std::bad_exception */) {
  8. throw 1.0;
  9. }
  10. void my_unexpected_handler() {
  11. std::cout << "my unexpected handler\n";
  12. // これをイキにすると, throw(...) に列挙されている型なので, 処理が続行し,
  13. // catch できる.
  14. //throw 1;
  15. // 列挙されていない型を投げると, bad_exception に差し替わる
  16. throw "hoge";
  17. }
  18. int main() {
  19. // std::unexpected() のハンドラを変更する
  20. std::set_unexpected( my_unexpected_handler );
  21. // std::terminate() のハンドラ.
  22. // abort を止めることはできない.
  23. std::set_terminate([](){ std::cout << "my terminate handler\n"; });
  24. try {
  25. throwable();
  26. } catch (std::bad_exception& e ) {
  27. std::cout << e.what() << "\n";
  28. } catch (...) {
  29. std::cout << "caught!\n";
  30. }
  31. return 0;
  32. }

unexpectedハンドラは、次のように動く;

  1. 例外を投げない場合, 元の例外の取り回しが続行
    => それは元の throw(...) に違反なので, std::terminate() に進む
  2. このハンドラから例外を投げた場合,
    1. 例外発生元の関数の throw(...) に列挙されている型なら, 最初からそれが投げられたかのように、例外が取り回わされる
    2. throw(...) に列挙されていない場合, std::bad_exception にさらに差し替わる. その上で, bad_exception が列挙されていない場合, やっぱり terminate().

しかし実際問題, このハンドラで何か意味のある処理をさせるのはできない。

unexpected ハンドラの内部では, 一体どこでどのような例外が発生したために自分が呼び出されたのかを知るすべがない。さらに、ハンドラ内で何か例外を投げないと、元の例外が再び送出されてabortしてしまう。かといって元の関数で許可されていない例外を投げると std::bad_exception を投げたものと見なされ、std::bad_exception も許可されていないと, やっぱりabortしてしまう。

結局、何かログを取るくらいが関の山で、関数宣言で std::bad_exception も指定されていることを期待してそれを投げるぐらいしかできない。

元の関数に焦点を合わせると、やはり, 列挙している型以外の例外が送出されることはありえない、ということになる。

例外安全性

エラー処理の方法はいろいろ考えられる。

方法 難点
戻り値によってエラーかどうか (-1とかナル値とか), あるいはエラーコードを返す。 エラーコード以上のことが分からない.
オブジェクトの状態を変化させ, 別のメソッドで問い合わせる. エラー処理の書き忘れ
Eitherを返す
例外
モナド

例外安全性の水準

例外によってエラー処理をおこなう場合, 例外安全性を満たす必要がある。

例外安全性は,次の3つの水準がある。

基本保証 (Basic Guarantee)
例外が発生しても, (1) リソースのリークを発生させない。(2) オブジェクトの内部状態の整合性が保たれる。

上位レイヤーが関連リソースにアクセスできない場合、関連リソースを解放する責任がある。この水準は、常に満たす必要がある。

整合性さえ取れていればいいので, 例えば, 10個の要素をコンテナに追加するメソッドの中で例外が発生したとき、コンテナにまったく追加されていないかもしれないし、5個追加されているかもしれない。

C++での実装: RAIIイディオム (Resource Acquisition Is Initialization) などを活用。

強い保証 (Strong Guarantee)
例外が発生した場合に, 手続きをロールバックし、呼び出しによる副作用をもたらさない。つまり、完全に成功するか無効かのいずれかになる。

上の例では、必ず、コンテナに何も挿入されていない状態にならなければなりません。

IOなど、原理的に「強い保証」を満たせない状況もある。出力済みのものは巻き戻せない。

no-fail保証 (No-fail Guarantee)
例外を発生しない、というより、処理が必ず成功する。

注意したいのは、単に例外を発生させなければいい、というわけではない。例えば, C++ の ofstream はファイルを開くのに失敗しても例外を発生しない。例外は発生しないが, 処理としては no-fail ではない。

可能な場合は「強い保証」を満たすようにするのが望ましいが、そんなに簡単ではない。

エラーからの回復

より下のレイヤーから上がってきた例外への対処。※ネット上で「例外回復」という用語を見かけたが、そういう言い方はしないように思う。

単に、次を満たすようにすればいい。

  • 自分で対処できるエラーは処理する。対処・解決できないエラーは、上位レイヤーに伝えるようにする。

例外に当てはめると, 自分で対処できる例外のみ catch し、処理する。握りつぶす (再送しない)。対処・解決できない例外は, そのままか, 再送によって上位レイヤーに伝えるようにする。これは「例外中立」と呼ばれる。

catchで何でもかんでも捕捉しないようにするのが肝要。型を特定し、内容を確認する。

Note.

例外は catch するか、catchして再送するか、突き抜けるか, の3択。プログラミング言語によって finally があったりなかったり。C++にはない。C#にはある。

Ruby には例外が発生したbegin節の最初からやり直す命令 (retry) があるが、珍しい。継続と, 継続を取り出す機能 call-with-current-continuation (call/cc) が必要。

swap関数

C++11 での __cplusplus の値は 201103L. 余談。

C++は、変数がオブジェクトの領域を確保する。swap ですら例外を投げかねないという, 特有の難しさがある。

noexceptとなる条件について。

std::swap関数は, 型Tがムーブ構築可能かつムーブ代入可能な場合のみ, 定義される。<utility> ヘッダ (C++11以降) または <algorithm>ヘッダ.

gcc 6.3.1 (libstdc++) の実際の定義は, 次のようになっている。

C++
[RAW]
  1. /**
  2. * @brief Swaps two values.
  3. * @param __a A thing of arbitrary type.
  4. * @param __b Another thing of arbitrary type.
  5. * @return Nothing.
  6. */
  7. template<typename _Tp>
  8. inline
  9. typename enable_if<__and_<is_move_constructible<_Tp>,
  10. is_move_assignable<_Tp>>::value>::type
  11. swap(_Tp& __a, _Tp& __b)
  12. noexcept(__and_<is_nothrow_move_constructible<_Tp>,
  13. is_nothrow_move_assignable<_Tp>>::value)
  14. {
  15. // concept requirements
  16. __glibcxx_function_requires(_SGIAssignableConcept<_Tp>)
  17. _Tp __tmp = _GLIBCXX_MOVE(__a);
  18. __a = _GLIBCXX_MOVE(__b);
  19. __b = _GLIBCXX_MOVE(__tmp);
  20. }
  21. // _GLIBCXX_RESOLVE_LIB_DEFECTS
  22. // DR 809. std::swap should be overloaded for array types.
  23. /// Swap the contents of two arrays.
  24. template<typename _Tp, size_t _Nm>
  25. inline
  26. typename enable_if<__is_swappable<_Tp>::value>::type
  27. swap(_Tp (&__a)[_Nm], _Tp (&__b)[_Nm])
  28. noexcept(__is_nothrow_swappable<_Tp>::value)
  29. {
  30. for (size_t __n = 0; __n < _Nm; ++__n)
  31. swap(__a[__n], __b[__n]);
  32. }

is_move_constructible<T> 構造体は, 型Tがムーブ構築可能かどうか. is_move_assignable<T> 構造体は, 型Tがムーブ代入可能か.

C++
[RAW]
  1. #include <utility>
  2. #include <iostream>
  3. struct S {
  4. int v;
  5. // 1. 次のいずれかによってメンバ関数を削除すると, コンパイルエラーになる.
  6. // S() = default; // ムーブ構築子をdeleteすると道連れ
  7. // S(S&& ) = delete;
  8. //
  9. // S& operator =(S&&) = delete;
  10. // 2. 例外を送出しうるムーブコンストラクタまたはムーブ代入演算子を陽に定義
  11. // すると, 下の noexcept式が false を返す
  12. // S() = default;
  13. // S& operator=(S&& ) = default;
  14. // S(S&& x): v(x.v) { } // これは noexcept(false)
  15. //
  16. // S()= default;
  17. // S(S&& ) = default;
  18. // S& operator =(S&& x) { // これは noexcept(false)
  19. // v = x.v;
  20. // return *this;
  21. // }
  22. };
  23. int main() {
  24. S s, t;
  25. s.v = 1; t.v = 2;
  26. // swap() の定義ではコピーコンストラクタとコピー代入演算子があれば実現可能
  27. // に見えるが, ムーブコンストラクタ, ムーブ代入演算子がないと、使えないよう
  28. // になっている.
  29. std::swap(s, t);
  30. std::cout << "noexcept? = " << noexcept(std::swap(s, t)) << "\n";
  31. std::cout << "s.v = " << s.v << ", t.v = " << t.v << "\n";
  32. return 0;
  33. }

コンストラクタで例外を発生させない

C++では, コンストラクタで例外が発生すると、オブジェクト(インスタンス)の生成が完了せず、デストラクタも呼ばれない。

次のソースをコンパイルして実行すると、Cクラスのデストラクタが呼ばれない。この例ではabortするが、main()で例外を捕捉するようにしても同じ。

C++
[RAW]
  1. #include <cstdio>
  2. struct St { int v; };
  3. struct C {
  4. St* ptr;
  5. C() {
  6. ptr = new St();
  7. throw 1; // 例外!!
  8. }
  9. virtual ~C() { // 呼び出されない
  10. printf("C dtor\n");
  11. delete ptr; ptr = nullptr;
  12. }
  13. };
  14. int main() {
  15. C c;
  16. return 0;
  17. }

実行結果.

$ ./a.out 
terminate called after throwing an instance of 'int'
Aborted (コアダンプ)

コンストラクタでは例外が発生しないようにコーディングするか、例外が発生しても資源を適切に解放する(メソッドへの進入時点まで巻き戻す)ようにしなければならない。