新人SEの学習記録

14年度入社SEの学習記録用に始めたブログです。もう新人じゃないかも…

学習記録:Scala関数型デザイン

第4章:例外を使わないエラー処理

1章で例外をスローすることは副作用であることを説明した。
本章では、関数型プログラミングにおけるエラーの生成と処理の基本原理を学ぶ。

全体的な考えとしては、失敗や例外を通常の値として表し、
エラー処理とリカバリに共通するパターンを高階関数として抽出する。
エラーを値として返すほうが安全であり、参照透過性も維持される。
また、高階関数を使用することにより、エラー処理ロジックの一本化という例外な主な利点も維持できる。

本章では標準ライブラリに含まれるOption型とEither型を独自に作成する。
これらの型をエラー処理に使用する方法について理解を深めることが目的である。

例外の光と影

例外によって参照透過性が損なわれるのはなぜか。以下の簡単な例で見ていく。

def failingFn(i: Int): Int = {
  val y: Int = throw new Exception("fail")
  try {
    val x = 42 + 5
    x + y
  }
  catch { case e: Exception => 43 }
}

failingFnを呼び出すと、2行目でエラーになる。

scala> failingFn(12)
java.lang.Exception: fail
...

参照透過な式は参照先の式と置き換えることが可能である。
そこで、5行目のyをthrow new Exception("fail")と置き換えてみる。

def failingFn2(i: Int): Int = {

  try {
    val x = 42 + 5
    x + (throw new Exception("fail"): Int)
  }
  catch { case e: Exception => 43 }
}

failingFn2は呼び出してもエラーにならない。
つまり、yは参照透過ではないことがわかる。

scala> failingFn2(12)
res1: Int = 43

例外には主に2つの問題がある。
一つは、上で示したように例外は参照透過性を失わさせ、コンテキストへの依存をもたらすこと。
例外ベースの複雑なコードを記述することが可能になってしまう。
もう一つは、例外が型安全でないことである。failingFnの型Int => Intからは例外が発生することがわからない。
そしてそうした例外の処理方法を呼び出し元に強制的に決定させることはなく、failingFnでチェックされなければ実行時まで検出されない。

そこで、例外のスローに代わる手法として「例外的な状況が発生していることを示す値を返す」という古いアイデアを使用する。
Cでリターンコードを使って例外を処理するのと同じ方法である。
ただし、エラーコードの代わりに「定義されている可能性のある値」に対する新しい総称型を作成し、
高階関数を使ってエラー処理や伝搬に共通するパターンをカプセル化する。
Cスタイルのエラーコードと異なり、ここで使用するエラー処理戦略は完全に型安全である。

検査例外について

Javaの検査例外には、エラーを処理するか再スローするかを強制的に決定させる効果があるが、
呼び出し元がボイラープレート(決まりきったコード)だらけになるという欠点がある。
更に重要なのは、検査例外は高階関数ではうまくいかないため、例外が発生しても認識する手立てがないことである。

例えば、以下のmap関数

def map[A,B](l: List[A])(f: A => B): List[B]

このfがスローする可能性のある検査例外をmapは知りようがないように、高階関数と検査例外との相性は最悪である。
Javaでさえ、汎用的なコードではRuntimeExceptionやExceptionなどの汎用的な型が使われることが多いのはこういった理由からである。

例外に代わる手法

例外を使用すると思われる現実的な状況において、例外の代わりに使用できるアプローチを考えていく。
以下のリストの平均を計算する関数meanでは、リストが空の場合平均は未定義となる。

def maen(xs: Seq[Double]): Double =
  if (xs.isEmpty)
    throw new ArithmeticException("mean of empty list!")
  else xs.sum / xs.length

このような関数は、部分関数と呼ばれる「一部の入力に対して定義されない関数」である。
一般的に、入力型によって暗黙的に定義されない入力に関してある種の前提を設けているために部分関数となる。
このような場合には例外をスローしたくなるが、他の方法はないだろうか。

まず一つ目の方法としては、Double型の偽の値を返すことが考えられる。
入力が空の場合にはNaNに相当する0.0/0.0にするか、nullを返すことも考えられる。
例外を使用しない言語でよく使われるアプローチではあるが、本書では以下の理由により却下する。

  • 呼び出し元がチェックし忘れてもコンパイルエラーにならないため、エラーが隠れて伝搬する可能性がある。
  • 呼び出し元がif文を使ってチェックする部分がボイラープレートだらけになる上、エラーの温床となる。
  • 出力型によっては、センチネル値やnullを使えない場合がある。
  • 呼び出し元に特別な呼び出し規則が要求されるため、高階関数にそれらの引数を渡すのが難しくなる。

二つ目の方法としては、入力の処理方法がわからない場合にどうすればよいかを示す引数を渡させる方法がある。

def mean_1(xs: IndexedSeq[Double], onEmpty: Double): Double =
  if (xs.isEmpty) onEmpty
  else xs.sum / xs.length

これでmeanは完全な関数となるが、呼び出し元が未定義のケースに対処する方法を知っていないといけないという欠点がある。
また、呼び出し元がDoubleを返すことしかできなくなるため、meanが未定義の場合現在の計算を中止したい、
といったようなケースに対応するのは難しい。

未定義のケースの処理方法に関する決断を先送りにし、適切なレベルで処理できる方法が必要である。