エラーハンドリングの歴史

はじめに

2011年現在、ソフトウェアの複雑化により、プログラムは正常系の動作だけができればいい時代は終わっている。

###ゲームにおけるエラーハンドリング
スタンドアロンで動作するゲームが主流であったころ、ゲーム開発者はパフォーマンスのためにライブラリを含む全てのプログラムからエラー処理を省き、デバッグ時にassertで全てのバグを取り去り、正常系のみのプログラムをリリースしていた。現代のゲームは通信処理や、OS・コントローラからのイベントなどにより、正常系のみでは対処できずエラーハンドリングを正しく行う必要が出てきた。

ソフトウェアにおけるエラーハンドリング

ソフトウェアは、元々マルチタスクやメモリの制限などにより、昔からエラーハンドリングは行われてきたが、
現代のソフトウェアはユーザー人口がとても多かったり、クレジットカードや個人情報の扱いなどで機密性がより求められるようになってきたため、
エラーハンドリングの重要性は一層高くなってきた。

エラーハンドリングは軽視されがち

エラーハンドリングは、多くの理由から軽視されがちだ。
たとえば以下のような理由が考えられる:

  • 正常系のプログラムを書くだけで手一杯で異常系を書く期間がとれない
  • 予算の削りどころ(セキュリティやテストなども削りどころ)
  • 想定不足

エラーハンドリングの歴史

エラーハンドリングがどのように進化してきたかを見ていこう。

1. 正常値とエラー値

いつからかは把握していないが、昔は各ライブラリがそれぞれ異なる規定の下、「0を正常値と見なす」はたまた「1を正常値と見なす」「それ以外の値をエラー値とする」といった決まりごとを作った。
たとえば、Xライブラリ(仮)は「正常値であれば0、それ以外の値はエラーである」という仕様にした。

int f(int x)
{
    if (x == 1)
        return 0;
    else
        return 1;
}

ユーザーはその決まりごとをドキュメントで確認し、気をつけてコーディングする必要があった。

int result = f(3);
if (result == 0) {
    // 正常系の処理
}
else {
    // 異常系の処理
}

この方法は、いろいろなライブラリを混ぜて使う場合に問題が起こりやすくなる。
Yライブラリ(仮)が「正常値であれば1、マイナス値はエラーである」という仕様にしたとしよう。

int g(x == 1)
{
    if (x == 1)
        return 1;
    else
        return -65536;
}

このような、規定の異なる複数のライブラリを同時に使う場合、もしくは規定の異なる別のライブラリを使ったことがあるという経験が、
正常値と異常値の判定ミスというヒューマンエラーの原因になりえた。

2. ヌルポインタ

主にC言語では、正常値として有効な値を指すポインタ、異常値としてヌルポインタを渡す/返す設計がよく行われていた。
ここでの問題は、C言語におけるプログラミングのイディオムとして、大きいデータのコピーによるオーバーヘッドを避けるために
データをなんでもかんでもポインタで渡していたことだ。
これのせいで、「この関数のパラメータはヌルポインタでありえていいのか」が非常にわかりにくかった。

また、ヌルポインタには「どこでどんな理由でヌルポインタになったのか」という情報がないため、エラーが追いにくい傾向がある。

3. アサート

アサートは、「この関数のパラメータがヌルポインタになることはあってはならない」というような表明を行うための機能だ。
これはデバッグ時に取り除くべきエラーを検知するために使用する。
この機能はリリース時には無効となるため、リリース時にも起こりえるエラーなのかどうかの設計判断が重要となる。

int f(int* p)
{
    assert(p != NULL);
}

4. 列挙値

これまでは、エラーとなる理由を表現するためにエラーコードという整数値が使われてきた。
Windows APIのGetLastError()関数がその代表的な例だ。

しかし、単なる整数値では、ユーザーはまったく関係のない値との比較を書いてしまう可能性がある。

int error_code = GetError();

switch (error_code) {
    case 0: return "正常";
    case 1: return "接続失敗!";
    case 2: return "送信失敗!";
    case 3: return "受信失敗!";

    default: // 何が入ってくるかわからない
        return error_code + ": 不明なエラー";
}

そこで、列挙値というものが登場し、現れる可能性を列挙して、その範囲の値しか入り得ないようにすることができるようになった。

enum ErrorValue {
    Success,
    ConnectError,
    SendError,
    ReceiveError
};

ErrorValue error_code = GetError();

switch (error_code) {
    case Success:      return "正常";
    case ConnectError: return "接続失敗!";
    case SendError:    return "送信失敗!";
    case ReceiveError: return "受信失敗!";
}

これによって、想定できる値をすべて挙げることができる場合において、
意図しない値が代入される可能性をなくすことができた。

5. 論理値

bool(C++), boolean(Java), Bool(Haskell)のように、いろんな言語で採用されている論理値と呼ばれるものは、正常値を表す値(true)と、異常値を表す値(false)の2つのみがありえる型とその値である。
これは、現代においてあらゆる環境でデフォルトで使用されるエラーハンドリングとなっている。

bool b1 = true;  // 正常値
bool b2 = false; // 異常値

論理値の問題点としては、異常値が「なぜ異常になったのか」という情報が持てないということだ。

6. 例外

例外は、深い階層で発生したエラーを上位層でハンドリングするために、エラー値を伝搬していくのが手間であるという動機の元に導入された。

bool f()
{
    return false;
}

bool g()
{
    ...
    bool result = f(); // 関数g()は、関数f()の結果を返す
    ...
    return result;
}

void foo()
{
    bool result = g();
}

このプログラムでは、深い階層のどこでエラーが発生したかわかりにくく、また関数g()の直接のエラーでないにも関わらず、関数g()は関数f()の結果を返す必要がある。
これは例外を使うと以下のように書ける:

void f()
{
    throw runtime_error("fでエラーがでた");
}

void g()
{
    ...
    f();
    ...
}

void foo()
{
    // エラーが起こりえるブロック
    try {
        g();
    }
    // tryブロック内で例外が投げられたら捕捉する
    catch (runtime_error& e) {
        error_message(e.what()); // 例外メッセージを表示する
    }
}

例外は、いくつかのケースでプログラムをわかりやすくした。
C++では例外におけるオーバーヘッドが大きいため、サイズ制限などが厳しい環境では例外が使えない場合が多い。

例外の問題点としては、捕捉ミスによるアプリケーションエラーが起こりやすいということと、どの関数がどの例外を投げうるのかがわかりにくいということだ。
Javaでは検査例外という機構を用意し、「この関数はこの例外を投げうる」ということを明示することができたが、近年のジェネリックプログラミングの流れにより、「この関数に渡されるあらゆる型の同名の関数が全てこの例外を投げうる」というのを明示するのは難しいと判断され、ScalaのようなJavaの後継言語では検査例外は採用されなかった。

参照:

7. 無効値の特別な表現

C++, Haskell, Scalaのような静的型付け言語では、エラーが起こりえる型であることを明示する方向に進んだ。
ここでは「8. パターンマッチ」との差別化のため、C++におけるboost::optional<T>型で説明する。

1. 正常値とエラー値」で解説した「正常値/エラー値の仕様がライブラリによってまちまちで、仕様を調べないとミスが起こりやすい」という問題に対処するため、Boost C++ Librariesでは、boost::optional<T>という型を提供することにより、型によらない無効値の表現と、その型の値。この2種類が起こりえる型を表現した。
これにより、正常値、エラー値というものをライブラリの仕様ではなく、エラーが起こりえる型という形で、ヒューマンエラーの軽減に取り組んだ。

boost::optional<int> a = 1; // int型の値全てが正常値
boost::optional<int> b = boost::none; // noneが無効値

if (a) { // 正常値かどうかの判定
    int x = a.get(); // 中身を取り出す
}

代表的な例:

言語 表現
C++ boost::optional<T>
Haskell Maybe
Scala Option
C# null許容型

8. パターンマッチ

パターンマッチは、 「[7. 無効値の特別な表現][#option]」をさらに進化させ、型の判別と分解を同時に行うことができる。
boost::optional<T>型は、値が正常値かどうかの判定と、中身を取り出す操作がわかれているため、無効値を指した状態で中身を取り出すとアプリケーションエラーになるだろう。

関数型言語一般で採用されているパターンマッチと呼ばれる機構は、この判定と取り出しを同時に行うことで、ヒューマンエラーを除去することができる。
Scala言語のOption型で例示する:

a : Option[int] = Some(1)
b : Option[int] = None

match (a) {
    case Some(x) => print x
    case _       => print "error"
}

ここでは、case Some(x)という式によって、「正常値だった場合には中身を取り出して変数xに代入する」ということをしている。
これによって、無効値を指しているのに中身を取り出そうと間違うことができなくなった。

参考:

9. 代数データ型

C++で論理値を表現するためのbool型は、歴史的経緯もあって、truefalse以外でも任意のint値を格納できてしまい、たびたび意図しない値の代入による問題が起こっていた。

Haskellのような言語では、論理値は代数データ型と呼ばれる機能によって定義される。
Bool型を以下のように定義した場合、

data Bool = True | False

Bool型は、「True」と「False」という2つの値以外は、決して格納できない型となる。

f :: Bool
f = True
 
g :: Bool
g = False
 
h :: Bool
h = 0     -- エラー!

代数データ型は、「0という値が入りえない自然数の型」「要素が空になりえないリスト型」などに応用でき、型によって多くの保証を生み出すことに成功している。

参照:

他の言語でも、列挙型(enum)によってある程度表現できる。

10. Either

7. の無効値と有効値の統一的な表現」により、エラーが起こりえる箇所がより明確となった。
だが、無効値には「無効値となった理由」という情報が欠落しているため、HaskellのMaybeモナドのような「失敗するかもしれない計算を連続で行い、失敗したらその後の計算を行わない」というアプローチにおいて、どの段階でどういう原因でエラーとなったのかがわからず、デバッグが困難であるという問題が起き始めた。

参考: data Maybe — harmful?

そこで、エラー情報付きの無効値を表現する方法として、Eitherという型が多くの言語でライブラリとして導入されるようになった。
Eitherは、「LeftRightどちらかにデータが入っている」というエラー以外の目的にも使用できる普遍的な型ではあるが、エラーハンドリングに使うのが一般的である。

有効値が入っていたら「正しい」を意味するRightにデータが入り、無効値が入っていたらLeftにエラーとなった理由を入れる、という文化がある。

代表的な例:

言語
Haskell Either
C++ expected

参照:

11. 自動再起動

now studying Erlang…

例外安全性

参照