C++ で例外処理を実装する - C++ プログラミング

PROGRAM


C++ で例外を使う

プログラムでの「例外」というのは、「普通ならその結果が得られることはない」とか「その値だとその後の処理に支障をきたす」など、今のままではそれ以上処理を続けても正しい結果を得られないような状況にあるエラーのことを言います。

 

例外が発生する場面として有名なのが「ある値を 0 で割る」という処理です。

double divide(double a, double b)

{

return a / b;

}

数学では 0 で割った値は「未定義」とされているので、ある値を 0 で割る状況になったとき、それ以上の計算ができなくなります。

もちろん「0 で割ったときは 0 とする」とか「0 で割ったときは -1 とする」とか、0 で割ろうとしたときに採用する値を決めてしまえば、それは「未定義」ではなくなるので、例外の場面はプログラムの想定によって変わってきます。

 

例外を使うメリットは、正常系と異常系の処理を分離できるところにあります。

プログラマは、例外となる場面に直面した時に throw キーワードを使って例外が発生したことをコンパイラに通知します。これによって、それ以上の判断をプログラムの上流に委ねることができます。

発生した例外は、そのままさらに上流へ判断を委ねたり、catch キーワードで捕捉して適切な対処をして正常系に戻すこともできます。

 

例外を発生させる

プログラム中で、これ以上の継続が困難な場面に遭遇したとき、例外を発生させることで、それをプログラムの上流に知らせることができます。

double divide(double a, double b)

{

// 例えば 0 で割ろうとしたときに std::range_error 例外を発生させます。

if (b == 0)

{

throw std::range_error("Divided by zero.");

}

 

return a / b;

}

このように、例外にしなければいけない状況を判定して、そのときに throw キーワードに続けて、例外として送出する値を指定します。

例外として送出する値は int でも const char* でもクラスでも、なんでもいいのですが、ここでは C++ の標準で用意されている std::range_error クラスを指定しています。

このクラスでは、コンストラクタの引数に「理由」を添えられるので、それも合わせて指定しています。

 

例外を throw で発生させると、それ以降の処理は実行されずに、呼び出し元に処理が戻ります。

呼び出し元では、例外を捕捉して問題を解消するか、そのまま処理を中断してさらに上流のプログラムに例外を伝えることになります。

 

例外を捕捉する

例外を捕捉する

発生した例外は、try-catch を使って捕捉することができます。

try

{

// try ブロックで括ることで、その中で発生した例外を捕捉することができます。

value = divide(100, 0);

}

catch (std::range_error& exception)

{

// try ブロック内で発生した例外のうち、std::range_error クラスまたはそこから派生したクラスが送出された例外を捕捉します。

value = 0;

}

try ブロック内で発生した例外だけが、それに続く catch ブロックで捕捉できます。

この cache キーワードのところで、どの型の例外が送出された場合に捕捉するかを指定できるので、想定する例外についてだけ処理を行うことが可能です。このとき、捕捉する例外がクラスの場合は、参照で捕捉するようにすると効率的です。

複数の種類の例外を想定して、それぞれに応じた処理をしたい場合は、catch ブロックを続けて複数書くことで対応できます。

 

ちなみに try ブロック内で、自分で throw キーワードを使って例外を発生させた場合も、それに続く cache ブロックでそれを捕捉することができます。

 

他にも cache キーワードに "..." を渡すことで、全ての例外を捕捉することもできます。

try

{

value = divide(100, 0);

}

catch (...)

{

// "..." を指定すると try ブロック内で発生した全ての例外を捕捉します。このとき、例外を変数で受けることはできません。

value = 0;

}

ただしこのとき、発生した例外を変数に受けることができないため、発生した例外の値を使った処理は行えません。

 

捕捉した例外を再送出する

例外を捕捉した後、それに対処する処理を行った後で、同じ例外を上流のプログラムに委ねたい場合があります。

たとえば、例外が発生したらエラーログだけ記録して、その後の判断は上流に任せてしまいたい場合です。

 

そんなときには、例外を捕捉した catch ブロック内で、throw キーワードを引数なしで使用します。

try

{

value = divide(100, 0);

}

catch (std::range_error& exception)

{

// 捕捉した例外を再送信します。

throw;

}

このようにすることで、例外をそのまま上流に投げることができます。

 

最後に必ず実行する処理

try ブロック内で例外が発生すると、ブロック内のそれ以降の処理がスキップされて catch ブロックの処理に移ります。逆に例外が発生しなければ catch ブロックは実行されません。

このとき、たとえば関数内でメモリを確保していたりすると、例外が発生しようとしまいと、最後にそれを解放してく必要があるのですけど、例外が発生した場合とそうでない場合と、2 箇所でそういう配慮をしないといけないところが厄介です。

 

そんなとき、言語によっては例外が発生しようとしまいと最後に実行される finally ブロックを使ったりとかするのですけど、残念ながら C++ には finally ブロックがありません。

そのため、メモリの解放などであれば、あらかじめ スマートポインタ を使っておくのがいちばん適切なのかもしれません。

// メモリをスマートポインタで管理するようにします。

std::unique_ptr<double[]> source(new double[2]);

 

try

{

// もし途中のどこかで例外が発生しても、スコープを抜ける時にスマートポインタが解放されます。

value = divide(source[0], source[1]);

}

catch (std::range_error& exception)

{

// たとえばここで例外を再送出しても、スコープを抜けたときにスマートポインタが解放されます。

throw;

}

 

// スマートポインタで管理しているので delete は不要です。

こうすることで、例外が発生したかどうかにかかわらず、確保したメモリを正しく解放することができます。

 

ただ、このようにしても finally ブロックとは違って、出来ることはあくまでもスマートポインタが扱える範囲での処理に限られます。

他にも ラムダ関数 を応用して scope exit という考え方の実装もあるみたいなのですけど、それについてはまだ概要も何もまったく捉えられていないので、それについては機会が巡れば、また調べてみようと思います。

ちなみに Microsoft Visual C++ には __try - __finally という構文があったりしますけど、Xcode とかでは通用しない構文です。

 

C++ で使える標準の例外クラス

C++ では、次の例外クラスが標準で用意されています。

これらの例外クラスを使う場合は <exception> ヘッダーをインクルードする必要があります。

  • std::exception
    全ての標準の例外クラスの基底になる例外クラスです。
    • std::logic_error
      プログラムの論理的な理由によって例外が発生したことを通知します。
      • std::invalid_argument
        引数の値が受け入れられないことを通知します。
      • std::domain_error
        未定義域の値が引数に渡されたことを通知します。
      • std::length_error
        扱えるデータ長を超えて処理が行われたことを通知します。
      • std::out_of_range
        定義された範囲を超えてアクセスされたことを通知します。
      • std::future_error
        (C++11) std::future や std::promise などの非同期で例外が発生したことを通知します。
    • std::runtime_error
      実行時エラーが発生したことを通知します。
      • std::range_error
        演算処理中で有効な範囲を超えて計算されたことを通知します。
      • std::overflow_error
        演算処理中でオーバーフローが発生したことを通知します。
      • std::underflow_error
        演算処理中でアンダーフローが発生したことを通知します。
      • std::system_error
        (C++11) std::thread などの OS がらみの関数でシステムエラーが発生したことを通知します。
        • std::ios_base::failure
          (C++11) 入出力ライブラリでエラーが発生したことを通知します。
    • std::bad_typeid
      typeid 演算子を nullptr や polymorphic type に適用しようとしたことを通知します。
    • std::bad_cast
      クラス参照を ダイナミックキャスト でキャストできなかったことを通知します。
    • std::bad_weak_ptr
      (C++11) スマートポインタ の std::weak_ptr を std::shared_ptr に取り出そうとしたときに、std::weak_ptr が管理するポインタが既に解放されていたことを通知します。
    • std::bad_function_call
      (C++11) ラムダ関数 などを格納する std::function<> を、適切な関数を設定しないまま呼び出そうとしたことを通知します。
    • std::bad_alloc
      new 演算子などでメモリの確保に失敗したことを通知します。
      • std::bad_array_new_length
        (C++11) new 演算による配列確保で、要素数が不正だったり、初期化で使う要素数が多すぎることを通知します。
    • std::bad_exception
      想定されている者とは違う例外が発生したことを通知します。

例外を throw キーワードで発生させるときには、これらのクラスかそれを派生したクラスを使うと、呼び出し元に例外の発生状況を伝えやすくなります。

 

独自の例外クラスを作成する

throw キーワードでは任意の値を例外として送出できるので、自分で作成した独自クラスの送出も可能です。

このとき、送出する独自の例外クラスは、C++ に標準で用意されている例外クラスの中から適切な意味のものを 1 つ選んで継承しておくと、例外が送出された先のプログラムが例外を捕捉しやすくなります。

標準の C++ 例外クラスを継承する

たとえば、メモリ確保に失敗した場合だけ捕捉して処理を追加したいようなとき、発生した例外が独自クラスでも std::bad_alloc を継承しておいてくれさえすれば、catch キーワードで std::bad_alloc& を捕捉するだけで処理できます。

// std::bad_alloc から派生することで、std::bad_alloc& としても捕捉できます。

class CEzDataBlockAllocationException : public std::bad_alloc

{

// 特別な実装が必要なければ、最低限の実装は継承元でされているので、実装は何も要りません。

}

こうすることで、混乱しにくく扱いやすい例外クラスを作成することができます。

 

例外クラスにグループ的な意味合いも持たせたい場合

ただ、場合によっては、独自の例外クラスだった場合にだけ例外処理をしたいこともあるかもしれません。

たとえば CEzDataBlock というクラスがあったとして、それの機能を使う上で、CEzDataBlock 固有の例外が発生したときにだけ何か処理をしたい場合です。

 

このとき、たとえば CEzDataBlockException という例外クラスを作成して、さらにそれを継承して CEzDataBlockAllocationException とか、CEzDataBlockOperationException とか、意味を含めた例外クラスを作成することで、CEzDataBlockException& を使ってこれらすべてを捕捉することはできます。

// たとえば、基本になる例外クラスを std::exception から派生します。

class CEzDataBlockException : public std::exception { };

 

// その他の詳細を、基本になる例外クラスだけから継承すると、std::bad_alloc といった標準の意味を持たせられなくなります。

class CEzDataBlockAllocationException : public CEzDataBlockException { };

class CEzDataBlockOperationException: public CEzDataBlockException { };

ただ、別の場所で CEzDataBlock の機能に限らず、メモリ確保で例外が発生したときに処理をしたいコードがあったときには、このままだと std::bad_alloc& と CEzDataBlockAllocationException& の両方を捕捉しなければいけなくなります。

 

このどちらでも、まとめて捕捉できるようにしたい場合は、多重継承を使う方法が考えられます。

// 基本になる例外クラスは、多重継承の関係上 std::exception からは派生できません。

class CEzDataBlockAnyException { };

 

// その他の詳細な例外クラスは、基本になる例外クラスと C++ 標準クラスの両方を多重継承します。

class CEzDataBlockException : public CEzDataBlockAnyException, public std::exception { };

class CEzDataBlockAllocationException : public CEzDataBlockAnyException, public std::bad_alloc { };

class CEzDataBlockOperationException: public CEzDataBlockAnyException, public std::logic_error { };

このようにすることで、C++ 標準のクラスを使って例外を捕捉することもできますし、独自の CEzDataBlockAnyException クラスを使って捕捉することもできるようになります。

 

ただ、多重継承の制約上、基本になる独自の例外クラスでは C++ の標準例外を継承することができません。

これは、C++ の例外クラスが全て std::exception を継承しているのですけど、これが virtual 指定になっていないため、独自の基本の例外クラスでも std::exception を継承してしまうと、それと他の std::bad_alloc などの標準クラスが継承した std::exception とが衝突してしまうためです。

 

そのため、基本になる例外クラス "CEzDataBlockAnyException" は std::exception では捕捉できなくなるので、使う場合は少し注意が必要です。

基本になる例外クラスにダミーの純粋仮想関数を protected 指定で実装したりして、派生先でそれを実装することで、間違って基本になる例外クラスをインスタンス化して使うことができなくなるので、そうやってミスをしにくくすることはできます。

// 基本になる例外クラスは、多重継承の関係上 std::exception からは派生できません。

class CEzDataBlockAnyException

{

protected:

// protected で純粋仮想関数を作っておけば、外部に影響することもないし、このクラスのインスタンスを作成することはできなくなります。ただし、派生先でこの関数を必ず実装しないといけない手間が生まれます。

virtual void dummy() = 0;

};

 

// 派生する例外クラスでは、ダミーの仮想関数をオーバーライドしておくことで、インスタンス化できるようになります。

class CEzDataBlockException : public CEzDataBlockAnyException, public std::exception

{

protected:

void dummy() override { };

}

果たしてここまでする価値があるかどうかは、例外エラーをどのように捕捉したいか等によって違ってくると思います。

そんな辺りに注意しながら、どのような例外クラスを使って行ったらいいかを考えて行く必要がありそうです。

 

発生し得る例外を明記する

関数に発生し得る例外を明記する

例外を発生させるときには、throw を使って任意の値を例外として送出することができますが、呼び出し元のプログラムでは、どのような例外が発生し得るのかを知っておきたいところです。

そのような場合でも、例外が発生し得る関数の定義と実装の両方で throw キーワードを使えば、発生し得る例外を明記することができます。

double divide(double a, double b) throw (std::range_error);

このようにすることで、原則として明記された例外だけが、この関数から送出されることになります。

 

複数の例外を送出する可能性がある場合は、それらをカンマ区切りで指定します。

double divide(double a, double b) throw (std::range_error, std::invalid_argument);

これで、この関数からはこれらの例外が発生される可能性を明記できました。

もちろん、何も例外が発生されない場合も OK です。

 

明記されていない例外が送出されようとした場合

これらのとき、内部で、明示した例外とは違う例外が発生したとします。

そのときには、呼び出し元がどの catch ブロックを使っても、呼び出し先でたとえ catch (...) として全ての例外を捕捉するようにしていても、明示されていない例外を補足することはできません。

 

これは、明記されていない例外が関数から送出されようとしたときに、関数の最後で std::unexpected 関数が呼び出されるためです。

そして、この関数の中ではディフォルトで std::terminate 関数が呼ばれることになっているそうです。そのため、この時点でプログラムの強制終了されることになります。

これはきっと、予期できない例外が発生したのだからそれ以上の復帰は望めないだろう、というような判断になっているのでしょう。

 

明記されていない例外についても何か処理をしたい場合には、std::set_unexpected 関数を使って、明示されていない例外を送出しようとしたときに実行する関数を登録することができます。

ただし、登録できる関数では、発生した例外を引数で受け取ることはできません。

// std::set_unexpected に渡す関数は、引数を取らない関数になります。

void unexpectedHandler();

このように定義した関数を、main 関数やどこか適切な場所で指定します。

// std::set_unexpected に関数を渡すと、明示されていない例外が発生したタイミングで、その関数が呼ばれるようになります。

std::set_unexpected(unexpectedHandler);

この std::set_unexpected をどこかで実行することで、それ以降に明示されていない例外が発生した全てのタイミングで、ここで指定した関数が呼び出されるようになります。

また、どこかで std::set_unexpected を実行すると、それ以降はここで指定した関数が呼ばれるようになります。

 

明記されていない例外から復帰する

std::set_unexpected 関数で設定したハンドラ関数が呼ばれた後、特に何もしないとそのまま std::terminate 関数が呼び出されて、プログラムが強制終了することになります。

ここからプログラムに復帰したい場合には、例外を送出した関数が明記している例外を、このハンドラ関数内で throw する必要があります。

そうすることで、呼び出し元に明示されている例外が送出されて、プログラムが続行できます。

 

このとき、引数無しで throw を実行すると std::bad_exception 例外が送信されます。

このときにも、例外が発生したそもそもの関数で、発生し得る例外としてこの std::bad_exception が明記されている必要があります。


[ もどる ]