例外
予期しない状況はプログラムの一部である。ユーザーの間違い、プログラミングの誤り、プログラム環境の変化などだ。プログラムは、このような例外的な状況に陥った場合でも、誤った結果を生じないように記述する必要がある。
これらの状況の中には、プログラムの実行を停止させるほど深刻なものもある。例えば、必要な情報が欠落しているか、無効である、あるいはデバイスが正しく機能していない場合などだ。Dの例外処理メカニズムは、必要に応じてプログラムの実行を停止し、可能な場合は予期しない状況から回復するのに役立つ。
深刻な状況の例としては、前の章の演習で見たように、4つの算術演算子しか認識しない関数に、未知の演算子を渡した場合が考えられる。
上記のswitch文は、case文でリストされていない演算子に対してどう処理すべきか分からないため、例外をスローする。
Phobosでは、スローされる例外の例はたくさんある。例えば、整数の文字列表現をint値に変換するために使用できるto!intは、その表現が有効でない場合に例外をスローする。
プログラムは、to!intによってスローされる例外で終了する。
メッセージの先頭にある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()関数を使って簡単に作成できる。
object.Exception...: サイコロの数が無効: -5
ほとんどの場合、newで例外オブジェクトを明示的に作成して、throwで明示的にスローする代わりに、enforce()関数が呼び出される。例えば、上記のエラーチェックに相当するものは、次のenforce()呼び出しだ。
enforce()とassert()の違いについては、後で説明する。
スローされた例外はすべてのスコープを終了させる
プログラムの実行は、main関数から始まり、そこから他の関数に分岐することがわかった。関数に深く入り込み、最終的にはそこから戻ってくるこの階層的な実行は、木の枝のように見ることができる。
例えば、main()はmakeOmeletという関数を呼び出し、さらにprepareAllという別の関数を呼び出し、さらにprepareEggsという別の関数を呼び出す、というように分岐する。矢印が関数呼び出しを表していると仮定すると、このようなプログラムの分岐は、次の関数呼び出しツリーのように表すことができる。
main │ ├──▶ makeOmelet │ │ │ ├──▶ prepareAll │ │ │ │ │ ├─▶ prepareEggs │ │ ├─▶ prepareButter │ │ └─▶ preparePan │ │ │ ├──▶ cookEggs │ └──▶ cleanAll │ └──▶ eatOmelet
次のプログラムは、出力のインデントのレベルを変化させることで、上記の分岐を実演している。このプログラムは、私たちの目的に適した出力を生成する以外の有用な動作は行わない:
プログラムは次の出力を生成する:
▶ main, 最初の行
▶ makeOmelet, 最初の行
▶ prepareAll, 最初の行
▶ prepareEggs, 最初の行
◁ prepareEggs, 最後の行
▶ prepareButter, 最初の行
◁ prepareButter, 最後の行
▶ preparePan, 最初の行
◁ preparePan, 最後の行
◁ prepareAll, 最後の行
▶ cookEggs, 最初の行
◁ cookEggs, 最後の行
▶ cleanAll, 最初の行
◁ cleanAll, 最後の行
◁ makeOmelet, 最後の行
▶ eatOmelet, 最初の行
◁ eatOmelet, 最後の行
◁ main, 最後の行
関数enteringとexitingは、▶と◁文字を使用して、関数の最初と最後の行を示すために使用される。プログラムは、main()の最初の行から始まり、他の関数に分岐し、最後にmainの最後の行で終了する。
prepareEggs関数に、卵の数をパラメータとして受け取るように変更しよう。このパラメータの特定の値はエラーになるから、卵の数が1未満の場合、この関数は例外をスローするようにしよう。
プログラムをコンパイルできるように、この変更に対応するためにプログラムの他の行も変更する必要がある。冷蔵庫から取り出す卵の数は、main()から始まり、関数から関数へと受け継ぐことができる。変更が必要なプログラムの部分は次の通りだ。-8という無効な値は、例外がスローされた場合にプログラムの出力がいかに変化するかを見やすくするために意図的に使用している。
ここでプログラムを実行すると、例外がスローされた後の行が表示されなくなっていることがわかる。
▶ main, 最初の行
▶ makeOmelet, 最初の行
▶ prepareAll, 最初の行
▶ prepareEggs, 最初の行
object.Exception: 冷蔵庫から-8個の卵を取り出せない
例外がスローされると、プログラムの実行は、prepareEggs、prepareAll、makeOmelet、main()の順で、下位レベルから上位レベルへと終了する。これらの関数を終了すると、それ以上のステップは実行されない。
このような抜本的な終了の根拠は、下位レベルの関数の失敗は、その成功を必要とする上位レベルの関数も失敗とみなすべきであるということだ。
下位関数からスローされた例外オブジェクトは、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文を使用していない、次のプログラムから始めよう。このプログラムは、ファイルからサイコロの目の値を読み込み、それを標準出力に表示する。
readDieFromFile関数は、ファイルとその値が利用可能であることを想定して、エラー条件を無視するように記述されていることに注意。つまり、この関数は、エラー条件に注意を払うことなく、自分のタスクのみを処理している。これは例外の利点である。多くの関数は、エラー条件に注意を払うのではなく、実際のタスクに集中して記述することができる。
the_file_that_contains_the_valueが存在しない状態でプログラムを実行しよう:
ErrnoException型の例外がスローされ、プログラムは "Die value: " を出力せずに終了する。
tryブロック内からreadDieFromFileを呼び出す中間関数をプログラムに追加し、main()にこの新しい関数を呼び出そう。
the_file_that_contains_the_valueがまだ存在しない状態でプログラムを再実行すると、今回は例外で終了しない:
(ファイルから読み込めなかった; 1と仮定)
Die value: 1
新しいプログラムは、tryブロック内でreadDieFromFileの実行を試みる。そのブロックが正常に実行されると、関数はreturn die;文で正常に終了する。tryブロックの実行が指定されたstd.exception.ErrnoExceptionで終了した場合、プログラムの実行はcatchブロックに入る。
ファイルが存在しない状態でプログラムが開始された場合のイベントの要約は以下の通りだ:
- 前のプログラムと同様に、
std.exception.ErrnoExceptionオブジェクトがスローされる (File()によって、このコードによってではない)。 - この例外は
catchによってキャッチされ、 catchブロックの通常の実行中は値1が仮定され、- プログラムは通常の動作を継続する。
catchこれは、スローされた例外をキャッチして、プログラムの実行を継続する方法を見つけるためだ。
別の例として、オムレツのプログラムに戻り、main()関数にtry-catch文を追加しよう。
(注:プロパティ ".msg"については、後で説明する。)
このtryブロックには2行のコードが含まれている。これらの行のいずれかでスローされた例外は、catchブロックによってキャッチされる。
▶ main, 最初の行
▶ makeOmelet, 最初の行
▶ prepareAll, 最初の行
▶ prepareEggs, 最初の行
オムレツを食べられなかった: "冷蔵庫から卵を8個取り出せない"
隣で食べることにする...
◁ main, 最後の行
出力からわかるように、プログラムはスローされた例外のために終了することはなった。エラー状態から回復し、main()関数の終わりまで正常に実行を続ける。
catchブロックは順序通りに処理される
これまで例で使用してきた型Exceptionは、一般的な例外型だ。この型は、プログラムでエラーが発生したことを単に指定するだけだ。また、エラーの詳細を説明するメッセージも含まれるが、エラーのタイプに関する情報は含まれない。
この章でこれまで見てきたConvExceptionとErrnoExceptionは、より具体的な例外型だ。前者は変換エラーに関するもので、後者はシステムエラーに関するものだ。Phobosの他の多くの例外型と同様、その名前が示すとおり、ConvExceptionとErrnoExceptionはどちらも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ブロックを、次のコードのように、最も具体的なものから最も一般的なものの順に並べる必要がある。
finallyブロック
finallyは、try-catch文のオプションのブロックである。このブロックには、例外がスローされたかどうかに関係なく実行すべき式が含まれる。
finallyの動作を確認するために、50%の確率で例外をスローするプログラムを見てみよう。
関数がスローしない場合、プログラムの出力は次のようになる。
foo()の最初の行
tryブロックの最初の行
tryブロックの最後の行
finallyブロックの本体
foo()の最後の行
関数がスローしなかった場合のプログラムの出力は次の通りだ。
foo()の最初の行
tryブロックの最初の行
finallyブロックの本体
object.Exception@deneme.d: エラーメッセージ
この例からわかるように、"tryブロックの最後の行"および"foo()の最後の行"は出力されないが、例外がスローされた場合でも、finallyブロックの内容は実行される。
try-catch文を使用する場合
try-catch文は、例外をキャッチして特別な処理を行う場合に便利だ。
そのため、try-catch文は、特別な処理を行う必要がある場合にのみ使用すべきだ。それ以外の場合は、例外をキャッチせず、例外をキャッチする上位の関数に任せてほしい。
例外プロパティ
例外によってプログラムが終了したときに自動的に出力される情報は、例外オブジェクトのプロパティとしても利用できる。これらのプロパティは、Throwableインターフェースによって提供される。
-
.file: 例外がスローされたソースファイル -
.line: 例外がスローされた行番号 -
.msg: エラーメッセージ -
.info: 例外がスローされたときのプログラムスタックの状態 -
.next: 次の関連例外
finallyブロックは、例外によってスコープを離れるときにも実行されることがわかった。(後の章で説明するように、scope文やデストラクタについても同様である。)
当然、このようなコードブロックも例外をスローすることができる。すでにスローされている例外のためにスコープを離れるときにスローされる例外は、付随的例外と呼ばれる。メインの例外と付随的例外はどちらも、リンクリストデータ構造の要素であり、すべての例外オブジェクトは、前の例外オブジェクトの.nextプロパティを通じてアクセスできる。最後の例外の.nextプロパティの値は、nullである。(nullについては、後の章で説明する。)
以下の例では、3つの例外がスローされている。foo()でスローされるメインの例外と、foo()およびbar()のfinallyブロックでスローされる2つの付随例外だ。プログラムは、.nextプロパティを通じて付随例外にアクセスする。
このプログラムで使用されている概念の一部は、後の章で説明する。例えば、excだけで構成されるforループの継続条件は、excがnullでない限り、という意味だ。
出力:
| エラーメッセージ | fooで例外がスローされた |
|---|---|
| ソースファイル | deneme.d |
| ソースの行 | 6 |
| エラーメッセージ | foo'のfinallyブロックで例外がスローされた |
|---|---|
| ソースファイル | deneme.d |
| ソースの行 | 9 |
| エラーメッセージ | bar'のfinallyブロックで例外がスローされた |
|---|---|
| ソースファイル | deneme.d |
| ソースの行 | 20 |
エラーの種類
例外メカニズムの有用性について見てきた。これにより、プログラムが不正なデータや欠落したデータで継続したり、他の不正な動作をしたりする代わりに、下位レベルと上位レベルの操作をすぐに中止することができる。
しかし、すべてのエラー状態で例外をスローすべきというわけではない。エラーの種類によっては、より適切な対処方法がある場合もある。
ユーザーエラー
一部のエラーはユーザーによって引き起こされる。上記で見たように、プログラムが数値を期待しているにもかかわらず、ユーザーが"hello"のような文字列を入力した場合などが該当する。このような場合、エラーメッセージを表示し、ユーザーに適切なデータを再入力するよう求める方が適切かもしれない。
それでも、データを使用するコードがとにかく例外をスローするならば、データを事前に検証せずにそのまま受け入れて使用しても問題はないだろう。重要なのは、データが適切でない理由をユーザーに通知できることだ。
例えば、ユーザーからファイル名を受け取るプログラムを考えてみよう。無効なファイル名に対処するには、少なくとも2つの方法がある。
- 使用前にデータを検証する:
std.fileモジュールのexists()を呼び出すことで、指定された名前のファイルが存在するかを確認できる:これにより、ファイルが存在する場合にのみデータを開くことができる。残念ながら、
exists()がtrueを返しても、このプログラムが実際にファイルを開く前に、システム上の別のプロセスによってファイルが削除または名前変更された場合、ファイルを開くことができない可能性がある。そのため、以下の方法がより有用かもしれない。
- データを最初に検証せずに使用する方法:ファイルを開けない場合は、
Fileが例外をスローするので、データは有効であると仮定して、すぐに使用を開始することができる。
プログラマーのミス
一部のエラーは、プログラマーのミスによって発生する。例えば、プログラマーは、作成した関数は常に0以上の値で呼び出されると考えているかもしれない。これは、プログラムの設計上、正しいかもしれない。しかし、その関数が0未満の値で呼び出された場合、プログラムの設計またはその実装に誤りがあると考えられる。どちらもプログラミングのエラーとみなすことができる。
プログラマーのミスによって発生するエラーには、例外メカニズムではなく、assertを使用するのがより適切だ。(注釈: assertについては、後の章で説明する。)
プログラムは、assertの失敗で終了する:
assertプログラムの状態を検証し、検証に失敗した場合、ファイル名と行番号を表示する。上記のメッセージは、deneme.dの2行目のアサーションが失敗したことを示している。
予期しない状況
上記の2つの一般的なケース以外の予期しない状況でも、例外をスローすることは適切だ。プログラムの実行を続行できない場合は、スローする以外に方法はない。
スローされた例外をどう処理するかは、この関数を呼び出す上位の関数に任せる。上位の関数は、私たちがスローした例外をキャッチして、状況を修復するかもしれない。
まとめ
- ユーザーエラーが発生した場合は、ユーザーにすぐに警告するか、例外を確実にスロー。例外は、誤ったデータを使用した場合に別の関数によってとにかくスローされる場合もあるし、直接スローすることもできる。
- プログラムのロジックおよび実装の妥当性を検証するには、
assertを使用する。(注釈:assertについては、後の章で説明する。) - 疑わしい場合は、
throwまたはenforce()を使用して例外をスロー。(注:enforce()については、後の章で説明する。) - その例外に対して有用な処理ができる場合にのみ、例外をキャッチ。それ以外の場合は、
try-catch文でコードをカプセル化しないで、その例外を、それに対して何らかの処理ができるコードのより上位の層に残しておく。 catchブロックは、最も具体的なものから最も一般的なものへと並べろ。- スコープを離れるときに必ず実行しなければならない式は、
finallyブロックに入れよう。