M.Hiroi's Home Page

お気楽C++プログラミング超入門

中級編 : 例外処理


Copyright (C) 2015-2023 Makoto Hiroi
All rights reserved.

はじめに

一般に、「例外 (exception)」はエラー処理で使われる機能です。「例外=エラー処理」と考えてもらってもかまいません。最近は例外処理をサポートしているプログラミング言語が多くなりました。もちろん、C++にも例外処理があります。なお、エラーが発生したことを「例外が発生した」とか「例外が送出された」という場合もあります。本ページでもエラーのことを例外と記述することにします。

●例外の捕捉

通常、例外が発生するとC++はプログラムの実行を中止 (異常終了) しますが、致命的な例外でなければプログラムの実行を継続する、または特別な処理を行わせたい場合もあるでしょう。このような場合、例外処理がとても役に立ちます。

C++の例外処理は try, catch, throw を使います。try に続くブロック { } 内で例外が送出された場合、そのあとに続く catch でその例外を捕捉することができます。例外を送出するには throw を使います。

try 文の構文を下図に示します。

try {
  処理A;
}
catch (データ型 引数) {
  処理B;
}

図 : 例外処理

try 文は、そのあとに定義されている処理 A を実行します。処理 A が正常に終了した場合は try 文も終了します。もしも、処理 A で例外が発生した場合、処理 A の実行は中断され、その例外が catch 節で指定した例外と一致すれば、その catch 節を実行します。

catch ( ) { } を例外ハンドラ (exception handller) と呼びます。例外ハンドラは必ず try { } の直後に定義してください。例外ハンドラは複数定義することができます。catch のあとの ( ) には、例外として送出されるデータ型やクラスを指定します。なお、指定するデータ型はポインタや参照型でもかまいません。

●例外の送出

例外は throw で送出することができます。

throw 式;

throw が実行されるとプログラムの実行を直ちに中断して、例外を受け止める catch 節を探索します。見つけた場合はそこに制御を移します。このとき、throw で指定した式の評価結果が catch 節の変数に渡されます。該当する catch 節が見つからない場合、標準ライブラリ関数 terminate を呼び出してプログラムを異常終了します。

簡単な例を示しましょう。

リスト : 例外処理の使用例 (sample140.cpp)

#include <iostream>
using namespace std;

class Foo { };
class Bar { };

int foo(int a)
{
  switch(a) {
  case 0:
    throw 0;
  case 1:
    throw 1.2345;
  case 2:
    throw "oops!";
  case 3:
    throw Foo();
  default:
    throw Bar();
  }
}

void bar(int a)
{
  try {
    foo(a);
  }
  catch (int e) {
    cerr << "Error: " << e << endl;
  }
  catch (double e) {
    cerr << "Error: " << e << endl;
  }
  catch (char const* e) {
    cerr << "Error: " << e << endl;
  }
  catch (Foo e) {
    cerr << "Error: Foo" << endl;
  }
}

int main()
{
  for (int i = 0; i < 5; i++)
    bar(i);
}
$ clang++ sample140.cpp
$ ./a.out
Error: 0
Error: 1.2345
Error: oops!
Error: Foo
terminate called after throwing an instance of 'Bar'
中止

関数 foo で例外を送出し、関数 bar で例外を捕捉します。C++の例外は、try 文の中で呼び出した関数の中で例外が送出されても、関数の呼び出し履歴 (コールスタック) を遡って catch 節を探索し、該当する例外を捕捉することができます。

throw はどんなデータでも例外として送出することができます。throw 0 は catch 節の int e で捕捉することができます。このとき、変数 e の値は 0 になります。同様に、throw 1.2345 は double e で、throw "oops!" は char const* e で、throw Foo() は Foo e で捕捉することができます。なお、catch 節 で Foo& e のように参照型にしても、throw Foo() を捕捉することができます。

最後に、foo で throw Bar() を実行すると例外が送出されますが、bar では Bar のインスタンスを受け取る catch 節が定義されていません。この場合、例外は捕捉されずにプログラムは異常終了します。

なお、catch (...) { } のようにデータ型に ... を指定すると、あらゆる例外を捕捉することができます。このとき、引数を指定することはできません。それから、catch 節の中で引数なしの throw を実行すると、同じ例外を再送出することができます。

●大域脱出

例外処理を使うと、評価中の関数からほかの関数へ制御を移す「大域脱出 (global exit)」を実現することができます。

簡単な例を示しましょう。

リスト : 大域脱出 (sample141.cpp)

#include <iostream>
using namespace std;

class GlobalExit {};

void bar1()
{
  cout << "call bar1\n";
}

void bar2()
{
  cout << "call bar2\n";
  throw GlobalExit();
}

void bar3()
{
  cout << "call bar3\n";
}

void foo()
{
  bar1(); bar2(); bar3();
}

int main()
{
  try {
    foo();
  }
  catch (GlobalExit e) {
    cout << "Global Exit\n";
  }
}

try 文で関数 foo() を実行すると、次のようになります。

$ clang++ sample141.cpp
$ ./a.out
call bar1
call bar2
Global Exit

実行の様子を下図に示します。

 ┌───────┐
 │try { ... }   │←─┐
 │catch { ... } │    │
 └───────┘    │
        ↓             │
 ┌──────┐      │
 │   foo()    │──┐│
 └──────┘    ││
       ↓↑          ↓│
 ┌──────┐  ┌ bar2() ──────┐ 
 │  bar1()    │  │throw GlobalExit(); │
 └──────┘  └──────────┘

            図 : 大域脱出

通常の関数呼び出しは、呼び出し元の関数に制御が戻ります。ところが bar2() で throw が実行されると、呼び出し元の関数 foo() を飛び越えて、制御が try 文の catch 節に移るのです。このように、例外処理を使って関数を飛び越えて制御を移すことができます。

なお、大域脱出はとても強力な機能ですが、多用すると処理の流れがわからなくなる、いわゆる「スパゲッティプログラム」になってしまいます。例外処理はあくまでもエラーを処理するために使ったほうがよいでしょう。

●例外クラス

C++の場合、throw はどんなデータ型でも例外として送出することができますが、通常は標準ライブラリ <stdexcept> に定義されている例外クラス logic_error または runtime_error の派生クラスを使用します。これらの例外クラスは <excpetion> に定義されている例外クラス exception を継承しています。例外クラスは次に示すような階層構造になっています。

  exception
  ├logic_error
  │├length_error
  │├domain_error
  │├out_of_range
  │└invalid_argument
  │
  ├runtime_error
  │├range_error
  │├overflow_error
  │└underflow_error
  │
  ├bad_alloc
  ├bad_cast
  ├bad_exception
  └bad_typeid

図 : exception の階層図

参考文献『プログラミング言語C++ 第 3 版』によると、『logic_error は、原則として、プログラムが実行を開始する前か、関数、コンストラクタの引数をテストしたときに見付かるエラーであり、runtime_error はその他すべてのエラーである。』 とのことです。

logic_error と rumtime_error のコンストラクタは文字列を受け取ることができます。その内容はメンバ関数 what で表示することができます。簡単な例を示しましょう。

リスト : 例外クラスの使用例 (sample142.cpp)

#include <iostream>
#include <stdexcept>
using namespace std;

void foo(int n)
{
  switch (n) {
  case 0:
    throw runtime_error("foo runtime error");
  case 1:
    throw logic_error("foo logic error");
  default:
    throw "oops!";
  }
}

int main()
{
  for (int i = 0; i < 3; i++) {
    try {
      foo(i);
    }
    catch (const exception& e) {
      cout << e.what() << endl;
    }
    catch (...) {
      cout << "other error\n";
    }
  }
}
$ clang++ sample142.cpp
$ ./a.out
foo runtime error
foo logic error
other error

関数 foo で例外を送出して、main で例外を捕捉します。logic_error と runtime_error は exception を継承しているので、どちらの例外も exception& e で捕捉することができます。C++の場合、例外クラスは "const 例外クラス&" のように参照型で受け取るのが一般的なようです。throw "oops!" は文字列なので exception を継承していませんが、次の catch 節はすべてのエラーを補足するので、ここで送出された文字列 "oops!" が捕捉されます。

●例外クラスの定義

例外クラスは他の例外クラスを継承することで、ユーザーが独自に定義することができます。通常は logic_error か runtime_error を継承するといいでしょう。次の例を見てください。

リスト : 例外クラスの定義 (sample143.cpp)

#include <iostream>
#include <stdexcept>
using namespace std;

class foo_error : public runtime_error {
public:
  foo_error(const string& s) : runtime_error(s) { }
};
  
void foo(int n)
{
  switch (n) {
  case 0:
    throw runtime_error("foo runtime error");
  case 1:
    throw logic_error("foo logic error");
  default:
    throw foo_error("foo error oops!");
  }
}

int main()
{
  for (int i = 0; i < 3; i++) {
    try {
      foo(i);
    }
    catch (const exception& e) {
      cout << e.what() << endl;
    }
    catch (...) {
      cout << "other error\n";
    }
  }
}
$ clang++ sample143.cpp
mhiroi@DESKTOP-FQK6237:~/cpp$ ./a.out
foo runtime error
foo logic error
foo error oops!

このように、foo_error は runtime_error を継承しているので、メンバ変数やメンバ関数を定義しなくても適当なコンストラクタを用意するだけで動作します。

●例外処理とデストラクタ

局所変数 (スタック領域) に確保されたインスタンスは、関数の実行が終了するとき (スコープの範囲外になったとき) デストラクタが呼び出されますが、例外処理によって関数の実行が中断された場合でもデストラクタが呼び出されます。次のリストを見てください。

リスト : 例外処理とデストラクタ (sample144.cpp)

#include <iostream>
using namespace std;

class GlobalExit {};

class Foo {
  int x;
public:
  Foo() : x(0) {}
  Foo(int n) : x(n) {}
  ~Foo() { cout << "Foo " << x << " destroy\n"; }
};

void bar1()
{
  cout << "call bar1\n";
}

void bar2()
{
  cout << "call bar2\n";
  throw GlobalExit();
}

void bar3()
{
  cout << "call bar3\n";
}

void foo()
{
  Foo a(1);
  Foo* b = new Foo(2);
  bar1();
  bar2();
  bar3();
  delete b;
}

int main()
{
  try {
    foo();
  }
  catch (GlobalExit e) {
    cout << "Global Exit\n";
  }
}
$ clang++ sample144.cpp
$ ./a.out
call bar1
call bar2
Foo 1 destroy
Global Exit

関数 foo でクラス Foo のインスタンスを局所変数 a とポインタ変数 b にセットします。関数の実行が正常に終了するならば、delete b で b のインスタンスが破棄され、そのあとで a のインスタンスが破棄されます。ところが、bar2 で throw が実行されると、foo を跳び越えて制御が main の catch 節に移ります。この場合でも、局所変数 a のデストラクタが呼び出されます。

ところが、ポインタ変数 b はヒープ領域からメモリを取得しているので、明示的に delete しない限りデストラクタは呼び出されません。この場合、例外が発生するとメモリリークが生じることになります。

メモリリークだけではなく、プログラムの途中で例外が送出されると、残りのプログラムは実行されないため、必要な処理が行われない場合もあります。他のプログラミング言語、たとえば Java や Python では、try 文を終了するときに必ず実行する処理を記述する機能 (finally 節) があります。このような機能はC++にはありませんが、クラスのデストラクタで代用することが可能です。

クラスを作るのが面倒であれば、標準ライブラリを使うことを考えてみてください。たとえば、配列を動的に取得したい場合は vector クラスを使う、ポインタ変数はスマートポインタ (auto_ptr など) に格納して使う、といった方法があります。標準ライブラリの使い方は回を改めて説明する予定です。


初版 2015 年 9 月 13 日
改訂 2023 年 4 月 9 日