例外について色々と考えてみた

オブジェクト倶楽部、コーディング規約の会の「C# コーディング標準」の駄目なところ - ぐるぐる〜から派生して、
「他の例外クラスを継承しただけの例外クラスを作らない」に不同意の理由 - Diary of Dary
例外クラスの指針 - 猫とC#について書くmatarilloの雑記や、さらには TwitterJava の検査例外と非検査例外についての議論へと発展したので例外についてまじめに考えてみた。
あくまで、今の自分の考えなので真に受けない方がいいかも!そもそも経験が少ないので、トンチンカンなことを言ってるかもしれません。
あ、それと、用語は基本的に Java から取ってきています。ただ、メソッドじゃなくて関数を使っているけど、これに深い意味はありません。多分。

例外とは

まず、例外とは一体何者なのか、ということ。
ここでは面倒を避けるために、Meyer 先生の定義を借りることにする。

例外
ルーチンコールの失敗を引き起こす可能性のある実行時イベント
成功と失敗
ルーチンが契約を満たす状態で実行を終えた場合、そのルーチンコールは成功、そうでなければ失敗
失敗するケース
ルーチンの実行中に例外が起き、ルーチンがその例外から回復しない場合に限る

同様に例外処理についても、Meyer 先生の定義を借りることにする。

例外処理
予期されていない異常な状況から回復するためのメカニズム

※Meyer 先生の定義とは、オブジェクト指向入門 第2版 原則・コンセプトの中に書いてある定義のこと。ここでは見やすいようにちょっと修正を加えてある。

例外とアサーション

次に、例外とアサーションの関係について。
多くの言語でアサーションは例外を投げるような実装になっているが、一体どのような状況でどちらを使えばいいのか。
迷うのはほとんど引数のチェックに例外を使うか、アサーションを使うかだと思う。


まず、アサーションの特徴として、

  • 無効にできる / 無効にされる恐れがある
  • アサーションが満たされない場合、それはバグである

というものがある。
なので、無効にされても問題のない部分にアサーションを使い、無効にされては困る部分には例外を使うことになる。


無効にされても問題がないと言うことは、「リリース後にはその成果物のユーザにとって正しく動作していなければならない部分」、つまり非 public な関数に関してはアサーションを使い、「成果物に取って外界とのインターフェイスとなる部分」、つまり public な関数の入力チェックには例外を使う、ということになるだろう。


この考え方は、Meyer 先生のフィルタモジュールの考え方にも合致する。

外界から得られた情報 (グレーで示したコミュニケーションパス) では、事前条件をあてにできない。しかし、図 11.1 の中央に薄いグレーで示された入力モジュールのタスク部分は、正しい処理に必要な条件を満たさないならば、右 (システムの実際の計算に責任のあるモジュール) に情報を受け渡さないことを保証するものである。このアプローチでは、右への黒い点線で示されるソフトウェア同士のコミュニケーションパスに表明が広く使われている。入力モジュールのルーチンで達成される事後条件は処理ルーチンによって課せられた事前条件に適合する (もしくは、前に示した「強い (stronger)」の意味で、それに勝る) ものでなければならない。

オブジェクト指向入門 第2版 原則・コンセプト 11.6.3 表明は入力検査メカニズムにあらず

画像が分かりにくいので少し補足すると、

  • 「グレーで示したコミュニケーションパス」は「外部オブジェクト」と「入力チェックモジュール」の間の点線のこと
  • 「右への黒い点線で示されるソフトウェア同士のコミュニケーションパス」は「入力チェックモジュール」と「処理モジュール」の間の点線のこと

である。
この図で、入力チェックモジュールを public 関数とすると、public 関数の入力チェック部分にはアサーションを使ってはいけない、という結論が得られる。
そもそも、public 関数が入力チェックモジュールと完全には等しいものではないので、これに関しては異論はあると思う。


また、他の見方として、それ以上プログラムを実行し続ける価値があるかどうかによって例外とアサーションを区別することもできる。
すなわち、明らかなバグにはアサーションを使用し、バグかどうか判断できないが、不正である場合は例外を使用する、というものである。
この基準を用いると、

  • public な関数への意図しない入力というのは、バグかどうかは判断できない
  • パッケージでのみ使用される関数への意図しない入力というのは、バグである

となり、やはり public な関数の入力チェックには例外を、そうでない関数の入力チェックにはアサーションを使用する、という結論となる。

例外と継承

ここはちょっとまだ考え中。
例外を安易に継承関係で表すのは危険じゃないかというか、例外は互いに排他的であるべきではないかとか思う一方で、例外にこそ多重継承のうまみを引き出す力があるんじゃないか、とか思っていたり。
ていうか型で例外を捕捉するの無理じゃね?CSSセレクタ以上に柔軟な仕組みがいるとかって考えは多重継承のさらに上を行っている。


まぁ、確かに型で例外を捕捉するのは無理がある気がしないでもない。型にまつわるあれやこれやを考慮しないといけないのもつらい順番気にしなければならないとか、つまり要は instanceof の羅列じゃんとか、継承関係弄ったとたんバグるとか (このあたり、例外は互いに排他的であるべきじゃないか、って考えにつながっていく)。

独自例外

独自例外を作るのは、例外が発生した際により適切なメッセージをログに書き込んで処理を続行、もしくは中断するだけの場合が多い。
この場合、わざわざ中身が空の独自例外を定義するまでもなく、メッセージをフィールドに持たせて適切なコンストラクタ、および例外のビルド用メソッドを定義すれば解決する。
果たして、例外毎に異なる動作をさせたい状況というのがどれほどあるのか、疑問である。
これは、そもそも基本的には例外を使うべきではない、というスタンスから来ているので、以下すべて読んだ上でもう一度この意味を考えて欲しい。


また、Java でも C# でも、例外が使いにくいというのもある。
例外を発生させた部分等が分かるのはいいのだが、それを引き起こした状況はプログラマが指定する必要がある。
できれば、例外が発生した部分での引数やローカル変数等の状況もキャプチャして例外の情報に含んで欲しいものだ。


また、例外オブジェクトを文字列することはできるものの、詳細なコンテキスト情報を例外オブジェクトから引き出そうとすると、文字列を解析するしかない。
例外を catch したコンテキスト情報と合わせて分かりやすいメッセージを構築しようにも、文字列解析が必要だというのは非常に残念なことだ。


これらも独自に (空っぽの) 例外を作るのをためらわせる理由となる。

検査例外と非検査例外

Java 以外の主要な言語で採用されていないことからも、検査例外というものは失敗だったというのは明かだと思っていたのだけれど、Twitter ではあまり同意を得られなかった。
なので、検査例外のイケテナイところもちょっとまとめてみる。


まず、検査例外は発生したその場、もしくは直接の呼出し元で処理しない限り、throws に記述せざるを得ない。
そうしない場合、より上位層の throws を追加する必要が出てくる。このような追加、もしくは変更は、中間のクラスの再リリースという手間も必要となる。
これは、明らかに開放閉鎖原則に違反する。


また、throws もしくは try-catch を強制されるというのは、戻り値としてエラーコードを返す場合と手間としては何らかわりがない。
検査例外は強制される、という利点を持つものの、戻り値の破棄を許容しないようなアノテーションのようなものをコンパイラが認識するように修正するだけでその利点はなくなる。


更に、プログラムは抽象に依存して組み立てるべきであるのに対し、検査例外は実装の詳細をさらけだしすぎる、という問題も持つ。
例外がインターフェイス階層ではなく、クラス階層により構成されているのも問題で、複数の特性をもった例外を構築することができない。


検査例外が必要だという主張の中でしばしば持ち出されるのが以下の例だ。

// Java
try {
    FileInputStream fin = new FileInputStream("hoge.txt");
    // ファイルが存在した場合
    ...
} catch (FileNotFoundException e) {
    // ファイルが存在しなかった場合
    ...
}

検査例外は問い合わせと主処理に分離することが可能であることが多いが、この例でファイルの存在の問い合わせと実際のリソースの獲得を分けてしまうと、そのわずかな時間にファイルが存在しなくなってしまう可能性がある、という主張だ。
しかし、この主張はそもそも例外を使うことを前提にしており、以下のような API にすることで回避が可能だ。

// Java

// コンストラクタではリソースの取得を行わない
FileInputStream2 fin = new FileInputStream2("hoge.txt");
if (fin.tryOpen()) {
    // ファイルが存在した場合
    ...
} else {
    // ファイルが存在しなかった場合
    ...
}

このように、検査例外の代替手段は何かしら存在するならば、検査例外は必要ない。
少なくとも、独自の検査例外を設計する必要性は感じない。


これに対し、非検査例外の代替手段は存在しない。例えば、

hoge.method(piyo);

この呼出しに対して、

if (hoge.isValid(piyo)) {
    hoge.method(piyo);
}

このような呼出しを契約とした API を構築することはナンセンスである。


また、「例外とアサーション」で述べたように、public な関数の入力チェックに使用する例外に関して、Effective Java では、

エラーは JVM が使用するのに予約されているという強い慣例があります。この慣例がほとんど広範囲に受け入れられていますので、新たな Error サブクラスを実装しないことが最善です。したがって、実装するすべてのチェックされない例外は、RuntimeException をサブクラス化するべきです (直接的あるいは間接的に)。

Effective Java 第2版 項目 58 回復可能な例外にはチェックされる例外を、プログラミングエラーには実行時例外を使用する

とあるとおり、Error ではなく RuntimeException を使用するべきという言語の習慣に従うのがいいだろう。
自分の中では、AssertionError は決して catch せず、RuntimeException 系列の例外はログのためのかなり上位の層で catch することもある、という使い分けも行っている。

例外と RAII イディオム

C++ の RAII イディオムを見ると、try-finally はノイズでしかないことが分かる。

// C++
{
    std::ifstream fin("hoge.txt");
    // 何か処理
    ...
} // ここで自動的にfin.close()が実行される
  // 例外が発生しようとしまいと、fin.close()は必ず呼び出される
// Java
FileInputStream fin = null;
try {
    fin = new FileInputStream("hoge.txt");
    // 何か処理
    ...
} finally {
    // fin.closeはIOExceptionを投げる可能性があるため、
    // try-catch-finallyを使用している場合はおそらく
    // fin.close()もtry-catchで囲む必要がある
    if (fin != null) fin.close();
}

さらに問題なのは、Java ではコンストラクタでリソースを取得しない方が望ましいのではないか、ということだ。
例えば、スーパークラスとサブクラスで別々のリソースを取得するとする。

class Parent {
    Resource rsc;
    Parent() {
        rsc = new Resource();
    }
    void close() { rsc.close(); }
}

class Sub extends Parent {
    Resource rsc2;
    Sub() {
        rsc2 = new Resource();  // a
    }
    void close() {
        try { rsc2.close(); }
        finally { super.close(); }
    }
}

何も問題なく見えるが、a の位置で例外が発生すると、Parent の rsc が close されないままとなってしまう。
更に、コンストラクタでリソースを取得することは別の問題もはらんでいる。
今までにも見てきたように、try-finally と相性が非常に悪い点だ。

Sub s = null;   // b
try {
    s = new Sub();  // c
    ...
} finally {
    if (s != null) s.close();   // d
}

このように、b では null で初期化し、c で初期化ではなく代入となり、d では null チェックまで必要となっている。
これを「検査例外と非検査例外」で示した tryOpen 方式 (コンストラクタではリソースを取得しない) を使用することで、以下のように単純に記述することが可能となる。

Sub s = new Sub();  // nullが必要なくなる
try {
    s.tryOpen();    // これを忘れると動かないが、アサーション違反でAssertionErrorが飛ぶはず
    ...
} finally {
    s.close();  // ここではsがnullであることはない
}

この設計は、RAII イディオムが使用できない言語では検討に値するものである。


C++ の RAII イディオムと似たようなものとして C# の using ステートメントや、Python の with ステートメントがあるが、どちらにしても、例外が発生した場合の処理をその場に記述しようとすると、その複雑さ (インデント) の前に try-finally に対する利点は消える。

using (StreamReader reader = new StreamReader("hoge.txt"))
{
    // 何か処理
    ...
} // 例外が発生しようがしまいが、reader.Dispose()が呼び出される
using (StreamReader reader = new StreamReader("hoge.txt"))
{
    try
    {
        // 何か処理
        ...
    }
    catch (IOException e)
    {
        // 例外処理
    }
}

StreamReader reader = null;
try
{
    reader = new StreamReader("hoge.txt");
    // 何か処理
    ...
}
catch (IOException e)
{
    // 例外処理
}
finally
{
    if (reader != null) reader.Close();
}

例外とその代替手段

例外を使用するタイミングとしては、数ある指標の中でも達人プログラマーの

これに対する我々の答えは、「例外とは予期せぬ事態に備えるためのものであり、プログラムの通常の流れの一部には組み込むべきではない」というものです。例外がプログラム中で捕捉されなかった場合、そのプログラムは停止します。このような前提を置いた上で、「すべての例外ハンドラーを除去しても、このプログラムは動作することができるだろうか?」と自問してください。答えが「ノー」であれば、例外ではない状況下で例外が使われているはずです。

達人プログラマー 24 いつ例外を使用するか

がもっとも正しく思える。


これが正しいとすると、例外というものは業務エラー等には使うべきではない、と言える。
では、業務エラーなど異常系はどのように対応すればいいのだろうか?
簡単に思いつくだけでも、以下のような方法が考えられる。

この中で、例外と同系列で語られることが多いのが戻り値を使用する方法で、その次に特殊変数への問い合わせとハンドラの登録が来るだろうが、ポリモフィズムが語られることはあまりない。
が、上記のように「例外ハンドラをすべて取り除いてもプログラムが動作する」ためには、実はポリモフィズム等を使い「本来例外を使うべきでない部分」を極力正常系として扱うべきではないだろうか?

例外 (妄想)

ここからは視点を変えて、どのような例外のメカニズムが考えられるか、と言うものを紹介する。

Icon のゴール指向のような形式

詳細は Wikipedia 等に任せるとして、Icon では真偽値のかわりに成功か失敗か、つまり例外によって条件分岐を実現している。
この方式を少し採用して、if や while の条件を囲む括弧によって例外を false に変換するかどうかを選択できる、というのはどうだろう?

bool isHoge(String str) {
    return Integer.parseInt(str) < 10;
}

void hoge() {
    // 通常のif
    // "z"などを渡すと例外が投げられる
    if (isHoge("3")) {
        System.out.println("hoge");
    }
    // 例外をfalseに変換するif
    if [isHoge("p")] {
        System.out.println("piyo");
    } else {
        System.out.println("foo");
    }
}

このプログラムでは、hogeとfooが出力される。


また、C# で例外が発生した場合に false を返すようなラッパーも考えられる。

public static bool E2f(Func<bool> cond)
{
    try { return cond(); }
    catch { return false; }
}

public static bool E2f<T>(Func<T, bool> cond, T arg1)
{
    try { return cond(arg1); }
    catch { return false; }
}

public static bool E2f<T1, T2>(Func<T1, T2, bool> cond, T1 arg1, T2 arg2)
{
    try { return cond(arg1, arg2); }
    catch { return false; }
}

public static bool E2f<T1, T2, T3>(Func<T1, T2, T3, bool> cond, T1 arg1, T2 arg2, T3 arg3)
{
    try { return cond(arg1, arg2, arg3); }
    catch { return false; }
}

public static bool E2f<T1, T2, T3, T4>(Func<T1, T2, T3, T4, bool> cond, T1 arg1, T2 arg2, T3 arg3, T4 arg4)
{
    try { return cond(arg1, arg2, arg3, arg4); }
    catch { return false; }
}
例外情報をもっとメタな部分に持たせる

throws に指定するのはシグニチャを汚すので、もっとメタな部分に情報を持たせるというのはどうだろうか?

@Throws(HogeException.class)
public void hoge() {
    ...
}

こうすることによる利点はまだ見いだせていないが、他の情報も持たせるなどして活用できないだろうか?

戻り値の評価を強制

「例外と継承」で触れた、戻り値の評価を強制するような文法、もしくはアノテーションというのはどうだろう。

@MustEval
public boolean isHoge() {
    ...
}

void hoge() {
    // エラー
    // isHoge();
    
    // 空のcatchと同じ問題はあるが・・・
    if (isHoge()) {}
}

以上

現時点での例外についての考えを書き散らかしただけのエントリでした。
考えまとめるのを手伝ってくれた id:rf0444 に感謝!