エラーチェックを忘れないための工夫

ユキ先輩、実行時エラーのチェックは手を抜かないっていう話でしたけど……
うん。それが大切よ。
はい。でも、いくら手を抜かないといっても、完璧にはできないですよね?
そうね。うっかりエラーチェックを忘れてしまうこともあるわよね。
ですよね。そういうミスって、なくす方法ないんですか?
ミスをなくすのは無理ね。でも、減らす方法ならあるわよ?

「実行時エラー(ランタイムエラー)」とは、プログラムを実行しているときの状態によって起こるかもしれないエラーのことでした。例えばメモリー不足やファイル入出力の失敗、通信エラーなどが考えられます。

ここでは、「設定をファイルから読み込む」という動作について考えてみましょう。設定項目は、次のような構造体にまとめられているとします。

/* 設定項目をまとめた構造体 */
typedef struct {
  ……
} Configuration;

これを読み込む関数は、例えば次のような形になるでしょう。読み込みに成功したかどうかを、bool型の戻り値で返します。正常に読み込めた場合は、引数のポインタ経由で設定値を受け取れます。

bool LoadConfiguration(Configuration *pConfiguration) {
  ……
}
覚えてるかしら?ここでは前に説明したテクニックを使っているわよ。
えっ?
ほら、戻り値をbool型にして……
あ、もしかして……エラーチェックが必要なことを思い出しやすくするっていう、あれですか?
そうそう。

この関数の呼び出し側は、次のようになるでしょう。エラーがないかどうかをif文でチェックして、処理を振り分けています。

  Configuration conf;
  if (LoadConfiguration(&conf)) {
    /* 通常の処理 */
    ……
  } else {
    /* エラー処理:設定を読み込めなかった! */
    ……
  }

しかし、エラーチェックが必要なことを忘れて、うっかり次のように書いてしまうかもしれません。

  Configuration conf;
  LoadConfiguration(&conf);

  /* 通常の処理(エラーチェックを忘れている!) */
  ;

こういったミスは、いくら注意していても完全になくせるわけではありません。そこで、どうすればミスに気付けるかを考えることが大切になってきます。例えば、コードレビューを実施するのは、ミスの早期発見に効果的な方法の一つです。

いずれにしても、ミスを見つけるにはレビューやテストが基本ということよ。
ミスのないプログラムに、近道はないってことですね!
ん?近道ならあるけど?
ええっ!いいこと言ったつもりだったんですけどっ!

失敗しない関数をつくる

それじゃあ、さっきの「設定をファイルから読み込む関数」について考えてみましょうか。
はい。
この関数を呼び出しているところがね、もし1カ所しかなかったとしたらどう思う?
そうですね……エラーチェックを忘れる可能性は低いと思います。
じゃあ反対に、呼び出している場所がたくさんあったら?
その場合は……チェックを忘れることもありそうですね。
そうよね。だとしたら、よく使う関数はエラーを返さないほうがいいわね。
いやいや、どうしたってエラーは起こるじゃないですか。
エラーを「起こさない」じゃなくて、「返さない」ってことよ。

上記の「設定をファイルから読み込む関数」は、どのような場合に実行時エラーを起こすでしょうか。例えば、以下のようなケースが考えられます。

  • ファイルが存在しなかった
  • ファイルは存在するが、読み込めなかった
  • ファイルを読み込めたが、そのデータが破損していた

関数内でこうした問題が発生したとき、ほんとうにエラーを返す必要があるのかどうか考えてみましょう。もし、関数が自力でエラーに対処できるのであれば、それで十分かもしれません。よく使う関数ほど、エラーを返さない仕様を検討してみる価値は高いといえるでしょう。

エラーを返さない関数は、例えば次のような形になります。読み込んだ設定値を戻り値として返すだけの、ごく単純なつくりです。

Configuration LoadConfiguration(void) {
  ……
}

エラーチェックが不要なため、呼び出し側もシンプルになります。

  Configuration conf = LoadConfiguration();

  /* 通常の処理 */
  ;

ただし、ほんとうにエラーが発生しなくなったわけではなく、発生しても関数内で対処するよう工夫されているのです。つまり、この関数はあたかもエラーなどないかのように振る舞うため、呼び出し側でエラーチェックについて気にする必要がなくなったということです。

関数内でエラーに対処する方法としては、例えば以下が考えられます。

  • 設定ファイルを復旧して読み込み直す
  • ファイルからの読み込みをあきらめて、デフォルトの設定値を返す
  • エラーログを出力して、プログラムを緊急終了する(呼び出し元には戻らない)

もちろん、実際にどのような工夫でエラーに対処するのがベストなのかは、何を作っているのかによって変わってきます。毎日バックアップをとっているなら設定ファイルを復旧するのが簡単かもしれないし、緊急終了はどうしても避けたいという場合もあるでしょう。

エラーを「起こさない」と「返さない」の違いは分かったかしら?
はい。エラーがあっても呼び出し元に返さないっていうのは、けっこう意外な考え方でした。
でしょ。エラーにどう対処すべきかはケースバイケースだから、よく検討してからやってみてね。
はーい!
ここがポイント!
関数が自力で実行時エラーに対処できるように工夫すれば、呼び出し側でのエラーチェックは不要になります。よく使う関数ほど、この方法を検討してみる価値があるでしょう。

呼び出し側のバグとみなす

もう1つ、エラーチェックを不要にするテクニックを紹介しちゃおうかしら。
わーい。お願いします!

次のように、表情の種類が定義されているとします。「幸せな顔」と「悲しい顔」です。

typedef enum {
  Happy,
  Sad
} FaceType;

この値を受け取って、顔文字を表示する関数を考えてみましょう。

bool PrintFace(FaceType type) {
  bool bSuccess = true;

  switch (type) {

    case Happy:
      puts("(^o^)");
      break;

    case Sad:
      puts("(T_T)");
      break;

    default:
      bSuccess = false;
  }

  return bSuccess;
}
ふむふむ。引数がHappySadかによって、対応する顔文字を表示するんですね。
引数がそのどちらでもないときは?
えっと……その場合はdefaultのところが実行されるから、falseが返されます!
そう。つまり関数がエラーを返すということね。

この関数は、正常に処理できたかどうかを戻り値として返します。次のような方針で作られているためです。

  • 引数には、ひとまずどのような値でも受け入れる
  • 処理できない値が引数に与えられたときは、エラーを返す

つまり、引数の値によっては実行時エラーを返すため、呼び出し側でのエラーチェックが必要な関数になっています。でも、引数は列挙型(enum)ですね。そもそも、どのような値でも受け入れる必要があるでしょうか?

実際のところ、列挙型に定義されていない値をこの関数に渡したいケースは考えられません。もしもそのような値を渡しているとしたら、どこかで論理(ロジック)が破綻しているのだといえます。

そこで、次のような方針で関数のつくりを考え直してみることにしましょう。

  • 引数には、列挙型で定義された値しか受け付けない

これは、関数の定義域を明確にするということです。これにより、エラーを返さない関数として書き換えることができます。具体的には、次のようになるでしょう。

void PrintFace(FaceType type) {
  switch (type) {

    case Happy:
      puts("(^o^)");
      break;

    case Sad:
      puts("(T_T)");
      break;

    default:
      assert(false); /* ここに到達したらバグ! */
  }
}
戻り値を返さない関数になったんですね。
うん。あと、引数がHappyでもSadでもない呼び出し方が、禁止になったのが分かるかしら?
えっと……もしも、それ以外の値を渡したとしたら、バグだってことですかね。
呼び出し側のバグということね。だとしたら、それを検出する方法があるわよね。
そっか。assertですね!
そのとおり!defaultのところに書かれているでしょう?
ほんとだ、なるほどー。あ、でもassert(false)って、どういう意味ですか?
これはね、条件式がfalseになっているから、絶対に成立しないassertね。つまり、この行が実行されたらバグだっていう意味なのよ。

ここでの処理がdefaultに到達するのは、引数がHappyでもSadでもないとき、つまり呼び出し側にバグがあった場合です。このような場所には、assert(false)と書いておきましょう。そうすれば、本来は到達してはならない行が実行されてしまったことを検出できます。

これは、switch文のdefaultでよく使うテクニックなのよ。覚えておくといいわねー。
そうなんですね!
ていうか、たしか前にもちょっとだけ出てきていたわね。if文の話をしたときだったかしら?
なんとっ!(あとで復習しとこう……)
ここがポイント!
関数が引数で受け取れる値を制限すると、エラーを返す必要がなくなる場合があります。このとき、範囲外の値を渡すのは呼び出し側のバグとみなせるので、assertで検出しましょう。