例外処理
例外処理(れいがいしょり、exception handling)とは、IT業界で用いられる専門用語で、システムの設計で想定されておらず、ユーザー操作によって解決できない問題に対処するための処理である。例外処理の結果として問題が解決されないとシステム障害になる。システム停止やデータ破損の原因になり、ユーザーに損害を与える可能性があるため、システム開発で例外処理は重要視されている。
システムの設計で想定されておらず、継続不能や継続すると問題になる様な状態としては、次のようなものが挙げられる。
オペレーティングシステム等、システムの設定ミス
数値入力を要求している場合での、英単語の入力
存在しないデータベースのテーブル/カラムやの指定
許されない演算(0での除算や実数演算で解が虚数になる演算など)
不正な値が与えられたポインタで参照する、或いは機械語レベルで不正な値が与えられたインデックスレジスタ等を用いてメモリ参照することとなった場合
プログラミング言語において、何も参照していないハンドルやポインター(Nullポインタ)を参照して操作しようとした場合(例としてJavaにおけるNullPointerExceptionなど)。
例外処理の動作としては、システムを構成するプログラムの各呼び出し階層で、呼び出し先が想定していない入力値を受け取って問題が起きた場合に、問題に合わせた例外を発生させて呼び出し元に処理を返す。呼び出し元に例外を返す事によって、呼び出し元で問題解決が行われることに期待するが、どの呼び出し階層の例外処理でも問題解決できない場合は、システムの内部状態に矛盾が残り、システム障害となる。例外発生の後、システムが動作していても、例外への対処結果が設計から逸脱している場合、システムの内部状態に矛盾を来しており、ストレージやデータベースやネットワークに無意味なデータを出力する可能性が生じ、データ破損のみならず、連携する他システムにも矛盾が伝播して広範なシステム障害に繋がる可能性がある。従って、例外処理はシステム障害を未然に防ぐ意味で非常に重要である。
例外(exception)はシステム担当者が問題解決を行う必要がある。例外の問題解決手段には例外を無視することも含まれるが、明確な根拠をもって無視する必要がある(設計の一環として一部の例外を無視しても問題ないと判断する,連携する他システムのメンテナンス中で例外発生が不可避な場合はメンテナンス完了を待つ)。例外に対して、ユーザーが解決すべき問題はエラー(error)と呼ぶ(但し、業務システム開発ではエラーを業務エラー、例外をシステムエラーと表現する場合もあり、技術者間での厳密な統一見解は存在しない)。何らかのシステム開発を行う会社では、例外への対処が不適切であるとユーザーに損害を与えるため、システム障害を発生させるような例外発生は製品の瑕疵として扱う必要がある。
あるコード内を実行中の失敗が、メモリリーク、格納データの不整合、不正な出力などの有害な効果を生じないとき、そのコード片は例外安全であると言う。例外安全なコードは例外が発生したとしてもそのコードが備える不変条件を満たさなければならない。例外安全性にはいくつかのレベルがある:
不送出保証、もしくは失敗透過性: 操作は成功するものと保証され、例外的な状況の中であっても全ての要求を満たす。もし例外が発生したとしても、その例外をより上位に送出はしない。(最高レベルの例外安全性)
強い例外安全性、コミット・オア・ロールバックセマンティクスあるいは無変更保証: 操作は失敗することがあるが、失敗した操作は副作用を起こさないことが保証され、すべてのデータは元の値を保持する。
基本的例外安全性: 失敗した操作の不完全な実行によって副作用が起こることがあるが、状態の不変条件は保たれる。あらゆる格納データは、もはや実行前とは異なるとしても、有効な値を持つ。
例外安全性なし: 何も保証されない。(最低レベルの例外安全性)
幾つかのプログラミング言語では組み込みの例外処理機能を用意している。例えばAda、C++、Java、Scala、C#、JavaScript、OCamlがそうである。これらの言語では専用の言語機能によってプログラマが例外処理を記述する手間を軽減している。
例外が発生したことを見落として正常時の動作を継続してしまうと、より深刻・致命的な異常を招くおそれがある。それを避けるには例外が発生したことのチェックを綿密に行い、例外が検出された場合には適切な事後処理を行う他ない。しかし、大規模なプログラムではこのようなチェックは膨大なものとなり、本来目的としている正常時の処理よりも多くの記述を必要とする場合すらある。
そこで、これらの言語では例外の発生チェックをほぼ自動化している。例外が発生すると現在の処理を中断する。発生した例外の事後処理を担当できるハンドラを探して次々にコールスタック(関数呼び出し)を遡り、適切なハンドラを見つけるとそれに事後処理を任せる。これにより、遡る途中にあったこの例外を処理する能力を持たない処理は自動的に中断されることになる。
Schemeでは言語レベルでの例外処理を持たないが、これは継続が存在するため例外をライブラリレベルで実現できるからである(標準仕様であるSRFI-34で定義されている)。
tryブロック中で呼び出した関数Function0()が(1)のthrowを実行すると、Function1()のcatch文へと制御が移る。C++では後発の言語とは異なり、std::exceptionあるいはその派生型以外の型の値でもthrowで投げることができ、(3)(4)(5)の様に型に対応したcatch文で捕捉することができる。なお、(1)では例示のため複数のthrowを書いているが、実際には1個目のthrowを実行した時点でcatch文に移動する。
例外構文には例外が存在しないことを明示するnoexceptが標準化されている。
C++の特徴的な構文として、(6)の省略子を用いたcatchが存在する。あらゆる例外を捕捉可能であり、他のcatchが取りこぼした例外でも捕まえる必要がある場合に用いる。値を指定しないthrowを捕まえられるのも省略子を用いたcatchだけである。また、Microsoft Visual C++といった一部の処理系では、コンパイラオプションの指定によりC++例外だけでなくOSが投げた構造化例外も省略子を用いたcatchで捕捉できる。catch文の中では(7)のように引数の無いthrowを用いた場合、例外の再送を意味する。省略子を用いたcatchの場合は例外情報を判断できないため必須であるが、省略子を使わないcatchでも派生型の例外を基底型で受け取ってしまった場合のスライシングを防ぐために必要となる。
tryブロックは(8)のように関数全体に適用することもできる。これをfunction-try-blockと言う。catch文では局所変数を参照できず引数だけしか参照できないが、コンストラクタの初期化リストで発生した例外やデストラクタから投げられた例外はこの書き方でしか捕捉することができない。
例外処理構文を最初に実装したのはAdaであるが、C++の例外処理構文はJavaやJavaScript、C#など多くの後発言語の規範となった。
C++の例外指定 (Exception Specification、例外仕様とも) は、関数から伝達される例外の種類を明示する言語機能である。例えば void f() throw(int) はint型のエラーをthrowしうることを明示している。例外指定はコンパイラによる静的例外検査を想定して用意された機能だが各コンパイラには実装されず、C++11で非推奨になり、C++17をもって廃止された。
Javaでは例外はクラスとして実装する。例外を「投げる(throw)」メソッドはthrows Exceptionのように指定する。Javaプラットフォーム上でJava言語を使用し、発生する例外がjava.lang.Exceptionを継承しているが、しかし<tt>java.lang.RuntimeExcption</tt>を継承していない場合、<tt>try</tt>/<tt>catch</tt>文で例外処理を明示的に記述するか、メソッドに<tt>throws</tt>を追加する必要がある。ただし、Javaプラットフォーム上で動く言語でも、GroovyやScalaなど、Java言語以外の多くは、<tt>RuntimeException</tt>以外の例外に対して必ずしも明示的に記述しなくても良くなっている。初期のJava(JDK1.0)では、I/O処理など他の手段では例外の発生を回避することができない種類の例外に対しては<tt>RuntimeException</tt>を継承させないという設計思想になっていた。
言語機能としては例外処理構文が存在しないが別途例外処理を備える言語も存在する。Smalltalkは言語機能として制御構文が殆どない。このため例外処理構文もブロックを組み合わせたメッセージ式として記述するようになっている。言語機能ではないため極めて柔軟性がありブロックの戻り値を変更したり例外が発生した式の途中から復帰したりなど様々な制御が可能になっている。
例外処理の過程では処理の流れが通常の制御とは大きく変化することとなるが、これを(エラー処理以外の目的で、正常系において)積極的に利用することは、アンチパターンとされることもある。
一方、RubyやPythonでは、イテレータが終端に達するという、無限ループでなければ必ず発生する事象により例外が起きることもあるほか、Rubyでは例外処理と関係なく大域脱出を行う制御構造も用意されている。
Smalltalkでも例外はエラー処理以外の通知として使われる。Smalltalkの例外 (Exception) はエラー (Error) と通知 (Notification) からなり、EOFの通知やスレッド間の割り込み終了通知等に使われている。またSmalltalkは値を検索した結果値が見つからなかった場合の戻り値としてnil (null) を返さない傾向があり、値が見つからなければ例外を投げる。ただし単純な例外処理というパターンがあり、値が見つからない場合でも例外構文を使わず安全に回復する手段を用意している。
通知例外にはエラー例外と異なる特徴的な点がある。通知を投げた場合は、エラーと異なり補足しなければ処理を中止せずそのまま続行となる。
例外処理(例外オブジェクト)をサポートしないCなどの言語では、従来から関数(サブルーチン)の戻り値によってその関数(処理)の成否を判定する方法がとられてきた。慣例的に、関数の戻り値を32ビット整数値などで宣言して、関数が成功した場合は<tt>0</tt>を返し、失敗した場合はエラーコードとして何らかの負数を返すことが多い。さらに簡略化して、成否の結果を真偽値1/0で返すだけにすることもある。戻り値がポインタ型である場合は、成功した場合に有効なポインタすなわち非NULLを返し、失敗した場合に無効なポインタすなわちNULLを返すのが通例である。標準ライブラリや各種APIでは、詳細を伝えるエラーコードを別途errnoのようなグローバル変数に格納することもある。各エラーコードによって失敗の原因を定義しておき、呼び出し側で原因を判定する。
このような戻り値による処理の成否判定には下記のような問題点がある。
戻り値は無視できるため、呼び出し先でエラーが発生しても通常通り処理を継続するプログラムを記述できてしまう。
エラーコードはたいてい32ビットの整数値でしかないため、それ以上の詳細な情報(例えば具体的原因および異常発生個所などを示すエラーメッセージ)を付加することができない。
直前のエラー情報をグローバル変数に格納する設計は、マルチスレッド対応の際に別途スレッドローカルストレージ化が必要となる。
戻り値を毎回チェックする判定文を記述するのが煩雑である。
戻り値に正常系の値と異常系の値(エラー判定用の値)とを混在させる、あるいは正常系と異常系とで戻り値の区別がつかない関数は、関数呼び出し結果の戻り値を式の中でそのまま使えなくなってしまう。
3. に関連する問題として、戻り値が正常系の結果取得に使えないため引数を処理結果の取得用に使い関数インターフェイスおよび呼び出し側のコードが複雑化するという問題がある。
4.の問題では関数の呼び出し結果をいったんローカル変数に格納することなく次の関数引数にそのまま式として渡すようなこともできなくなる。例えば下記のC言語の例では、atof()関数の戻り値が正常系と異常系とで区別がつかない仕様のため、対象フォーマット外の不正な入力があっても検知できず処理を継続してしまう。例外を使わずにこの問題に対処するには、正常系と異常系とを区別できるようにするために、関数の実装およびインターフェイスが複雑化することを許容しなければならない。
一方、文字列から数値への変換に失敗した場合に例外を投げるライブラリを持つ言語では、メインロジックに関係のないコードを挿入することなく、正常系と異常系とを簡潔かつ明確に区別できる。下記はC#による例である。
別の例として、たとえば主記憶領域の容量やファイル容量を取得する関数を設計する際、結果を符号なし整数型 (非負数) の戻り値で返すように決めた場合、戻り値でエラー判定用の値を返すことができない。この場合、errnoのようなグローバル変数もしくは別途用意した関数引数経由でエラーコードを返して、呼び出し側で判定する必要がある。たとえばWindows APIのGetFileSize()関数は戻り値でファイルサイズを返すが、混合した設計となっており、エラーが発生した場合は-1を返す。しかし戻り値の型はDWORDつまり符号なし32ビット整数型であるため、実際には0xFFFFFFFFが返却される。これは本来正常値としてありうる値であり、異常系との区別が付かないため、エラーによる結果だったかどうかを判定するにはGetLastError()関数の呼び出しが別途必要になっている。なおGetFileSizeEx()関数は成否を戻り値で、正常系出力を引数で返す設計となっており、GetFileSize()関数の代替として推奨されている。また、C言語において身近な例としてはmallocおよびcalloc、realloc関数があげられる。これらは確保を要求する容量として0を指定したときC言語の規格としてNULLを返して良いと定義されている。このためこれらの関数の戻り値では記憶空間の容量不足か引数に0を指定したか判断できず切り分けのためにerrnoを調べるか引数を調べる必要がある。C++において同等のnew演算子ではこの点を容量不足のときだけ例外を投げることによって改善している。
言語レベルでの例外処理はこれらの欠点を解消し、例外を確実に、かつ統一的に処理する目的で導入されたものと言える。
処理中に使用可能な記憶領域が枯渇した場合の回復は比較的単純である。
記憶領域が原因として上記が考えられるがGUIプログラムのイベント処理であれば3以外の状況から回復できる。1,2どちらも現在実行中の処理を中断して処理に使った記憶領域を解放すればプロセスを継続できる可能性がある。特に2の状況であればシステムの記憶領域使用状況が回復を待って再度同じ処理を実行することができる。
CUIの場合でかつ複数のファイルを処理しているような場合1の状況から回復することができる。枯渇が発生した大容量ファイルの処理は無理でもその後の小容量ファイルは処理できる可能性がある。
次にSmalltalkによる回復処理の例を示す: