例外

予期しない状況はプログラムの一部である。ユーザーの間違い、プログラミングの誤り、プログラム環境の変化などだ。プログラムは、このような例外的な状況に陥った場合でも、誤った結果を生じないように記述する必要がある。

これらの状況の中には、プログラムの実行を停止させるほど深刻なものもある。例えば、必要な情報が欠落しているか、無効である、あるいはデバイスが正しく機能していない場合などだ。Dの例外処理メカニズムは、必要に応じてプログラムの実行を停止し、可能な場合は予期しない状況から回復するのに役立つ。

深刻な状況の例としては、前の章の演習で見たように、4つの算術演算子しか認識しない関数に、未知の演算子を渡した場合が考えられる。

switch (operator) {

case "+":
    writeln(first + second);
    break;

case "-":
    writeln(first - second);
    break;

case "x":
    writeln(first * second);
    break;

case "/":
    writeln(first / second);
    break;

default:
    throw new Exception(format("Invalid operator: %s", operator));
}
D

上記のswitch文は、case文でリストされていない演算子に対してどう処理すべきか分からないため、例外をスローする。

Phobosでは、スローされる例外の例はたくさんある。例えば、整数の文字列表現をint値に変換するために使用できるto!intは、その表現が有効でない場合に例外をスローする。

import std.conv;

void main() {
    const int value = to!int("hello");
}
D
exceptions.1

プログラムは、to!intによってスローされる例外で終了する。

std.conv.ConvException@std/conv.d(38): std.conv(1157): 値`hello'の型
const(char)[]を型intに変換できない
Undefined

メッセージの先頭にあるstd.conv.ConvExceptionは、スローされる例外オブジェクトの型だ。この名前から、型はstd.convモジュールで定義されているConvExceptionであることがわかる。

例外をスローする文throw

throw文は、上記の例と前の章でも見たことがある。

throw throwは例外オブジェクトをスローし、プログラムの現在の操作を終了する。 ステートメントの後に記述されている式および文は実行されない。この動作は、例外の性質によるものだ。例外は、プログラムが現在のタスクを続行できない場合にスローされなければならない。

逆に、プログラムが継続できる場合、例外をスローする理由はない。そのような場合、関数は何らかの方法を見つけて継続する。

例外の型Exception Error

Throwableクラスから継承された型のみをスローすることができる。Throwableは、プログラムで直接使用されることはほとんどない。実際にスローされる型は、ExceptionまたはErrorから継承された型で、これらはThrowableから継承された型である。例えば、Phobosがスローするすべての例外は、ExceptionまたはErrorから継承されている。

Errorは回復不可能な状態を表し、キャッチすることは推奨されない。そのため、プログラムがスローする例外のほとんどは、 から継承された型だ。(Exception注釈:継承はクラスに関連するトピックだ。クラスについては後の章で説明する。)

Exceptionオブジェクトは、エラーメッセージを表すstring値で構築される。このメッセージは、std.stringモジュールのformat()関数を使って簡単に作成できる。

import std.stdio;
import std.random;
import std.string;

int[] randomDiceValues(int count) {
    if (count < 0) {
        throw new Exception(
            format("Invalid dice count: %s", count));
    }

    int[] values;

    foreach (i; 0 .. count) {
        values ~= uniform(1, 7);
    }

    return values;
}

void main() {
    writeln(randomDiceValues(-5));
}
D
exceptions.2
object.Exception...: サイコロの数が無効: -5

ほとんどの場合、newで例外オブジェクトを明示的に作成して、throwで明示的にスローする代わりに、enforce()関数が呼び出される。例えば、上記のエラーチェックに相当するものは、次のenforce()呼び出しだ。

enforce(count >= 0, format("Invalid dice count: %s", count));
D

enforce()assert()の違いについては、後で説明する。

スローされた例外はすべてのスコープを終了させる

プログラムの実行は、main関数から始まり、そこから他の関数に分岐することがわかった。関数に深く入り込み、最終的にはそこから戻ってくるこの階層的な実行は、木の枝のように見ることができる。

例えば、main()makeOmeletという関数を呼び出し、さらにprepareAllという別の関数を呼び出し、さらにprepareEggsという別の関数を呼び出す、というように分岐する。矢印が関数呼び出しを表していると仮定すると、このようなプログラムの分岐は、次の関数呼び出しツリーのように表すことができる。

main
  │
  ├──▶ makeOmelet
  │      │
  │      ├──▶ prepareAll
  │      │          │
  │      │          ├─▶ prepareEggs
  │      │          ├─▶ prepareButter
  │      │          └─▶ preparePan
  │      │
  │      ├──▶ cookEggs
  │      └──▶ cleanAll
  │
  └──▶ eatOmelet

次のプログラムは、出力のインデントのレベルを変化させることで、上記の分岐を実演している。このプログラムは、私たちの目的に適した出力を生成する以外の有用な動作は行わない:

import std.stdio;

void indent(int level) {
    foreach (i; 0 .. level * 2) {
        write(' ');
    }
}

void entering(string functionName, int level) {
    indent(level);
    writeln("▶ ", functionName, "'s first line");
}

void exiting(string functionName, int level) {
    indent(level);
    writeln("◁ ", functionName, "'s last line");
}

void main() {
    entering("main", 0);
    makeOmelet();
    eatOmelet();
    exiting("main", 0);
}

void makeOmelet() {
    entering("makeOmelet", 1);
    prepareAll();
    cookEggs();
    cleanAll();
    exiting("makeOmelet", 1);
}

void eatOmelet() {
    entering("eatOmelet", 1);
    exiting("eatOmelet", 1);
}

void prepareAll() {
    entering("prepareAll", 2);
    prepareEggs();
    prepareButter();
    preparePan();
    exiting("prepareAll", 2);
}

void cookEggs() {
    entering("cookEggs", 2);
    exiting("cookEggs", 2);
}

void cleanAll() {
    entering("cleanAll", 2);
    exiting("cleanAll", 2);
}

void prepareEggs() {
    entering("prepareEggs", 3);
    exiting("prepareEggs", 3);
}

void prepareButter() {
    entering("prepareButter", 3);
    exiting("prepareButter", 3);
}

void preparePan() {
    entering("preparePan", 3);
    exiting("preparePan", 3);
}
D
exceptions.3

プログラムは次の出力を生成する:

▶ main, 最初の行
  ▶ makeOmelet, 最初の行
    ▶ prepareAll, 最初の行
      ▶ prepareEggs, 最初の行
      ◁ prepareEggs, 最後の行
      ▶ prepareButter, 最初の行
      ◁ prepareButter, 最後の行
      ▶ preparePan, 最初の行
      ◁ preparePan, 最後の行
    ◁ prepareAll, 最後の行
    ▶ cookEggs, 最初の行
    ◁ cookEggs, 最後の行
    ▶ cleanAll, 最初の行
    ◁ cleanAll, 最後の行
  ◁ makeOmelet, 最後の行
  ▶ eatOmelet, 最初の行
  ◁ eatOmelet, 最後の行
◁ main, 最後の行

関数enteringexitingは、文字を使用して、関数の最初と最後の行を示すために使用される。プログラムは、main()の最初の行から始まり、他の関数に分岐し、最後にmainの最後の行で終了する。

prepareEggs関数に、卵の数をパラメータとして受け取るように変更しよう。このパラメータの特定の値はエラーになるから、卵の数が1未満の場合、この関数は例外をスローするようにしよう。

import std.string;

// ...

void prepareEggs(int count) {
    entering("prepareEggs", 3);

    if (count < 1) {
        throw new Exception(
            format("Cannot take %s eggs from the fridge", count));
    }

    exiting("prepareEggs", 3);
}
D

プログラムをコンパイルできるように、この変更に対応するためにプログラムの他の行も変更する必要がある。冷蔵庫から取り出す卵の数は、main()から始まり、関数から関数へと受け継ぐことができる。変更が必要なプログラムの部分は次の通りだ。-8という無効な値は、例外がスローされた場合にプログラムの出力がいかに変化するかを見やすくするために意図的に使用している。

// ...

void main() {
    entering("main", 0);
    makeOmelet(-8);
    eatOmelet();
    exiting("main", 0);
}

void makeOmelet(int eggCount) {
    entering("makeOmelet", 1);
    prepareAll(eggCount);
    cookEggs();
    cleanAll();
    exiting("makeOmelet", 1);
}

// ...

void prepareAll(int eggCount) {
    entering("prepareAll", 2);
    prepareEggs(eggCount);
    prepareButter();
    preparePan();
    exiting("prepareAll", 2);
}

// ...
D

ここでプログラムを実行すると、例外がスローされた後の行が表示されなくなっていることがわかる。

▶ main, 最初の行
  ▶ makeOmelet, 最初の行
    ▶ prepareAll, 最初の行
      ▶ prepareEggs, 最初の行
object.Exception: 冷蔵庫から-8個の卵を取り出せない

例外がスローされると、プログラムの実行は、prepareEggsprepareAllmakeOmeletmain()の順で、下位レベルから上位レベルへと終了する。これらの関数を終了すると、それ以上のステップは実行されない。

このような抜本的な終了の根拠は、下位レベルの関数の失敗は、その成功を必要とする上位レベルの関数も失敗とみなすべきであるということだ。

下位関数からスローされた例外オブジェクトは、1階層ずつ上位関数に転送され、最終的にmain()関数からプログラムが終了する。例外が辿る経路は、次のツリーで強調表示された経路として示すことができる。

     
     
     
main  ◀───────────┐
  ├──▶ makeOmelet  ◀─────┐
  │      │               
  │      │               
  │      ├──▶ prepareAll  ◀──────────┐
  │      │          │                
  │      │          │                
  │      │          ├─▶ prepareEggs  X thrown exception
  │      │          ├─▶ prepareButter
  │      │          └─▶ preparePan
  │      │
  │      ├──▶ cookEggs
  │      └──▶ cleanAll
  │
  └──▶ eatOmelet

例外メカニズムのポイントは、まさにこの、関数呼び出しのすべての層をすぐに終了するこの動作にある。

プログラムの実行を継続する方法を見つけるために、スローされた例外をキャッチすることが適切な場合もある。以下では、catchキーワードについて説明する。

使用する場合throw

throwは、実行を継続できない場合に使用する。例えば、ファイルから生徒数を読み込む関数では、この情報が得られない場合や情報が間違っている場合に例外をスローすることができる。

一方、問題の原因が、無効なデータの入力などのユーザー操作にある場合は、例外をスローするよりも、データを検証するほうが理にかなっている場合がある。多くの場合、エラーメッセージを表示して、ユーザーにデータの再入力を求めるほうが適切だ。

例外をキャッチするtry-catch

これまで見てきたように、スローされた例外は、プログラムの実行をすべての関数から終了させ、最終的にプログラム全体を終了させる。

例外オブジェクトは、関数を終了するまでの経路上の任意の場所で、try-catch文によってキャッチすることができる。try-catch文は、"何かを実行し、スローされる可能性のある例外をキャッチする"という表現をモデル化している。try-catchの構文は次の通りだ。

try {
    // 実行中のコードブロックで、例外が発生する
    // 可能性がある

} catch (an_exception_type) {
    // このタイプの例外がキャッチされた場合に
    // 実行する式

} catch (another_exception_type) {
    // この他のタイプの例外がキャッチされた場合に
    // 実行する式

// ... 必要に応じて、さらにキャッチブロックを追加 ...

} finally {
    // 例外がスローされたかどうかに関係なく
    // 実行する式
}
D

この状態ではtry-catch文を使用していない、次のプログラムから始めよう。このプログラムは、ファイルからサイコロの目の値を読み込み、それを標準出力に表示する。

import std.stdio;

int readDieFromFile() {
    auto file = File("the_file_that_contains_the_value", "r");

    int die;
    file.readf(" %s", &die);

    return die;
}

void main() {
    const int die = readDieFromFile();

    writeln("Die value: ", die);
}
D
exceptions.5

readDieFromFile関数は、ファイルとその値が利用可能であることを想定して、エラー条件を無視するように記述されていることに注意。つまり、この関数は、エラー条件に注意を払うことなく、自分のタスクのみを処理している。これは例外の利点である。多くの関数は、エラー条件に注意を払うのではなく、実際のタスクに集中して記述することができる。

the_file_that_contains_the_valueが存在しない状態でプログラムを実行しよう:

std.exception.ErrnoException@std/stdio.d(286): モード`r'で
ファイル`the_file_that_contains_the_value'を開けない(そのような
ファイルやディレクトリはない)
Undefined

ErrnoException型の例外がスローされ、プログラムは "Die value: " を出力せずに終了する。

tryブロック内からreadDieFromFileを呼び出す中間関数をプログラムに追加し、main()にこの新しい関数を呼び出そう。

import std.stdio;
import std.exception;

int readDieFromFile() {
    auto file = File("the_file_that_contains_the_value", "r");

    int die;
    file.readf(" %s", &die);

    return die;
}

int tryReadingFromFile() {
    int die;

    try {
        die = readDieFromFile();

    } catch (std.exception.ErrnoException exc) {
        writeln("(Could not read from file; assuming 1)");
        die = 1;
    }

    return die;
}

void main() {
    const int die = tryReadingFromFile();

    writeln("Die value: ", die);
}
D
exceptions.6

the_file_that_contains_the_valueがまだ存在しない状態でプログラムを再実行すると、今回は例外で終了しない:

(ファイルから読み込めなかった; 1と仮定)
Die value: 1

新しいプログラムは、tryブロック内でreadDieFromFileの実行を試みる。そのブロックが正常に実行されると、関数はreturn die;文で正常に終了する。tryブロックの実行が指定されたstd.exception.ErrnoExceptionで終了した場合、プログラムの実行はcatchブロックに入る。

ファイルが存在しない状態でプログラムが開始された場合のイベントの要約は以下の通りだ:

catchこれは、スローされた例外をキャッチして、プログラムの実行を継続する方法を見つけるためだ。

別の例として、オムレツのプログラムに戻り、main()関数にtry-catch文を追加しよう。

void main() {
    entering("main", 0);

    try {
        makeOmelet(-8);
        eatOmelet();

    } catch (Exception exc) {
        write("Failed to eat omelet: ");
        writeln('"', exc.msg, '"');
        writeln("Will eat at neighbor's...");
    }

    exiting("main", 0);
}
D

(注:プロパティ ".msg"については、後で説明する。)

このtryブロックには2行のコードが含まれている。これらの行のいずれかでスローされた例外は、catchブロックによってキャッチされる。

▶ main, 最初の行
  ▶ makeOmelet, 最初の行
    ▶ prepareAll, 最初の行
      ▶ prepareEggs, 最初の行
オムレツを食べられなかった: "冷蔵庫から卵を8個取り出せない"
隣で食べることにする...
◁ main, 最後の行

出力からわかるように、プログラムはスローされた例外のために終了することはなった。エラー状態から回復し、main()関数の終わりまで正常に実行を続ける。

catchブロックは順序通りに処理される

これまで例で使用してきた型Exceptionは、一般的な例外型だ。この型は、プログラムでエラーが発生したことを単に指定するだけだ。また、エラーの詳細を説明するメッセージも含まれるが、エラーのタイプに関する情報は含まれない。

この章でこれまで見てきたConvExceptionErrnoExceptionは、より具体的な例外型だ。前者は変換エラーに関するもので、後者はシステムエラーに関するものだ。Phobosの他の多くの例外型と同様、その名前が示すとおり、ConvExceptionErrnoExceptionはどちらもExceptionクラスから継承されている。

Exceptionおよびその兄弟クラスErrorは、最も一般的な例外型であるThrowableからさらに継承されている。

可能ではあるが、Error型のオブジェクトや、Errorから継承された型のオブジェクトをキャッチすることは推奨されない。Errorよりも一般的であるため、Throwableもキャッチすることは推奨されない。通常キャッチすべきは、Exception自体を含む、Exception階層の下にある型だ。

           Throwable (キャッチしないことを推奨)
             ↗   ↖
    Exception     Error (キャッチしないことを推奨)
     ↗    ↖        ↗    ↖
   ...    ...    ...    ...

注釈:階層表現については、後で継承の章で説明する。上のツリーは、Throwableが最も一般的であり、ExceptionおよびErrorがより具体的であることを示している。

特定の型の例外オブジェクトをキャッチすることは可能だ。例えば、システムエラーを検出して処理するために、ErrnoExceptionオブジェクトを具体的にキャッチすることは可能だ。

例外は、catchブロックで指定されている型と一致する場合にのみキャッチされる。例えば、SpecialExceptionTypeをキャッチしようとするキャッチブロックは、ErrnoExceptionはキャッチしない。

try catchブロックの実行中にスローされる例外オブジェクトの型は、catchブロックで指定されている型と、catchブロックが記述されている順に照合される。オブジェクトの型が ブロックの型と一致する場合、その例外はそのcatchブロックによってキャッチされたものとみなされ、そのブロック内のコードが実行される。一致が見つかった場合、残りのcatchブロックは無視される。

catchブロックは、最初のブロックから最後のブロックの順に照合されるため、catchブロックは、最も具体的な例外型から最も一般的な例外型へと順番に並べる必要がある。したがって、そのタイプの例外をキャッチすることが妥当である場合は、Exception型を最後のcatchブロックで指定する必要がある。

例えば、学生レコードに関するいくつかの特定のタイプの例外をキャッチしようとするtry-catch文では、catchブロックを、次のコードのように、最も具体的なものから最も一般的なものの順に並べる必要がある。

try {
    // 学生の記録に関する操作で、例外が発生する可能性があるもの ...

} catch (StudentIdDigitException exc) {

    // 学生IDの数字に関する
    // エラーに特化した例外

} catch (StudentIdException exc) {

    // 学生IDに関するより一般的な例外だが
    // 必ずしもその数字自体に関するものではない

} catch (StudentRecordException exc) {

    // 学生記録に関するさらに一般的な例外

} catch (Exception exc) {

    // 学生記録と関連しない可能性のある
    // 最も一般的な例外

}
D
finallyブロック

finallyは、try-catch文のオプションのブロックである。このブロックには、例外がスローされたかどうかに関係なく実行すべき式が含まれる。

finallyの動作を確認するために、50%の確率で例外をスローするプログラムを見てみよう。

import std.stdio;
import std.random;

void throwsHalfTheTime() {
    if (uniform(0, 2) == 1) {
        throw new Exception("the error message");
    }
}

void foo() {
    writeln("the first line of foo()");

    try {
        writeln("the first line of the try block");
        throwsHalfTheTime();
        writeln("the last line of the try block");

    // ... ここに1つ以上のキャッチブロックがあるかもしれない ...

    } finally {
        writeln("the body of the finally block");
    }

    writeln("the last line of foo()");
}

void main() {
    foo();
}
D
exceptions.8

関数がスローしない場合、プログラムの出力は次のようになる。

foo()の最初の行
tryブロックの最初の行
tryブロックの最後の行
finallyブロックの本体
foo()の最後の行

関数がスローしなかった場合のプログラムの出力は次の通りだ。

foo()の最初の行
tryブロックの最初の行
finallyブロックの本体
object.Exception@deneme.d: エラーメッセージ

この例からわかるように、"tryブロックの最後の行"および"foo()の最後の行"は出力されないが、例外がスローされた場合でも、finallyブロックの内容は実行される。

try-catch文を使用する場合

try-catch文は、例外をキャッチして特別な処理を行う場合に便利だ。

そのため、try-catch文は、特別な処理を行う必要がある場合にのみ使用すべきだ。それ以外の場合は、例外をキャッチせず、例外をキャッチする上位の関数に任せてほしい。

例外プロパティ

例外によってプログラムが終了したときに自動的に出力される情報は、例外オブジェクトのプロパティとしても利用できる。これらのプロパティは、Throwableインターフェースによって提供される。

finallyブロックは、例外によってスコープを離れるときにも実行されることがわかった。(後の章で説明するように、scopeやデストラクタについても同様である。)

当然、このようなコードブロックも例外をスローすることができる。すでにスローされている例外のためにスコープを離れるときにスローされる例外は、付随的例外と呼ばれる。メインの例外と付随的例外はどちらも、リンクリストデータ構造の要素であり、すべての例外オブジェクトは、前の例外オブジェクトの.nextプロパティを通じてアクセスできる。最後の例外の.nextプロパティの値は、nullである。(nullについては、後の章で説明する。)

以下の例では、3つの例外がスローされている。foo()でスローされるメインの例外と、foo()およびbar()finallyブロックでスローされる2つの付随例外だ。プログラムは、.nextプロパティを通じて付随例外にアクセスする。

このプログラムで使用されている概念の一部は、後の章で説明する。例えば、excだけで構成されるforループの継続条件はexcnullでない限り、という意味だ。

import std.stdio;

void foo() {
    try {
        throw new Exception("Exception thrown in foo");

    } finally {
        throw new Exception(
            "Exception thrown in foo's finally block");
    }
}

void bar() {
    try {
        foo();

    } finally {
        throw new Exception(
            "Exception thrown in bar's finally block");
    }
}

void main() {
    try {
        bar();

    } catch (Exception caughtException) {

        for (Throwable exc = caughtException;
             exc;    // ← 意味: excが'null'でない限り
             exc = exc.next) {

            writefln("error message: %s", exc.msg);
            writefln("source file  : %s", exc.file);
            writefln("source line  : %s", exc.line);
            writeln();
        }
    }
}
D
exceptions.9

出力:

エラーメッセージfooで例外がスローされた
ソースファイルdeneme.d
ソースの行6
エラーメッセージfoo'のfinallyブロックで例外がスローされた
ソースファイルdeneme.d
ソースの行9
エラーメッセージbar'のfinallyブロックで例外がスローされた
ソースファイルdeneme.d
ソースの行20
エラーの種類

例外メカニズムの有用性について見てきた。これにより、プログラムが不正なデータや欠落したデータで継続したり、他の不正な動作をしたりする代わりに、下位レベルと上位レベルの操作をすぐに中止することができる。

しかし、すべてのエラー状態で例外をスローすべきというわけではない。エラーの種類によっては、より適切な対処方法がある場合もある。

ユーザーエラー

一部のエラーはユーザーによって引き起こされる。上記で見たように、プログラムが数値を期待しているにもかかわらず、ユーザーが"hello"のような文字列を入力した場合などが該当する。このような場合、エラーメッセージを表示し、ユーザーに適切なデータを再入力するよう求める方が適切かもしれない。

それでも、データを使用するコードがとにかく例外をスローするならば、データを事前に検証せずにそのまま受け入れて使用しても問題はないだろう。重要なのは、データが適切でない理由をユーザーに通知できることだ。

例えば、ユーザーからファイル名を受け取るプログラムを考えてみよう。無効なファイル名に対処するには、少なくとも2つの方法がある。

プログラマーのミス

一部のエラーは、プログラマーのミスによって発生する。例えば、プログラマーは、作成した関数は常に0以上の値で呼び出されると考えているかもしれない。これは、プログラムの設計上、正しいかもしれない。しかし、その関数が0未満の値で呼び出された場合、プログラムの設計またはその実装に誤りがあると考えられる。どちらもプログラミングのエラーとみなすことができる。

プログラマーのミスによって発生するエラーには、例外メカニズムではなく、assertを使用するのがより適切だ。(注釈: assertについては、後の章で説明する。)

void processMenuSelection(int selection) {
    assert(selection >= 0);
    // ...
}

void main() {
    processMenuSelection(-1);
}
D
exceptions.11

プログラムは、assertの失敗で終了する:

core.exception.AssertError@deneme.d(2): Assertionの失敗
Undefined

assertプログラムの状態を検証し、検証に失敗した場合、ファイル名と行番号を表示する。上記のメッセージは、deneme.dの2行目のアサーションが失敗したことを示している。

予期しない状況

上記の2つの一般的なケース以外の予期しない状況でも、例外をスローすることは適切だ。プログラムの実行を続行できない場合は、スローする以外に方法はない。

スローされた例外をどう処理するかは、この関数を呼び出す上位の関数に任せる。上位の関数は、私たちがスローした例外をキャッチして、状況を修復するかもしれない。

まとめ