ニコニコC++入門

入門サイトや市販の入門書では絶対に教えてくれない、C++の本当の使い方を教えます。

あなたの知らない例外

2005-07-25 23:58:57 | C++

 C言語の場合、関数の中でエラーが発生したことを呼び出し元へ伝える方法として一番ポピュラーなものは戻り値にエラーコードを入れておく方法です。しかしこの方法は戻り値とエラーコードを同時に返したい場合には使えません。エラーコードを正常な戻り値として処理してしまう事故も起こりがちです。グローバルなエラー変数を用意する方法は、エラーを見逃したり、エラーを処理する前に次のエラーが発生してしまう危険があります。エラー受け取り用変数へのポインタを引数で渡す方法は問題は少ないのですがスマートさに欠けます。

 例外はこれらの問題をスマートに解決します。しかしここを読んでいるような読者なら、それはもうわかっていると思います。これらのメリットがわかっていながら例外を使わないのは、そのメリットがたいして有用なものと感じられないからではないでしょうか。例外を使ってみたものの、コード量が減るわけでもなく、むしろコードは増えて、厄介な問題も出てきて、メリットよりデメリットのほうが大きく感じます。

 例外にあまり魅力を感じていない人のほとんどは、例外の使い方を間違って認識しています。そこで今回は例外の本当の使い方をお教えします。このページを読み終わったら、きっと例外を使いたくてたまらなくなると思いますよ。

char *foo();
void normal_proc(char *);
void error_proc(int n);
bar()
{
 char *p = foo();
 int n = reinterpret_cast<int>(p);
 if (n == ERROR_NO)
  error_proc(n);
 normal_proc(p);
}

 一般的な throw の使い方では、関数の実行中に問題が発生したときに、return のかわりに throw を使って関数を終了させます(別に関数の外に投げなくてもいいのですが、話がややこしくなるので省略します)。

bar()
{
 try
 {
  normal_proc(foo());
 } catch (int n)
 {
  error_proc(n);
 }
}

 return では戻り値の型とエラーコードの型が同一でなければなりませんでしたが、throw ではエラーの内容にあわせて好きな型を選べます。

 上記の例は極端な例かもしれませんが、処理系によっては sizeof(char *) == sizeof(int) とは限りませんから、これをしなくて済むのはたいへんよいことです。

 しかしここで喜んではいけません。例外を上手く活用するためには throw に与える型が鍵になります。例外を生かすも殺すも型の選び方一つなのです。もうお気づきでしょう。上記の例は、ダメな例です。

 さて、話を進める前に例外の動きを少し追ってみましょう。

class EReadError : public EFileError
{
};

func()
{
 if (error)
  throw EReadError(filename);
}

bar()
{
 try
 {
  func();
 } catch (EFileError &e)
 {
  cout << e.FileName << "へのアクセス中にエラーが発生しました。" << endl;
 }
}

 throw された例外は catch されるまで関数をさかのぼります。catch しなければいつまでも関数をさかのぼり、最後にはプログラムが終了してしまいますので、どこかで catch してあげないといけません。

 catch 文があればなんでもいいわけではなく、throw された例外と同じ型の catch 文でなければなりません。型が合わない catch は無視されます。

 ただし継承を使ったクラスが throw されている場合は、その継承もとの親クラス(スーパークラス)でもあるとして扱われます。

 つまり左の例のように、EReadError クラスは EFileError クラスでもあるので、EFileError として catch できるのです。

bar1(somefile &s)
{
 try()
 {
  somefile.foo(s);
 } catch(EFileReadError &e)
 {
 } catch(EFileWriteError &e)
 {
 } catch(EFileError &e)
 {
 }
}

bar2(somefile &s)
{
  try()
  {
  somefile.foo(s);
  } catch(EFileError &e)
  {
  }

 これを利用して、エラーの種類はクラスの型で表現します。エラーコードではなくクラスを利用し、継承を使うことで、エラーのツリー体系を作り出すことができます。

 エラークラスの継承がツリー体系になっていると、エラーのリカバリー処理は柔軟になります。細かく調べたければ右の bar1 のようにクラスごとに処理を書けばよく、あまり細かいことを知る必要が無いときは bar2 のように書くことができます。

 また somefile クラスがさらに継承され、ポリモーフィズムを活用していて、foo が EFileTimeOutError を返すように書き換えられていたとしても、bar1 や bar2 を変更しなくていいのです。

 これを突き詰めていくと、throw されるエラーは必ず1つのクラスを継承することになります。stl には exception というクラスがありますし、C++Builder の VCL には Exception というクラスがあって、それぞれのクラスをルートとした例外クラスのツリーを形成しています。

 各自で独自の例外クラスを作るよりは、こうして用意されている既存の例外クラスを継承したほうがいいでしょう。とにかく組み込み型や、例外とは関係ないクラスを throw してはいけないと思ってまず間違いありません。

CFoo::CreateKey(int key)
{
 for (int i = 0; i < m_max; i++)
 {
  SetKey(new CKey(key, i));
 }
}

CFoo::Function(int n)
{
 CreateKey(800 * n);
 CreateKey(25 + n);
 CreateKey(6210 / n);
 CreateKey(7 - n);
}

int main(int, char**)
{
  try
  {
  MainLoop();//この中のどこかで CFoo::Function を呼び出しています。
 }
 } catch (exception &e)
 {
  cout << "なんかエラーが出ました" << endl;
  cout << e.what() << endl;
 }
}

 例外が継承されていると、catch を書かなくても済むことが増えます。

 上記のプログラムは、メモリが確保できなかったらエラーメッセージを出して終了する仕様です。

 各関数の呼び出し終了時でいちいちメモリを確保できたかどうか確認するのはたいへんです。CFoo::Function を、例外を使わずに1つ1つエラーチェックするようなコードに書き換えることを想像してみてください。これがもしもっと複雑な関数だったらどうでしょうか?

 例外を使えばエラー処理を減らすことができます。コード量が減ればバグは減ります。

 もちろん、メモリが確保できなかった理由を詳細に調べてその場でリカバリーするような処理を書けなくなったわけではなく、そういうときは必要に応じて catch を増やせばいいのです。

 つまり例外として投げるオブジェクトは何でも良いわけでなく、一つの exception クラスから体系立てて継承しなければなりません。しかも体系の立て方でリカバリしやすいかどうかも変わってきます。

 どうでしょうか。なるほどと感心された人もいるでしょうし、なにか狐につままれたような騙された気になる人もいるでしょう。

 「ちょっとマシな石ころ」にしか見えなかった例外という奇妙な仕組みは、正しい使い方を覚えれば光り輝く宝石だったことに気づくはずです。何度も練習して、使い方のこつを覚えるようにしてください。

 

 さて、これでおひらきにしてもいいのですが、もう1つ、例外で避けて通れない問題を解決しておきましょう。メモリリークの問題です。

 例外とメモリリークは付き物であるかのように言われています。ところがこれは簡単に解決できます。

try
{
 char *a = new char[size];
 char *b = new char[size];
 func(a, b);
 delete a;
 delete b;
} catch(...)
{
}

 とりあえず上記の例を見てください。func の中で例外が発生すると delete が実行されません。

char *a = NULL;
char *b = NULL;
try
{
 a = new char[size];
 b = new char[size];
 func(a, b);
}
catch(...)
{
 throw;
}
delete a;
delete b; 

 その対策として上記のようにしたりします。ちなみに NULL を delete しても何も起きませんので大丈夫です。これでだいぶましになりますが、catch の中でさらに throw するとやっぱりリークしてしまいます。

class foo
{
private:
 A *m_pA;
 B &m_B;
 C *m_pC;
public:
 foo(X &x);
 ~foo();
};

foo::foo(X &x)
: m_pA(new A(x)), m_B(x.GetB(new A(x)))
{
 try
 {
  m_pC = new C(x);
 } catch(...)
 {
 }
}

foo::~foo()
{
 delete m_pA;
 delete m_pB;
}

 さらに頭を悩ませるのが、コンストラクタの中で発生する例外です。

 m_pC の初期化は例外を catch していますが、もしここで catch しなかったらどうなると思いますか?

 コンストラクタが完全に終了していない場合は、なんとデストラクタが呼び出されないのです。なるほど、コンストラクタの処理が最後まで終わっていないのにデストラクタが呼ばれるとまずいこともあるでしょう。

 それだけではありません。A のコンストラクタは catch すらできません。これはコンストラクタの {} の中に移動すれば済むとしても、m_B は参照なのでこの手が使えません。この中でも例外が発生することがありますから困ります。

 こんな難問を本当に解決する方法があるのでしょうか? しかも簡単に?

try
{
 auto_ptr<char> a(new char[size]);
 auto_ptr<char> b(new char[size]);
 func(a.get(), b.get());
} catch(...)
{
}

 ここで秘密兵器をご紹介します。auto_ptr という STL 標準のテンプレートクラスです。このクラスはスコープから抜けたら自動的に解放されるポインタとして使うことができます。

 さっき出てきたメモリリークする関数の例を、auto_ptr を使って書き換えたのが右上のリストです。スコープの外に出たら自動的に開放されますので delete が不要です。catch の途中で throw が投げられても開放されます。

class foo
{
private:
 auto_ptr<A> m_pA;
 B &m_B;
 auto_ptr<C> m_pC;
public:
 foo(bar &x);
 ~foo();
};

 またメモリリークするクラスの例では、メンバが auto_ptr になっています(上記)。こうするとコンストラクタが完了しなくてもメンバは自動的に開放されます。しかも確保してないメモリを間違って開放して困ることもありません。

 よい C++ のコードには delete がほとんどありません。deleteを無くすために打つ手には以下のようなものがあります。上から優先的に適用を検討してください。これらの方法に共通しているのは、デストラクタがスコープから出たときに必ず呼ばれるというC++の特性を利用しているということです。

  1. クラスの中にポインタを隠蔽する(クラスの外ではdeleteが不要)
  2. listやvectorなどのコンテナを利用する(配列をnew/deleteしない)
  3. auto_ptrを利用する(一時的なポインタを作成するときに非常に狭いスコープの中で使う)

 というわけで例外は理解できましたでしょうか。このページを読み終えたなら、エラー処理を書くのが凄く楽になっているはずです。