例外処理

例外処理(れいがいしょり、英語: exception handling)とは、IT業界で用いられる専門用語で、ある抽象レベルにおけるシステムの設計で想定されておらず、ユーザー操作によって解決できない問題に対処するための処理である。例外処理の結果として問題が解決されないとシステム障害になる。システム停止やデータ破損の原因になり、ユーザーに損害を与える可能性があるため、システム開発で例外処理は重要視されている[1][2][3]

システムの設計で想定されておらず、継続不能や継続すると問題になる様な状態としては、次のようなものが挙げられる。

注意点として、あらゆる例外が抽象レベルに依存せずすべて異常系であるとは限らない。例えばページフォルトはカーネル内部のメモリなど例外が許されない環境下ではエラーとなるが、仮想記憶を採用したOSにおけるユーザプロセスのメモリは常時物理的に存在するとは限らないためページフォルトを正常系として処理する必要がある。また、例外処理中にさらなる例外が発生した場合は、通常なら正常系となる事象が異常系に変わる場合がある。詳しくは#例外のネストを参照されたい。

例外処理の動作と重要性

例外処理の動作としては、システムを構成するプログラムの各呼び出し階層で、呼び出し先が想定していない入力値を受け取って問題が起きた場合に、問題に合わせた例外を発生させて呼び出し元に処理を返す。呼び出し元に例外を返す事によって、呼び出し元で問題解決が行われることに期待するが、どの呼び出し階層の例外処理でも問題解決できない場合は、システムの内部状態に矛盾が残り、システム障害となる。例外発生の後、システムが動作していても、例外への対処結果が設計から逸脱している場合、システムの内部状態に矛盾を来しており、ストレージデータベースネットワークに無意味なデータを出力する可能性が生じ、データ破損のみならず、連携する他システムにも矛盾が伝播して広範なシステム障害に繋がる可能性がある。従って、例外処理はシステム障害を未然に防ぐ意味で非常に重要である。

例外とエラーの違い

例外(exception)はシステム担当者が問題解決を行う必要がある。例外の問題解決手段には例外を無視することも含まれるが、明確な根拠をもって無視する必要がある(設計の一環として一部の例外を無視しても問題ないと判断する,連携する他システムのメンテナンス中で例外発生が不可避な場合はメンテナンス完了を待つ)。例外に対して、ユーザーが解決すべき問題はエラー(error)と呼ぶ(但し、業務システム開発ではエラーを業務エラー、例外をシステムエラーと表現する場合もあり、技術者間での厳密な統一見解は存在しない)[1][2][3]。何らかのシステム開発を行う会社では、例外への対処が不適切であるとユーザーに損害を与えるため、システム障害を発生させるような例外発生は製品瑕疵として扱う必要がある。

例外安全性

あるコード内を実行中の失敗が、メモリリーク、格納データの不整合、不正な出力などの有害な効果を生じないとき、そのコード片は例外安全であると言う。例外安全なコードは例外が発生したとしてもそのコードが備える不変条件を満たさなければならない。例外安全性にはいくつかのレベルがある[4][5]:

  1. 不送出保証、もしくは失敗透過性: 操作は成功するものと保証され、例外的な状況の中であっても全ての要求を満たす。もし例外が発生したとしても、その例外をより上位に送出はしない。(最高レベルの例外安全性)
  2. 強い例外安全性コミット・オア・ロールバックセマンティクス[6]あるいは無変更保証: 操作は失敗することがあるが、失敗した操作は副作用を起こさないことが保証され、すべてのデータは元の値を保持する。
  3. 基本的例外安全性: 失敗した操作の不完全な実行によって副作用が起こることがあるが、状態の不変条件は保たれる。あらゆる格納データは、もはや実行前とは異なるとしても、有効な値を持つ。
  4. 例外安全性なし: 何も保証されない。(最低レベルの例外安全性)

言語サポート

幾つかのプログラミング言語では組み込みの例外処理機能を用意している。例えばAdaC++JavaScalaC#JavaScriptOCamlがそうである。これらの言語では専用の言語機能によってプログラマが例外処理を記述する手間を軽減している。

例外が発生したことを見落として正常時の動作を継続してしまうと、より深刻・致命的な異常を招くおそれがある。それを避けるには例外が発生したことのチェックを綿密に行い、例外が検出された場合には適切な事後処理を行う他ない。しかし、大規模なプログラムではこのようなチェックは膨大なものとなり、本来目的としている正常時の処理よりも多くの記述を必要とする場合すらある。

そこで、これらの言語では例外の発生チェックをほぼ自動化している。例外が発生すると現在の処理を中断する。発生した例外の事後処理を担当できるハンドラを探して次々にコールスタック(関数呼び出し)を遡り、適切なハンドラを見つけるとそれに事後処理を任せる。これにより、遡る途中にあったこの例外を処理する能力を持たない処理は自動的に中断されることになる。

Schemeでは言語レベルでの例外処理を持たないが、これは継続が存在するため例外をライブラリレベルで実現できるからである(標準仕様であるSRFI-34で定義されている)。

C++による例外処理構文の例

void Function0(void) throw(...) // (2)
{
    // (1)
    throw 0;
    throw "message";
    throw std::runtime_error( "message" );
    throw;
}

void Function1(void)
{
    try
    {
        Function0();
    }
    catch( int exception ) // (3)
    {
        // 回復処理
    }
    catch( char const *exception ) // (4)
    {
        // 回復処理
    }
    catch( std::exception const &exception ) // (5)
    {
        // 回復処理
    }
    catch(...) // (6)
    {
        // 回復処理
        throw; // (7)
    }
}
void Function2(void)
try
{
    // (8)
}
catch (...)
{
}

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で捕捉できる[7]catch文の中では(7)のように引数の無いthrowを用いた場合、例外の再送を意味する。省略子を用いたcatchの場合は例外情報を判断できないため必須であるが、省略子を使わないcatchでも派生型の例外を基底型で受け取ってしまった場合のスライシングを防ぐために必要となる。

tryブロックは(8)のように関数全体に適用することもできる。これをfunction-try-blockと言う。catch文では局所変数を参照できず引数だけしか参照できないが、コンストラクタの初期化リストで発生した例外やデストラクタ[8]から投げられた例外はこの書き方でしか捕捉することができない。

例外処理構文を最初に実装したのはAdaであるが、C++の例外処理構文はJavaやJavaScript、C#など多くの後発言語の規範となった。

例外指定

C++の例外指定 (Exception Specification、例外仕様とも) は、関数から伝達される例外の種類を明示する言語機能である[9]。例えば void f() throw(int) はint型のエラーをthrowしうることを明示している。例外指定はコンパイラによる静的例外検査を想定して用意された機能だが各コンパイラには実装されず、C++11で非推奨になり、C++17をもって廃止された[10]

Javaによる例外処理構文の例

    public void throwError() throws Exception {
        throw new Exception();
    }

    public void catchException() {
        try {
            throwError();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

Javaでは例外はクラスとして実装する。例外を「投げる(throw)」メソッドはthrows Exceptionのように指定する。Javaプラットフォーム上でJava言語を使用し、発生する例外がjava.lang.Exceptionを継承しているが、しかしjava.lang.RuntimeExcptionを継承していない場合、try/catch文で例外処理を明示的に記述するか、メソッドにthrowsを追加する必要がある。ただし、Javaプラットフォーム上で動く言語でも、GroovyScalaなど、Java言語以外の多くは、RuntimeException以外の例外に対して必ずしも明示的に記述しなくても良くなっている。初期のJava(JDK1.0)では、I/O処理など他の手段では例外の発生を回避することができない種類の例外に対してはRuntimeExceptionを継承させないという設計思想になっていた。

Smalltalkによる例外処理構文の例

| value |

value := "式であるため戻り値が存在する"
[
	Notification signal: '接続準備完了'. 

	1. "本来はvalueに代入されるが例外が発生しているため代入しない"
]
	on: Error, Notification "複数の例外を同時に捕捉できる"
	do:
	[ :exception |
        exception return: 0. "1の代わりにvalueに0を代入する。"
	]
	on: Exception
	do:
	[ :exception |
        exception pass. "処理できない例外は上位の例外処理に委ねる。"
	].

言語機能としては例外処理構文が存在しないが別途例外処理を備える言語も存在する。Smalltalkは言語機能として制御構文が殆どない。このため例外処理構文もブロックを組み合わせたメッセージ式として記述するようになっている。言語機能ではないため極めて柔軟性がありブロックの戻り値を変更したり例外が発生した式の途中から復帰したりなど様々な制御が可能になっている。

制御フローへの転用

例外処理の過程では処理の流れが通常の制御とは大きく変化することとなるが、これを(エラー処理以外の目的で、正常系において)積極的に利用することは、アンチパターンとされることもある。

一方、Ruby[11]Python[12]では、イテレータが終端に達するという、無限ループでなければ必ず発生する事象により例外が起きることもあるほか、Rubyでは例外処理と関係なく大域脱出を行う制御構造も用意されている[13]

Smalltalkでも例外はエラー処理以外の通知として使われる。Smalltalkの例外 (Exception) はエラー (Error) と通知 (Notification) からなり、EOFの通知やスレッド間の割り込み終了通知等に使われている。またSmalltalkは値を検索した結果値が見つからなかった場合の戻り値としてnil (null) を返さない傾向があり、値が見つからなければ例外を投げる。ただし単純な例外処理というパターンがあり、値が見つからない場合でも例外構文を使わず安全に回復する手段を用意している。

通知例外にはエラー例外と異なる特徴的な点がある。通知を投げた場合は、エラーと異なり補足しなければ処理を中止せずそのまま続行となる。

戻り値と例外

例外処理(例外オブジェクト)をサポートしないCなどの言語では、従来から関数(サブルーチン)の戻り値によってその関数(処理)の成否を判定する方法がとられてきた。慣例的に、関数の戻り値を32ビット整数値などで宣言して、関数が成功した場合は0を返し、失敗した場合はエラーコードとして何らかの負数を返すことが多い[14][15]。さらに簡略化して、成否の結果を真偽値1/0で返すだけにすることもある。戻り値がポインタ型である場合は、成功した場合に有効なポインタすなわち非NULLを返し、失敗した場合に無効なポインタすなわちNULLを返すのが通例である。標準ライブラリや各種APIでは、詳細を伝えるエラーコードを別途errnoのようなグローバル変数に格納することもある。各エラーコードによって失敗の原因を定義しておき、呼び出し側で原因を判定する。

このような戻り値による処理の成否判定には下記のような問題点がある。

  1. 戻り値は無視できるため、呼び出し先でエラーが発生しても通常通り処理を継続するプログラムを記述できてしまう。
  2. エラーコードはたいてい32ビットの整数値でしかないため、それ以上の詳細な情報(例えば具体的原因および異常発生個所などを示すエラーメッセージ)を付加することができない。
    直前のエラー情報をグローバル変数に格納する設計は、マルチスレッド対応の際に別途スレッドローカルストレージ化が必要となる。
  3. 戻り値を毎回チェックする判定文を記述するのが煩雑である。
  4. 戻り値に正常系の値と異常系の値(エラー判定用の値)とを混在させる、あるいは正常系と異常系とで戻り値の区別がつかない関数は、関数呼び出し結果の戻り値をの中でそのまま使えなくなってしまう。

3. に関連する問題として、戻り値が正常系の結果取得に使えないため引数を処理結果の取得用に使い関数インターフェイスおよび呼び出し側のコードが複雑化するという問題がある。

bool countPositiveElements(const double x[], int inNumberOfElements, int* outNumberOfElements) {
    if (x == NULL || inNumberOfElements <= 0 || outNumberOfElements == NULL) {
        return false; // 異常終了。
    }
    *outNumberOfElements = 0;
    for (int i = 0; i < inNumberOfElements; ++i) {
        if (!isnan(x[i]) && x[i] > 0) { (*outNumberOfElements)++; }
    }
    return true; // 正常終了。
}

4.の問題では関数の呼び出し結果をいったんローカル変数に格納することなく次の関数引数にそのまま式として渡すようなこともできなくなる。例えば下記のC言語の例では、atof()関数の戻り値が正常系と異常系とで区別がつかない仕様のため、対象フォーマット外の不正な入力があっても検知できず処理を継続してしまう。例外を使わずにこの問題に対処するには、正常系と異常系とを区別できるようにするために、関数の実装およびインターフェイスが複雑化することを許容しなければならない。

#include <stdio.h>
#include <stdlib.h>

double addAsDouble(const char* x, const char* y) {
    return atof(x) + atof(y);
}

int main(void) {
    printf("%f\n", addAsDouble("1", "2"));
    printf("%f\n", addAsDouble("x", "y")); // 変換および加算結果は0となるが、無意味。
}

一方、文字列から数値への変換に失敗した場合に例外を投げるライブラリを持つ言語では、メインロジックに関係のないコードを挿入することなく、正常系と異常系とを簡潔かつ明確に区別できる。下記はC#による例である。

using System;

public class Test
{
    public static double AddAsDouble(string x, string y)
    {
        return double.Parse(x) + double.Parse(y);
    }

    public static void Main()
    {
        try
        {
            Console.WriteLine(AddAsDouble("1", "2"));
            Console.WriteLine(AddAsDouble("x", "y")); // 例外がスローされ、処理は継続されない。
        }
        catch
        {
        }
    }
}

別の例として、たとえば主記憶領域の容量やファイル容量を取得する関数を設計する際、結果を符号なし整数型 (非負数) の戻り値で返すように決めた場合、戻り値でエラー判定用の値を返すことができない。この場合、errnoのようなグローバル変数もしくは別途用意した関数引数経由でエラーコードを返して、呼び出し側で判定する必要がある。たとえばWindows APIのGetFileSize()関数は戻り値でファイルサイズを返すが、混合した設計となっており、エラーが発生した場合は-1を返す。しかし戻り値の型はDWORDつまり符号なし32ビット整数型であるため、実際には0xFFFFFFFFが返却される。これは本来正常値としてありうる値であり、異常系との区別が付かないため、エラーによる結果だったかどうかを判定するにはGetLastError()関数の呼び出しが別途必要になっている[16]。なおGetFileSizeEx()関数は成否を戻り値で、正常系出力を引数で返す設計となっており、GetFileSize()関数の代替として推奨されている[17]。また、C言語において身近な例としてはmallocおよびcallocrealloc関数があげられる。これらは確保を要求する容量として0を指定したときC言語の規格としてNULLを返して良いと定義されている。[18]このためこれらの関数の戻り値では記憶空間の容量不足か引数に0を指定したか判断できず切り分けのためにerrnoを調べるか引数を調べる必要がある。C++において同等のnew演算子ではこの点を容量不足のときだけ例外を投げることによって改善している。

言語レベルでの例外処理はこれらの欠点を解消し、例外を確実に、かつ統一的に処理する目的で導入されたものと言える。

回復処理

記憶領域の枯渇

処理中に使用可能な記憶領域が枯渇した場合の回復は比較的単純である。

  1. 大量データを読み込もうとした
  2. システム全体の短期的な記憶領域枯渇
  3. システム全体の長期的な記憶領域枯渇

記憶領域が原因として上記が考えられるがGUIプログラムのイベント処理であれば3以外の状況から回復できる。1,2どちらも現在実行中の処理を中断して処理に使った記憶領域を解放すればプロセスを継続できる可能性がある。特に2の状況であればシステムの記憶領域使用状況が回復を待って再度同じ処理を実行することができる。

CUIの場合でかつ複数のファイルを処理しているような場合1の状況から回復することができる。枯渇が発生した大容量ファイルの処理は無理でもその後の小容量ファイルは処理できる可能性がある。

次にSmalltalkによる回復処理の例を示す:

| window textBox filePath open notifyArea |

"ファイルの読み込み失敗を通知する部品"
notifyArea := LabelStringMorph new.

"ファイルを開くための部品"
textBox := TextMorph new.
filePath := TextMorph new.
open := SimpleButtonMorph new.

"ボタンをクリックしたらファイルを読み込んでテキストボックスに表示できるようにする"
open
	on:     #click
	send:   #value
	to:
    [
    	[
    		filePath contents asFile withReadStreamDo:
    		[ :readStream |
    			textBox contents: readStream contents.
    		]
    	]
    		"
    		記憶領域が枯渇した場合の回復処理。
    		処理を中断した上、できるだけ資源を解放したのち使用者に記憶領域の解放を促す。
    		例外が発生してから例外を捕捉するまでに幾分かの記憶領域が解放される、
    		解放後ゆとりができていれば回復できるが、ゆとりが無い場合は回復できなくなる場合もある。
    		"
    		on: AllocationFailure
    		do:
    		[
    			ObjectMemory compact.  "ガーベッジコレクターによる解放"
    			textBox contents: ''. "途中まで読み込んでしまった文字列を解放"
    			ObjectMemory compact.  "textBox contents分の解放"
    			notifyArea text: '記憶領域が枯渇しているためファイルを開けませんでした。他のプログラムを終了してから再度実行して下さい。'
    		].
    ].

"ファイルを表示するテキストボックスとファイルの読み込みに必要な各種部品を組み込んだWindowを表示する"
window := SystemWindow new.
window
	addMorph: textBox;
	addMorph: filePath;
	addMorph: open;
	addMorph: notifyArea;
	openInWorld.

例外のネスト

ある例外を処理中に、元のものとは別の例外が発生する場合がある。これを例外のネスト(入れ子)と呼ぶ。例外のネストの処理は例外処理の設計や実装を複雑にするため、どの範囲までネストを許すかやどのような処理に対しては例外を許さないかがしばしば問題となる。

言語仕様として実装されている例外にあっては、ネストのレベルは一般には制限されていない。この場合、同じ例外が繰り返し発生することにより無限ループに陥る恐れがある。そのような状況への対策は設計者や実装者の責任となる。

別の抽象レベルとして、OSやCPUの例外は処理や例外に備えた準備が複雑化することを防ぐため、ネストレベルに制限をかける場合が多い。典型的な例がページフォルトで、ユーザプロセスでのページフォルトは許すが、それを処理するカーネルに対してはページフォルトを許さないことにより例外処理を簡単化している。また、CPUでは例外が発生しハンドラを実行しようとした際、スタックオーバーフローに起因するページフォルトなど更なる例外が発生することがある。これはダブルフォルト英語: Double faultと呼ばれ、専用のハンドラやスタックなど一般的な例外よりもさらに複雑な設定や処理を要する。ダブルフォルトを処理しようとしてさらに例外が発生してしまうとトリプルフォルト英語: Triple faultとなり、現実的な処理が困難となるためリセット処理を行う実装が多い。

参照

  1. ^ a b 第 5 章 例外処理 (C++ プログラミングガイド)”. docs.oracle.com. 2019年10月26日閲覧。
  2. ^ a b IPA ISEC セキュア・プログラミング講座:C/C++言語編 第6章 フェイルセーフ:体系だてたエラーハンドリング”. www.ipa.go.jp. 2019年10月26日閲覧。
  3. ^ a b エラー処理をパターンにはめよう”. Codezine. 2019年10月26日閲覧。
  4. ^ Bjarne Stroustrup. “Appendix E: Standard-Library Exception Safety in "The C++ Programming Language" (3rd Edition).Addison-Wesley, ISBN 0-201-88954-4”. 2013年5月1日閲覧。
  5. ^ Exception-Safety in Generic Components”. 2013年5月1日閲覧。
  6. ^ http://www.open-std.org/jtc1/sc22/wg21/docs/papers/1997/N1077.asc
  7. ^ /EH (例外処理モデル)
  8. ^ ただし、例外処理中にもう一度別のデストラクタから例外が発生してしまうと復帰できなくなるため、デストラクタから例外を発生させるべきではないとされる。
  9. ^ 例外指定は、 C++関数によって伝達される例外の種類についてプログラマが意図したものを示す言語機能です。 Microsoft Docs Visual C++
  10. ^ C++17ではこの動的例外仕様が削除される。C++日本語リファレンス
  11. ^ class StopIteration Ruby 1.9.3 リファレンスマニュアル(2013年10月7日閲覧)。
  12. ^ 組み込み例外 Python 2.7ドキュメンテーション(2013年10月7日閲覧)。
  13. ^ module function Kernel.#throw Ruby 1.9.2 リファレンスマニュアル(2013年10月7日閲覧)。
  14. ^ たとえばCOMではメソッドの戻り値として、MAKE_HRESULT()マクロを用いてHRESULTコードを定義するが、異常系は負数となる。
  15. ^ CUDAのように列挙型で定義した正の数をエラーコードとして使用するライブラリもある。CUDA Runtime API :: CUDA Toolkit Documentation
  16. ^ GetFileSize 関数
  17. ^ GetFileSize function (Windows)
  18. ^ C言語規格のドラフト”. 2018年11月21日閲覧。

関連項目