契約と非検査例外

9 min読了の目安(約8200字IDEAアイデア記事

null か None か、例外か?

Scala でデータ構造のスタックを実装することを考えます。Stack クラスの pop メソッドは、スタックが空であった場合、次のうちどのような挙動を示すのが正しいでしょうか?

  1. 結果として null を返す。
  2. 結果として None を返す。
  3. 非検査例外を発生させる。

これを議論するためには、「検査例外と非検査例外」および「契約による設計」について理解しておく必要があります。次節からそれぞれ説明していきます。

検査例外と非検査例外

Scala に限らず、最近のプログラミング言語の多くは、「例外」という言語機能を持っています。この「例外」は、その性質によって「検査例外」と「非検査例外」に分類されます。

補足しておきますと、ここでの検査例外と非検査例外の用語の使い分けは、例外の性質による違いを示す目的のためであり、Java 固有の言語仕様(throws 節の記述および try-catch 句の記述の強制)によらないことに注意してください。実際に Java の検査例外の言語仕様は不評でした。そのため、後継の Scala や Kotlin には検査例外の言語仕様は組み込まれませんでした。多くの言語設計者は検査例外が素晴らしい機能であることを認識していながら、自身の言語に組み込んでいません。現在のアジャイル開発において重要視されている拡張性やバージョン管理性を損なうためです。

話を元に戻します。具体的に見るために、Java の例外クラスのクラス階層を以下に示します。

Exception
├─ IOException (検査例外)
├─ InterruptedException (検査例外)
└─ RuntimeException (非検査例外)
   ├─ NullPointerException (非検査例外)
   ├─ IndexOutOfException (非検査例外)
   └─ IllegalArgumentException (非検査例外)

Java では、RuntimeException 以下を除く Exception 以下の例外は検査例外、それ以外は非検査例外に分類されます。関数内で検査例外が発生する場合には、throws 節で例外が発生する可能性があることを宣言し、関数の呼び出し側は try-catch 句で例外を補足しなくてはなりません。一方、非検査例外が発生する場合には、throws 節で発生する可能性があることを宣言する必要がなく、呼び出し側が try-catch 句で補足する必要もありません。より正確には、非検査例外は補足するべきではありません。

このような言語仕様上の違いはありますが、検査例外も非検査例外も共に「例外」という枠組みの中で論じられるため、それらの使い分けに関してプログラマ間でも混乱が生じています。Java の言語仕様を一旦忘れ、次のように一般化して捉えると理解が進みます。

検査例外を使用するか非検査例外を使用するかを決めるもっとも基本的な規則は、「適切にプログラムを記述しても回避できない例外的な状況に対しては検査例外を使用し、適切にプログラムを記述すれば回避できる例外的状況に対しては非検査例外を使用する」ということです。次項からそれぞれ具体的に見ていきます。

検査例外

検査例外は、適切にプログラムを記述しても回避できない例外的な状況で発生する例外です。言い換えると、例外が回避されるように適切な指示をユーザに与える必要性があることを示す例外になります。具体的に見るために、Joshua Bloch 氏の Effective Java から例を借ります。

ギフトカードを利用して何らかの商品を購入するプログラムがあります。このプログラムは、カードに十分な残金がない場合に例外を発生させます。このプログラムを使用するクライアントプログラムは、例外の発生を回避することができるでしょうか?

率直に言えばできません。カードの残金に手を加えることを許さない限り例外は発生します。したがって、この例外は検査例外であるべきです。このとき、クライアントプログラムができることは、その例外を適切に補足してユーザに不足額を提示することです。

非検査例外

非検査例外は、適切にプログラムを記述すれば回避できる例外的な状況で発生する例外です。言い換えると、プログラムの実装に誤りがあることを示す例外です。具体的に見るために、以下の Java プログラムを考えます。

int[] arr = new int[10];
for (int i = 0; i <= 10; i++) {
  arr[i] = 0;
}

このプログラムは、ArrayIndexOutOfBoundsException という例外を発生させます。この例外が発生した原因は、配列 arr が添字に取る値が 0 から 9 であるのにも関わらず、添字に 10 が指定されるためです。このプログラマが意図したことは、おそらく、配列 arr の値をすべて 0 にすることでしょう。プログラムを以下のように変更することで、ArrayIndexOutOfBoundsException の発生を回避することができます。

int[] arr = new int[10];
for (int i = 0; i < 10; i++) {
  arr[i] = 0;
}

このように、プログラムを注意して実装することで、非検査例外を回避することができます。

配列が添字にとりうる値の範囲と違反した場合の動作は、配列と配列を使用するクライアントプログラムの間の「契約」によって定められています。次節からは、契約を軸にプログラムの設計を行う「契約による設計」について説明していきます。

契約による設計

契約による設計は、Bertrand Meyer 氏によって提唱されたプログラム設計のコンセプトの一つです。したがって、特定のデザインパターンやアーキテクチャパターン、SOLID 原則などの原則と衝突することはありません。むしろ、それらを契約という観点から整理して捉える能力を持ちます。

契約による設計はコンセプトであるため、特定のプログラミング言語に依存しません。どのプログラミング言語でも実践することが可能ですし、その表現方法は指定されていません。契約による設計に基づくプログラミング言語として Bertrand Meyer 氏による Eiffel がありますが、この言語に依存しなくともアドバンテージを発揮することができます。

これまでに何度も「契約」という用語が出てきています。プログラミングにおける契約とは何でしょうか?

簡単に言うと、契約は、関数(あるいはプロシージャ)に代表される呼び出される側とそれを呼び出す側が満たすべき条件についての、呼び出される側とそれを呼び出す側の間の合意のことを指します。満たすべき条件は、満たすタイミングとその主体によって、「事前条件」および「事後条件」、「不変条件」に分類されます。次項からそれぞれ具体的に見ていきます。

事前条件

事前条件は、関数の呼び出し開始時に呼び出す側が満たすべき条件です。呼び出される関数は、事前条件を違反している状況に対して特別なサポートを行う必要がありません。事前条件が満たされている場合のみ、処理を続行すれば問題ありません。

代表的な事前条件は、関数が引数が満たすべき条件です。具体的に見るために、以下の Scala のプログラムを考えます。

def func(x: Int): Int =
  if (x == 0) 0 else x + func(x - 2)

定義された関数 func は、引数 x が 0 以上の偶数の場合、0 から x までの偶数の総和を求めて返します。一方、引数 x が負の値の場合や奇数の場合には、計算が終了しません。つまり、関数 func は、定義域が 0 以上の偶数である部分関数となっています。したがって、事前条件は 0 <= x かつ x % 2 == 0 となります。

呼び出す側と呼び出される側のどちらが事前条件が満たされているかを確認するのかは、契約の方針によります。性善説に基づけば、事前条件は呼び出し側が満たしているため、呼び出される側は満たされているかを確認する必要がありません。上記のバージョンの関数 func は性善説に基づいているため、事前条件が満たされない場合には、スタックを使い果たしてエラーで終了するまで計算をやめません。

一方、性悪説に基づけば、事前条件は呼び出し側が満たしているとは限らないため、呼び出される側は満たされているか確認する必要があります。呼び出される側が確認した結果、満たされていないと判断した場合、非検査例外を発生させるのが適切です。性悪説に基づけば、関数 func は以下のように実装されます。

def func(x: Int): Int = {
  require(0 <= x)
  require(x % 2 == 0)
  if (x == 0) 0 else x + func(x - 2)
}

事後条件

事後条件は、事前条件が満たされている場合に、関数の呼び出し終了時に呼び出される側が満たすべき条件です。つまり、事前条件に対する関数の仕様を表しています。

例として、データ構造のスタックを考えます。スタック抽象データ型に対してプッシュメソッドを呼び出した場合の事後条件は、以下の通りになります。

  • スタックの要素数は 1 増加している。
  • スタックのトップはプッシュメソッドの引数に与えた値となっている。

事前条件と同様に、呼び出す側と呼び出される側のどちらが事後条件が満たされているかを確認するのかは、契約の方針によります。

不変条件

不変条件は、関数の呼び出し前後で常に満たされなければならない条件です。関数の呼び出し側が満たすべき条件です。関数の呼び出しの最中には、満たされていなくても問題はありません。

防御的プログラミング

性悪説に基づき、事前条件および事後条件を、呼び出す側および呼び出される側が満たさないことを前提に、呼び出す側および呼び出される側ができる限り確認するアプローチは、「防御的プログラミング」と呼ばれています。実際には、「防御的プログラミング」という用語は、より幅広く堅牢なプログラムを作成するための方法論のことを指します。

契約違反と修正責任

呼び出される側が事前条件が満たされているかを確認し、契約に違反していると判断した場合、非検査例外を発生させます。非検査例外が意味することが、プログラミングエラーであることはすでに説明しました。したがって、プログラムを実行して非検査例外が発生して実行が終了した場合、プログラマは非検査例外が発生しないように呼び出し側のプログラムを修正する責任を持ちます。

一方、呼び出し側が呼び出される側の事後条件違反を認めた場合には、呼び出される側に修正を依頼する必要があります。つまり、呼び出される側が修正責任を持ちます。

再び、null か None か、例外か?

ここまで理解が進むと、この章の冒頭に提起した問題について議論が可能となります。再び問題を提起します。

Scala でデータ構造のスタックを実装することを考えます。Stack クラスの pop メソッドは、スタックが空であった場合、次のうちどのような挙動を示すのが正しいでしょうか?

  1. 結果として null を返す。
  2. 結果として None を返す。
  3. 非検査例外を発生させる。

もうお解りだと存じますが、正解は、「契約を満たすことを正しいと定義すれば、どれも正しい」になります。それぞれが、自身の動作を「正しいとする契約」を持っているためです。

しかし、どれも正しいのでは、正しさを根拠として、どれを採用するかの意思決定はできません。したがって、それぞれの特徴を把握した上で、自身のプロダクト(もしくはプロジェクト)の契約の方針と照らして決める必要があります。次項から、それぞれの特徴を見ていきます。

結果として null を返す場合

「1. 結果として null を返す。」は、安易な null チェックが、プログラムの間違いに気がつくことを妨げる場合があります。具体的に見るために、以下の Scala による擬似プログラムを考えます。

var stack = Stack[String]()
stack.push("HELLO!")
... // いくつかの処理
val s = stack.pop()
println(s.toLowerCase) // -> hello!

このプログラムでは、スタックにデータをプッシュしたのち、いくつかの処理を実行し、ポップしています。このプログラムを書いているプログラマは、プッシュからポップまでの間に、スタックに変化が起きていないことを確信しています。したがって、変数 s の null チェックを行っていません。この場合、途中で意図しないポップがなされていた場合に、NullPointerException が発生するため、プログラムのバグに気がつくことができます。

var stack = Stack[String]()
stack.push("HELLO!")
...
stack.pop() // 意図しないポップ
...
val s = stack.pop()
println(s.toLowerCase) // -> NullPointerException

しかし、途中で社内のコーディング規約が変更され、null チェックが必須となってしましました。仕方がないので、安易な null チェックを行うことにしました。

var stack = Stack[String]()
stack.push("HELLO!")
...
stack.pop() // 意図しないポップ
...
val s = stack.pop()
if (s != null) {
  println(s.toLowerCase) // 通過しない
}

このプログラムは、例外が発生することなく正常に終了します。それゆえに、バグの発見を遅らせる結果になります。

結果として None を返す場合

「2. 結果として None を返す。」は、安易なパターンマッチが、プログラムの間違いに気がつくことを妨げる場合があります。ストーリは、null を返す場合と同様です。具体的に見るために、以下の Scala による擬似プログラムを考えます。

var stack = Stack[String]()
stack.push("HELLO!")
... // いくつかの処理
val s = stack.pop().get
println(s.toLowerCase) // -> hello!

このプログラムでは、スタックにデータをプッシュしたのち、いくつかの処理を実行し、ポップしています。このプログラムを書いているプログラマは、プッシュからポップまでの間に、スタックに変化が起きていないことを確信しています。したがって、変数 s に get メソッドを使って強制的に値を取得しています。この場合、途中で意図しないポップがなされていた場合に、NoSuchElementException が発生するため、プログラムのバグに気がつくことができます。

var stack = Stack[String]()
stack.push("HELLO!")
...
stack.pop() // 意図しないポップ
...
val s = stack.pop().get // -> NoSuchElementException
println(s.toLowerCase)

しかし、途中で社内のコーディング規約が変更され、get メソッドの使用が禁止されてしまいしました。仕方がないので、安易なパターンマッチを行うことにしました。

var stack = Stack[String]()
stack.push("HELLO!")
...
stack.pop() // 意図しないポップ
...
stack.pop() match {
  case Some(s) => println(s.toLowerCase) // 通過しない
  case None => ()
}

このプログラムは、例外が発生することなく正常に終了します。それゆえに、バグの発見を遅らせる結果になります。

非検査例外を発生させる場合

「3. 非検査例外を発生させる。」は、安易な存在確認が、プログラムの間違いに気がつくことを妨げる場合があります。ストーリは、null を返す場合および None を返す場合と同様です。具体的に見るために、以下の Scala による擬似プログラムを考えます。

var stack = Stack[String]()
stack.push("HELLO!")
... // いくつかの処理
val s = stack.pop()
println(s.toLowerCase) // -> hello!

このプログラムでは、スタックにデータをプッシュしたのち、いくつかの処理を実行し、ポップしています。このプログラムを書いているプログラマは、プッシュからポップまでの間に、スタックに変化が起きていないことを確信しています。したがって、変数 s に存在確認を行わずに値を取得しています。この場合、途中で意図しないポップがなされていた場合に、AssertionError が発生するため、プログラムのバグに気がつくことができます。

var stack = Stack[String]()
stack.push("HELLO!")
...
stack.pop() // 意図しないポップ
...
val s = stack.pop() // -> AssertionError
println(s.toLowerCase)

しかし、途中で社内のコーディング規約が変更され、存在確認が必須となってしましました。仕方がないので、安易な存在確認を行うことにしました。

var stack = Stack[String]()
stack.push("HELLO!")
...
stack.pop() // 意図しないポップ
...
if (stack.nonEmpty) {
  val s = stack.pop() // 通過しない
  println(s.toLowerCase) // 通過しない
}

このプログラムは、例外が発生することなく正常に終了します。それゆえに、バグの発見を遅らせる結果になります。

重要なことは、どれを採用するにしても、プログラミングエラーを示す非検査例外を安易に回避しないことです。