C++

もう少し例外を使用しても良いのではないか...


はじめに

プログラムを実行していると、例えば、ファイルの読み書きをしようとしたがファイルが見つからなかった...というような、予期せぬこと(エラー)が起こることがある。堅牢なプログラムを作成するためには、エラーを検出し、検出したエラーに適切に(プログラムが異常な動作をしないように)対処する必要がある。

プログラムにおいて、エラーの検出と対処のコードは分割されることが多い。分割する理由は、例えば、ライブラリの作者はエラーの検出をできるが、対処方法はライブラリのクライアントにより異なり、全クライアントの要件を満たす対処をライブラリ内で実装できないためだ。

C++では、エラーを検出したことを通知する言語機能として、 例外(Exception) を提供している。しかし私が関わってきたプロジェクトで、例外によるエラー処理をしたプロジェクトは無かった。無いどころか、「例外の使用は何となくコストがかかりそうだから」などという理由で、例外の使用を禁止するプロジェクトもあった。そういったプロジェクトでは、STLを使うなと言いたくなる。

本記事では、エラー処理として例外を使用することによるメリットを紹介する。


注意

以下ではエラー処理の手法として例外を採用することで得られるメリットを紹介するが、 常に例外を使用すべきというわけではない ことに注意されたい。例えば 組み込みシステムのような厳しい処理速度が要求されるケース では例外を使用すべきではない(例外を throw してから catch するまでの最大時間を正確に測定することが困難なため)。


例外を使用することで得られるメリット


汎用的にエラーを扱うことができる

例外を使用しない場合、エラーコード(関数戻り値)、または errno などのグローバル変数によりエラーを通知する。しかしこれらの手法には下記理由によりうまく機能しない場合があり得る。


  • エラーコードによる通知では、コンストラクタで発生したエラーを通知できない。また、戻り値をチェックしないプログラマもいる(私は printf の戻り値をチェックするプログラマを見たことがない)。


  • errno などのグローバル変数を使用した通知では、マルチスレッド環境では確実にエラーを検出できない可能性がある(別スレッドがグローバル変数を書き換えてしまう可能性があるため)。また、 呼び出し側で常に errno を初期化する手間が発生する。


例外を使えばコンストラクタで発生したエラーも、マルチスレッド環境で発生したエラーも検出することができる。


エラーの扱い方を体系化できる

例外を使用しない場合、汎用的にエラーを扱う仕組みが無いため、上に挙げた方法を非体系的に組み合わせることになる。その組み合わせ方には大した根拠の無いことが多く、組み合わせ方はプログラマにより異なったものになり得る。その結果、一貫性の無い、汚いコードができあがる。

一方例外は、C++の言語機能として(完璧とは言えないまでも例外を使わない方法と比べて)体系化されたものであり、コードに一貫性をもたらすことができる。


正常系ではコストがかからない

例外を使用しない場合、コードのいたるところにエラーをチェックする処理が追加される(各関数の冒頭でのパラメータチェックや、関数の戻り値チェックなど)。こういったチェックはエラーが発生していない場合でも実行され、当然ながら実行にはコストがかかる。


コードの可読性が上がる

例えば下記コードでは 正常系と異常系の処理が混じっている ためコードが読みにくい。


sample1.cpp

int f() 

{
int rc;
GResult gg = g(rc);
if (rc == FooError) {
// FooErrorを処理するコードを書く。
} else if (rc == BarError) {
// BarErrorを処理するコードを書く。
} else if (rc != Success) {
return rc;
}
HResult hh = h(rc);
if (rc == FooError) {
// FooErrorを処理するコードを書く。
} else if (rc == BarError) {
// BarErrorを処理するコードを書く。
} else if (rc != Success) {
return rc;
}
IResult ii = i(rc);
if (rc == FooError) {
// FooErrorを処理するコードを書く。
} else if (rc == BarError) {
// BarErrorを処理するコードを書く。
} else if (rc != Success) {
return rc;
}
JResult jj = j(rc);
if (rc == FooError) {
// FooErrorを処理するコードを書く。
} else if (rc == BarError) {
// BarErrorを処理するコードを書く。
} else if (rc != Success) {
return rc;
}
// ...
return Success;
}

sample1.cpp の内容を例外を使用して書き直したのが sample2.cpp である。正常系と異常系の処理が混ざっていない(正常系の処理は全て try ブロックにまとまっている)ため、コードが読みやすくなる。


sample2.cpp

void f()

{
try {
GResult gg = g();
HResult hh = h();
IResult ii = i();
JResult jj = j();
// ...
}
catch (FooError& e) {
// FooErrorを処理するコードを書く。
}
catch (BarError& e) {
// BarErrorを処理するコードを書く。
}
}


コードの保守性が上がる

下記のような、 f1()f2() を、 f2()f3() を呼び出すコードがあるとする。この場合、 f3() で発生したエラーは f3() で処理をせず、 f1() で処理することになる(発生したエラーに対してどう対処べきかは、呼び出し元の f1() だけが知っているため)。

つまり呼び出し先( 今回の場合は f2() または f3() )でエラーが発生した場合、呼び出し元( 今回の場合 f1() )までエラーを伝播させる必要がある。呼び出し元と最終的な呼び出し先の関数全てに、エラーチェックの処理( f2() にある条件分岐処理 )を記述することになり、ビジネスロジックとは関係のないコードの重複が生まれる。

ビジネスロジックとは関係のないコードが増えると、 ビジネスロジックがビジネスロジックとは関係のない条件分岐に埋もれ、コードが保守しにくいものになる という問題がある。また、 テストケースが増える という面倒さもある。


sample3.cpp

int f1()

{
// ...
int rc = f2();
if (rc == 0) {
// 正常系の処理を書く。
} else {
// 異常系の処理を書く。
}
}

int f2()
{
// ...
int rc = f3();
if (rc != 0)
return rc;
// ...
return 0;
}

int f3()
{
if ( /* 何らかのエラーチェック */ )
return some_nonzero_error_code;
// ...
return 0;
}


sample3.cpp の内容を例外を使って書き直したのが sample4.cpp である。 f2() からエラーをチェックする条件分岐が消えているのが分かる。この程度の小さなサンプルでは効果を実感しにくいだろうが、 f1() ... f10() などのように呼び出す関数の数が多くなった場合を想像すれば、効果は容易に実感できると思う。


sample4.cpp

void f1()

{
try {
// ...
f2();
// ...
} catch (some_exception& e) {
// some_exceptionを処理するコードを書く。
}
}

void f2() { ...; f3(); ...; }

void f3()
{
// ...
if ( /* 何らかのエラーチェック */ )
throw some_exception();
// ...
}



最後に

C++の言語機能である例外を使うことで得られるメリットをいくつか紹介したが、例外を使うためには、例外について基礎知識を学ぶ必要がある(単に throw / catch すれば良いわけではない)。例えば、デストラクタで例外を送出してはいけないとか、例外には巨大なデータをもたせてはいけない(捕捉されるまでに複数回コピーされる可能性があるため)とか。学ぶべきことを学ばずに例外を使用したコードは技術的負債になりかねないので、注意されたい。


参考資料