例外
予期しない状況はプログラムの一部である。ユーザーの間違い、プログラミングの誤り、プログラム環境の変化などだ。プログラムは、このような例外的な状況に陥った場合でも、誤った結果を生じないように記述する必要がある。
これらの状況の中には、プログラムの実行を停止させるほど深刻なものもある。例えば、必要な情報が欠落しているか、無効である、あるいはデバイスが正しく機能していない場合などだ。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
ブロックに入れよう。