エラー処理とログ出力

作成:2005年7月26日

改訂:2005年10月15日

吉田誠一のホームページ   >   ソフトウェア工学   >   技術コラム   >   オブジェクト指向

ソフトウェアの開発において、エラー処理は、時には本来の機能よりも重要です。業務として開発するソフトウェアでは、本来の処理を行うためのコードよりも、エラー処理のコードの方が量が多くなることも良くあります。

ところが、実際のソフトウェアの開発では、エラーをどこでどのように出力するかについては、実装者任せになってしまうことが多いようです。ソフトウェア設計書を見ても、エラーの出力については記述されていないことも良くあります。実装が終わってから、最後に慌しくエラーの出力を組み込むこともあります。

エラー処理について考えてみると、たくさんの難しい問題があることが分かります。これらの問題を理解した上で、きちんとエラー処理の仕組みを考えないと、ソフトウェアの設計や品質にも、重大な影響が及ぶかもしれません。

エラー処理とログ出力は、本来、どのようにして行うべきなのでしょうか。

目次

  1. エラーが起きたら知らせよう
    1. エラーを知らせる仕組み
    2. エラーコードはいくつにする?
    3. エラーの伝言ゲーム
  2. 人に知らせるエラー処理
    1. エラー処理は何のため?
    2. 人にエラーを知らせる方法
    3. どこでエラーを出力するべきか?
  3. 正しいエラー処理のやり方とは?
    1. エラー処理がオブジェクト指向設計を破綻させる
    2. アスペクト指向プログラミングは解決策となるか?
    3. エラー処理の理想を追い求めて
  4. おまけ
    1. ログを出力する際の注意
    2. ログに出力しておくと役立つ情報
    3. アサートの罠

エラーが起きたら知らせよう

エラーを知らせる仕組み

ソフトウェアが走っていると、ユーザが入力した値が適切でなかったり、ファイルが開けない等の、さまざまなエラーが発生します。時には、ソフトウェアにバグがあって、予期しない値が現れることもあるかもしれません。

関数やクラスといったソフトウェア部品を作る際は、その中でエラーが起きた時に、エラーが発生したことを呼び出し側に知らせる処理を、きちんと組み込まなくてはいけません。

C言語による開発では、関数でエラーが発生した時は、エラーコードという数値を返す、という方法が一般的に使われてきました。

C++言語やJava言語といったオブジェクト指向言語では、例外という仕組みが備わっています。処理中にエラーが発生した場合は、例外を投げて、処理を途中で抜けることができます。しかし、C言語の延長でC++言語を使っている場合は、C++言語でもエラーコードを使うことも多くあります。

エラーコードはいくつにする?

ソフトウェアの開発を始めるにあたって、エラーコードをどうやって決めるかは、しばしば悩むところです。

統一的なエラーコードを定義する

たいていの場合、プロジェクトで1つ、エラーコードを定義するヘッダファイルを決めて、各自がそこに、必要なエラーコードを順次追加していく、という方法が採用されるようです。例えば、次のようになります。

#define XXX_OK                  0
#define XXX_FILE_NOT_FOUND      1001
#define XXX_FILE_READ_ERROR     1002
#define XXX_ILLEGAL_NAME        2001
#define XXX_ILLEGAL_USER        2002
            

モジュールやクラスごとにエラーコードを定義する

しかし、このような中央集権的な方針の他に、モジュールやクラスごとに独自のエラーコード体系を持つ、という方針も考えられます。例えば、次のようになります。

class Foo {
public:
        enum ReturnCode {
                OK,
                FILE_NOT_FOUND,
                ILLEGAL_NAME
        };

public:
        enum ReturnCode bar ( ) {
                        :
                return ILLEGAL_NAME;
                        :
                return OK;
        }
};
            

このクラスを利用する側のコードは、次のようになります。

Foo foo;
enum Foo::ReturnCode ret = foo.bar();
if (ret == Foo::ILLEGAL_NAME) {
        // エラー処理
}
            

モジュールやクラスごとに独自のエラーコード体系を持つ方針には、次のような利点があります。

  • モジュールやクラスのエラー処理を、互いに独立に設計できる。
  • エラーコードの名称がクラスごとの名前空間に閉じられるため、他のクラスでどのような名称が使われているかを意識する必要がない。

エラーコードにオブジェクトのアドレスを使用する

ところで、引数がNULLであるとか、添字が配列サイズを超えている等の、一般的なエラーは、個々のモジュールやクラスでいちいち定義するのは面倒です。良く使われる一般的なエラーについては、共通のエラーコードを定義したくなるかもしれません。しかし、一部のエラーコードだけを共通にすると、エラーコードの番号が重複しないようにする管理が、たいへん難しくなります。

エラーコードの番号管理を避けるために、整数型の値ではなく、オブジェクトのアドレスを使用する、という方法も考えられます。例えば、前述の #define で定義されたエラーコードは、オブジェクトのアドレスを使用する方法では、次のようになります。

class ErrorCode {
};

ErrorCode* XXX_OK = new ErrorCode;
ErrorCode* XXX_FILE_NOT_FOUND = new ErrorCode;
ErrorCode* XXX_FILE_READ_ERROR = new ErrorCode;
ErrorCode* XXX_ILLEGAL_NAME = new ErrorCode;
ErrorCode* XXX_ILLEGAL_USER = new ErrorCode;
            

関数を呼び出す側のコードは、次のようになります。

const ErrorCode* ret = foo();
if (ret == XXX_ILLEGAL_NAME) {
        // エラー処理
}
            

この方法であれば、一部のエラーコードのみを共通化することも簡単にできます。

ちなみに、この方法はtypesafe enumパターンに似ていますが、自由にエラーコードの種類を増やすことができますので、typesafe enumパターンとは異なります。

エラーの伝言ゲーム

関数が別の関数を呼び出し、そこからさらに別の関数を呼び出す、という具合に、関数の呼び出しが連鎖することは良くあります。

ここで、下位の関数でエラーが発生した時、上位の関数には、どのようにエラーを伝えれば良いでしょうか。

下位の関数で発生したエラーを、そのまま上位の関数に受け渡すことも多いようです。例えば、次のようになります。

下位のエラーを上位に受け渡す

int bar ( ) {
                :
        int ret = foo();
        if (ret != XXX_OK) {
                return ret;
        }
                :
        return XXX_OK;
}
          

しかし、この方法では、関数 bar() を呼び出した時に、どのようなエラーが発生する可能性があるのかを、bar() を利用する側が把握できない、という問題があります。関数 bar() の利用者は、そこから呼び出される関数 foo() のことまで知らなくてはなりません。また、foo() や bar() の実装が変わるだけで、返されるエラーコードが増えて、呼び出し側まで修正する必要がでてくる可能性もあります。

これらの問題を回避するために、下位の関数で発生したエラーは破棄し、改めてエラーコードを返す、という方法が考えられます。例えば、次のようになります。

下位のエラーを変換して上位に受け渡す

int bar ( ) {
                :
        int ret = foo();
        if (ret != XXX_OK) {
                return XXX_FOO_FAILED;
        }
                :
        return XXX_OK;
}
          

この方法なら、関数 bar() を呼び出したときにどのようなエラーが発生するかは、関数 bar() を見るだけで把握できます。

これは、C言語のエラーコードだけでなく、C++言語やJava言語の例外についても同じことが言えます。

特にC++言語では、投げられる例外を関数宣言に記述しないため、関数を呼び出す際に、何をキャッチすれば良いのか、まったく分からなくなります。それを避けるためには、それぞれの関数の中で、下位の関数から投げられる例外をすべてキャッチし、破棄した上で、改めて新たな例外を投げ直す、という方針は有効です。

Java言語の場合は、投げられる例外を宣言する必要がありますので、下位から投げられる例外をキャッチしなくても、このような問題は起こりません。但し、実行時例外については宣言しなくても構いませんので、注意する必要があります。

Java言語では、J2SE 1.4から、例外チェーンの仕組みが導入されました。下位の関数から投げられる例外を、別の例外に変換して投げ直す際に、下位から投げられた例外の情報も含めて、上位に渡すことができます。

人に知らせるエラー処理

エラー処理は何のため?

ここまで、エラーが発生した時に、どのようなエラーが発生したのかを呼び出し側に知らせる仕組みについて、考えてきました。しかし、そもそも何故、エラーコードの番号体系や、エラーハンドリングの方法について、このようなことを考えなくてはいけないのでしょうか。

エラー処理を行い、エラーについての情報を呼び出し側に伝える目的は、最終的には、発生したエラーについて、人にきちんと知らせるためです。即ち、エラーについて何らかの出力をすることが目的です。

エラーが発生した時には、必ず、エラーについての情報を何らかの形で出力し、人に知らせる必要があります。もし、その必要がないのであれば、エラーコードなどは考える必要がありません。単に、正常か異常かを返すだけで良いことになります。

人にエラーを知らせる方法

発生したエラーについて人に知らせる方法には、ソフトウェアの開発者に知らせるための方法と、利用者に知らせるための方法と、2つの種類があります。

ログは、主にソフトウェア開発者がデバッグを行うために、エラーの情報をファイルに出力するものです。クラス名や変数名、値、ファイル名、行番号などを、そのまま出力します。

エラーメッセージは、ソフトウェアの利用者にエラーが起きたことを知らせるために、コンソールやウィンドウに出力するものです。一般の利用者が見て分かるような表現で出力します。利用者がエラーを確認して何らかの操作を行うまで、ソフトウェアの動作を停止させるために、メッセージボックスを使うこともあります。

どこでエラーを出力するべきか?

では、これらの方法でエラーを出力する処理は、ソフトウェアのどこに記述すれば良いのでしょうか。

1つは、ある関数でエラーが発生した時、エラーの出力もその関数の中で行ってしまう、という方針が考えられます。ログの出力は、たいていはこの方針で行われます。

もう1つは、発生したエラーの情報を呼び出し側に渡して、エラーの出力は呼び出し側で行う、という方針も考えられます。エラーメッセージの出力は、たいていはこの方針で行われます。

この2つの方針には、それぞれ利点と欠点があります。

エラーが発生した箇所で、エラーの出力も行う場合の問題点

汎用性が無くなる

ログを出力する仕組みは、アプリケーションごとに異なります。

あるアプリケーションの中から、汎用的なソフトウェア部品を取り出し、他のアプリケーションに流用したいこともあります。しかし、2つのアプリケーションで、異なるログ出力の仕組みを使っている場合は、簡単には流用できなくなることがあります。

文脈が分からない

同じ状況が発生しても、呼び出された時の文脈によって、正常であったり、エラーであったりすることがあります。また、警告レベルのエラーに過ぎないこともあれば、致命的なエラーであることもあります。

呼び出される関数の側では、上位側からどのような文脈で呼び出されているのかを、知ることができません。そのため、エラーが発生しても、その重要性の判断ができません。

これは、汎用的なモジュールやクラスを作っている時に、特に問題となります。

実際のソフトウェア開発では、汎用的なモジュールであっても、呼び出される文脈が特定されていることがあります。その場合は、その文脈でしか呼び出されない、という前提で、エラー処理を実装してしまうことがあります。

例えば、XMLファイルを読み込み、引数で指定されたタグの値を読み取るモジュールを作ったとしましょう。指定されたタグが存在しない場合、これをエラーとするかどうかは、文脈に依存します。

しかし、このモジュールを使うアプリケーションでは、必ず存在するタグしか指定しない、と分かっていたとしましょう。この時、モジュールの中で、指定されたタグが存在しない場合はエラーを出力する、という処理を記述してしまうことがあります。

しかし、このように呼び出される文脈を意識した作りは、正しい設計ではありません。また、汎用性も失われてしまいます。

状況に応じた制御ができない

ある関数が繰り返し呼び出される時は、エラーの出力を抑制したくなります。数百個もの同じようなエラーメッセージを出力するよりは、「数百個のエラーがあります」というエラーメッセージを1回だけ出力する方が良いでしょう。

しかし、呼び出される関数では、数百回も繰り返し呼び出されている、ということは分かりません。

出力の仕方は一通りではない

エラーの出力を行う方法は、必ずしも一通りではありません。

エラーの書式は、アプリケーションごとに異なるかもしれません。また、アプリケーションによっては、複数の出力先にそれぞれエラーを出力することもあるかもしれません。

エラーが発生した箇所でエラーの出力も行う方針では、このようなバリエーションには対応できません。

エラーの情報を返し、上位側でエラーの出力を行う場合の問題点

下位が上位を意識してしまう

上位側でどのような出力を行うのかは、エラーが起きた下位のモジュールやクラスでは、本来は意識すべきではありません。

しかし、実際には、下位側のエラー情報の返し方を見ると、上位側でのエラー出力の仕方を強く意識していることが良くあります。

例えば、C言語では、エラー情報を返す際に、エラーコードという番号を返すことが多くあります。これは、何故でしょうか。

たいていの場合、呼び出し側で出力するエラーメッセージでは、エラーの概要だけを示し、詳細な値などは出力しません。そのため、エラーの内容を表すエラーコードだけで充分なのです。言い換えると、上位側で出力するエラーメッセージには、値などは出力しない、と分かっているからこそ、下位側の関数は、エラーコードだけを返すように決められる訳です。

情報が隠蔽されない

上位側でどのような出力も行えるようにするには、エラーに関するあらゆる情報を渡すことになります。

しかし、設計の良し悪しで言えば、下位側の詳細な情報は、上位側には隠されているべきです。

もし、ログの出力を上位側に委譲するとなると、クラスのprivate変数や、関数内で宣言された自動変数などまで、上位側に見せなくてはいけないかもしれません。

気が付いたら、すべての変数がpublicになっていた、ということにもなりかねません。

ポリモーフィズムに対応できない

オブジェクト指向言語では、さらに難しい問題が発生します。

クラスの継承やインターフェースの実装を行い、親クラスで定義されているメソッドを子クラスでオーバーライドします。この時、このメソッドから呼び出し側に渡せる情報は、親クラスで宣言された例外に限定されます。

親クラスでは、どのような子クラスが存在するかを知りません。メソッドが投げる例外には、親クラスのフィールドの情報をすべて含めることはできます。しかし、子クラスで独自に定義されるフィールドの情報は、含めることはできません。

子クラスでは、親クラスで宣言された例外を継承し、子クラス独自のフィールドの情報も、例外に詰め込むことはできます。

しかし、上位側、即ち、このメソッドを呼び出す側では、親クラスしか意識していません。投げられる例外も、親クラスで宣言された型で扱います。そのため、上位側では、子クラス独自のフィールドの情報は、得ることができません。

ポリモーフィズムにおける問題

正しいエラー処理のやり方とは?

エラー処理がオブジェクト指向設計を破綻させる

エラーの出力に関しては、エラーが発生した関数の中で出力するにしても、呼び出し側で出力するにしても、いずれにせよ、設計上の問題が生じます。エラー処理を組み込むと、上位側と下位側の結合が密になったり、暗黙の了解が含まれたりして、せっかくオブジェクト指向的に考えた設計が破綻することにもなりかねません。

実際のソフトウェア開発では、開発の最終段階になってからエラーの出力を組み込もうとして、オブジェクト指向的な設計の形を崩すことが多いようです。汎用性を犠牲にして、ある特定の利用形態で適切なログが出力されるようにエラー処理を組み込んだり、エラーメッセージの表示仕様に基づいて、返すエラー情報の内容を決めたりすることが良くあります。

アスペクト指向プログラミングは解決策となるか?

最近のソフトウェア開発では、アスペクト指向プログラミングという考え方が流行しています。

エラー処理とログ出力は、アスペクト指向プログラミングの典型的な応用例として紹介されていることが多いです。ですが、これまでに述べたエラー処理とログ出力に関する問題は、本当にアスペクト指向プログラミングによって解決するのでしょうか?

アスペクト指向プログラミングとは、ソフトウェアの主要なロジック以外の処理を切り離すことで、本来の処理を明確にするという手法です。

エラー処理やログ出力のコードは、きちんと書いていくと、かなりの分量になります。業務用のソフトウェアでは、エラー処理のコードばかりになって、本来の処理が良く分からなくなってしまうことも、珍しくありません。エラー処理やログ出力を、ソースコードから切り離す、というのは、確かに魅力的なアイディアです。

ただ、そのアイディアの背景は、ソフトウェアの本来の処理と、エラー処理は、互いに独立で、分離させることができる、という前提があります。しかし私は、ソフトウェア本来の処理とエラー処理は切り分けられるものではなく、むしろ、エラー処理の方法もソフトウェアのデザインの一部と考えるべきではないか、と思っています。

エラー処理の理想を追い求めて

エラー処理とログ出力に関する問題は、これらを無視して設計したソフトウェアに、後からこれらの仕組みを継ぎ足そう、としていることが大きな原因になっています。一貫したソフトウェアを構築するためには、初期の段階からエラー処理まで含めて設計すべきではないか、と思います。

もちろん、どのような方法でエラーを処理し、どのような仕組みでログを出力するのかは、良く考える必要があります。エラー処理やログ出力のコードで、本来の処理が分かりにくくなってしまった、というケースは、クラス設計がうまくできておらず、機能や役割がうまく分担できなかった場合と、同じ状況に陥っていると言えます。

実際のアプリケーションの開発では、エラーの判定やエラー処理だけを、独立したクラスに分離する、ということも良くあります。エラー処理やログ出力を行う専門のクラス群を作る訳です。この場合、これらのクラス群と、ソフトウェア本来の処理を行うクラスとの結合をできるだけ疎にすることが理想的です。

これがうまくいけば、ソフトウェア本来の処理と、エラー処理やログ出力を、見事に切り離すことができたように見えることでしょう。アスペクト指向プログラミングが目指した理想の形は、エラー処理やログ出力まで含めたソフトウェア全体の設計をうまくデザインすることでこそ、実現できるのではないか、と思います。

おまけ

ログを出力する際の注意

ログには、エラーの原因となった変数の値をそのまま出力することが多いです。しかし、エラーが発生した時の値は、不用意に出力するべきではありません。

文字列

文字列は、場合によっては終端文字が見つからず、異常に長い文字列になってしまっている可能性があります。文字列をそのまま出力すると、ログに出力するためのバッファをオーバーしたり、ログファイルを埋め尽くしてしまう可能性があります。

ポインタ

構造体やクラスの内容がおかしくなっている時は、ポインタが誤った位置を指している可能性があります。この時、ログに構造体やクラスの内容をそのまま出力しようとするのは危険なことがあります。

これらのことに注意しないと、肝心のエラーが発生した時に、ログが出力されない、という状況になってしまいます。

ログに出力しておくと役立つ情報

ログには、次のような情報も出力しておくと役に立ちます。

  • 実行ファイルやライブラリのパス。
  • 実行ファイルやライブラリのサイズ、日付。
  • 実行ファイルやライブラリのバージョン。

たびたび修正を行ったり、バージョンアップを繰り返しているソフトウェアでは、問題が発生した時のために、ソフトウェアがいつの時点のもので、どのような修正が施されているかを分かるようにしておく必要があります。

次のように、個々の障害番号や修正番号ごとに、それが修正されている旨をログに出力する、という方法も考えられます。

XXX_0001 fixed.
XXX_0002 fixed.
XXX_0004 fixed.
          

アサートの罠

ソフトウェアを開発する際に、アサートを多用する人も多くいます。しかし、アサートはエラー処理の仕組みではないので、注意が必要です。

アサートは、テスト工程で不具合を見つけるための準備です。いわば、テストコードの一種と考えられます。

アサートは、ソフトウェアが正しければ本来はあり得ない状態になったことを検出するものです。アサートの仕組みは、このような誤りをテスト工程ですべて見つけられる、という仮定に基づいています。

しかし、万が一、ソフトウェアの誤りが見逃されてしまったら、どうなるでしょうか。通常、リリース用の実行ファイルには、アサートの処理は含まれません。すると、万が一のことが起きた場合は、アサートを入れた箇所は素通りし、ソフトウェアは暴走することになります。

もし、万が一の場合でも暴走しないようにするためには、アサートではなく、ソフトウェアが確実に停止するように、きちんとエラー処理を組み込む必要があります。

Copyright(C) Seiichi Yoshida ( comet@aerith.net ). All rights reserved.