Scala 例外を使わないエラー処理(Option編)

用語

純粋関数とは

参照透過

式eがあるとしたら、全体のプログラムpにおいて、pの意味に影響を与えることなくp内のすべてのeをeの評価結果と置き換えることができたらeは参照透過であると言える。

以下の簡単なプログラム(値2, 3を加算した結果を返す関数)があるとする。

2 + 3 = 5

上記のプログラムは加算関数(+)を用いて実現している。このプログラムは左辺の式を評価結果に置き換えると必ず5になるので左辺の式は参照透過であると言える。

純粋関数

すべての式が参照透過性を持っているのであれば、その関数を純粋関数と呼ぶ。

例外を使わないエラー処理

例外をスローすると副作用が発生して、参照透過性が損なわれてしまう。この副作用を取り除くためにScalaでは例外を使わないエラー処理を行うことができる標準のライブラリOption、Eitherなどの型が存在する。そこで今回はOption型を使ってどうやったら例外が発生するプログラムで副作用を取り除くことができるかを確認していく。

例外によって参照透過性が損なわれる理由

以下のメソッドを実行したとする。

def failingFn(i: Int): Try[Int] = {
    val y: Int = throw new Exception("fail!")
    
    Try {
      val x = 42 + 5
      x + y
    }.recover{
      case e: Exception => 43
    }
  }

実行結果は以下になる。

java.lang.Exception: fail!
at .failingFn(<console>:8)

これは想定通りの結果だと思う。

次に例外を直接yに置き換えると、43が返ってくる。

def failingFn(i: Int): Try[Int] = {    
    Try {
      val x = 42 + 5
      x + throw new Exception("fail!")
    }.recover{
      case e: Exception => 43
    }
  }

これも想定通りだが、上記の2つのプログラムから以下のことがわかる。

参照透過な式は参照先の式と置き換えることが可能で、この置き換えによってプログラムの意味が変化しないことを示す。この場合、上記の2つ同じ関数で参照先を変更しただけにも関わらず結果が異なるので、例外よって参照透過性が損なわれていることが言える。

さらに参照透過は以下の性質があることがわかる。

  • 参照透過な式は関数の文脈に依存せず、ローカルでの推論が可能
  • 参照透過でない式は関数の文脈に依存して、よりグローバルな推論が必要

よって例外処理というのは参照透過性を損なわせ、ローカルでの推論を難しくするということがわかる。

例外処理によって参照透過性が損なわれることによる問題

例外処理には2つの問題がある。

  • 上記で示したように、例外は関数の文脈に依存をもたらすため、例外ベースで複雑なコードを記述でき、単純な推論から遠ざかってしまう。本来例外はエラー処理にのみ使用すべきであり、制御フローには使用すべきではない。
  • 例外は型安全ではない。上記の関数 failingFnの型 Int ⇒ Int から例外が発生することなど何もわからない。

例外に代わる手法

以下のメソッドで考える

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

このメソッド内はDouble型の戻り値だが例外を返すケースが存在する。そのため副作用を持つメソッドであることが言える。これを解消するための方法をいくつか考えてみる。

例外の場合にDouble型の偽の値を返す

上記の例で入力値が空の場合はDouble.Nanに相当する 0.0/0.0を返す

これで副作用がない戻り値 Double型のメソッドを作成することができるが、この場合いくつかの欠点がある。

  • 呼び出し元で条件分岐を行う必要がある
    • 上記の例では呼び出し元でDouble.Nanかどうか確認する必要がある
    • これは例外判定を特定の値で決める必要がありボイラープレートコードが量産される
  • 呼び出し元のチェックし忘れ
    • エラーが隠れて呼び出し元に伝搬されるが、呼び出し元がチェックしないとエラーの場合正常に動かない可能性がある
    • これはコンパイルエラーにもならないためバグとなり発見される可能性がある

例外が発生したときの代替の値を呼び出し元から引数で渡す

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

これも副作用をなくすことはできているが、以下の欠点がある

  • 呼び出し元がメソッドの内容を知っている前提になっている
  • 空の場合のハンドリングがDouble型で返すケースしか用意できない
    • 例えば他の処理で以下の実装を行いたいとした場合、この作りでは不可能
      • meanが未定の場合にその計算を中止したい
      • meanが未定の場合に他の条件分岐に移りたい

上記のことから例外ハンドリングの方法は決断を先送りにして最も適切なタイミングで処理してあげた方がよいことがわかる。

Optionで例外を吸収する

Optionを使用することでこれまで挙げてきた欠点を解決することができる。Optionを使用するとエラーの処理戦略を呼び出し元に委ねることができる。

上で考えたmeanメソッドの戻り値をOption型にする。

def mean(xs: Seq[Double]): Option[Double] = 
	if (xs.isEmpty) None
	else Some(xs.sum / xs.length)

そうすることで宣言された型の結果(Option[Double])が必ず返るので、meanメソッドを副作用のない純粋関数にすることができる。

Optionは部分関数に対して使用することが多い。部分関数とは一部の入力に対して定義されていない関数のこと。meanメソッドで空配列の場合の記述が書かれてないケースなど。

Optionの使い方

部分関数においてOptionを使う場合、パターンマッチングを使用することも可能だが、map、flatMap、filterなどの高階関数を使用することが推奨される。パターンマッチングを使用すると冗長なコードになりがちなため、できるだけ高階関数使用する。

map、flatMapを使用する例

mapはOptionに結果が存在する場合に後続処理を実行することができ、エラーが発生していないという前提で処理を進めることができる。そのためエラーを後続のコードに先送りする方法の一つである。

flatMapはOption[Optino[T]]のような型でmap同様エラーを後続のコードに先送りすることができる。

case class Employee(name: String, department: String)
def lookupByName(name: String): Option[Employee] = ???
val joeDepartment: Option[String] = lookupByName("Joe").map(_.department)

上記のコードのjoeDepartmentはJoeという従業員がすればその部署を取得する関数である。Joeという従業員が存在しなければ、後続の処理は実行されない。つまり、エラー処理を先送りしている。

filterを使用する例

filterを使うと成功した後の処理を失敗にすることができる。

val dept: String = lookupByName("Joe").map(_.dept).filter(_ != "Accounting").getOrElse("Default Dept")

ここでは、Option[String]をStringに変換するためにgetOrElseを使用している。

このようにJoeという従業員が存在しない場合、またはJoeの部署がAccountingの場合は"Default Dept”が返る。

例えばOptionがNoneの場合にエラーログを出力したい場合は、以下のようにfilterを使えば実現できそう。

ただnonEmptyの判定が入る。Emptyであればログ出力したいのが目的なのにやっていることがわかりづらい。

// aに何らかの処理を行った結果がOptionで入る
val a = Option("a")
// nonEmptyでなければlog出力する
a.filter(_.nonEmpty).getOrElse(logger.error("error message"))

Optionの合成

Optionを使ってしまうと、コード全体への影響はどのみち避けれないと思ってしまうかもしれない。しかしOptionが発生しても例外処理のタイミングは自由に決めることができ、呼び出し元の都合で例外ハンドリングを先送りにすることができる。

ユーザの入力値を受け取って何らかの計算を行うプログラムが以下にある。

これはaとbから何らかの計算を行うメソッドである。

def calculate(a: Int, b: Int): Int

上記のメソッドを呼び出したいが、ユーザからの入力値が文字列でなので、parseするメソッドを以下に用意した。またtoIntを使用して文字列から整数への変換を行っているが、文字列が整数に置き換えれない場合は例外が返されるため、例外を吸収するtryToOptionメソッドを用意した。

def parseForCalculate( a: String, b: String): Option[Int] = {
    val optA: Option[Int] = tryToOption(a.toInt)
    val optB: Option[Int] = tryToOption(b.toInt) 
    calculate(optA, optB)
  }

def tryToOption[A](a: => A): Option[A] = {
  Try(a).toOption
}

ただし上記は一つの問題を抱えている。それは文字列を解析して整数に変換したあとOption型で返ってくることである。このままではcalculateメソッドの引数の型に合わず、calculateメソッドを使えない。ここでcalculateメソッドの引数をOptionに変換することはしたくない。なぜならOptionを引数にすることでcalculateメソッドの中でも引数に入ってきた値が成功値か失敗値かの判断が必要になってくる。またcalculateメソッドが別モジュールで定義されていて、そもそも変更することができない場合もある。

ここでOptionの合成を行う。flatMapとmapを使うことで複数のOptionを扱う場合でもエラーの先送りすることができる。

def parseForCalculate( a: String, b: String): Option[Double] = {
    val optA: Option[Int] = tryToOption(a.toInt)
    val optB: Option[Int] = Try(b.toInt)
    optA.flatMap(A => optB.map(B => calculate(A, B)))
  }

def tryToOption[A](a: => A): Option[A] = {
  Try(a).toOption
}

Optionの合成の数が多くなった場合はfor式を使う。

関数シグネチャとして Option 型を使うべきか

関数シグネチャに Option 型は存在しない方が望ましい。つまり、関数への入力値は必ず存在し、関数から返す値も必ず存在することが望ましいということ。ただし、実際にはプログラミングにおいて、一度でも Option 型に関わった場合、Option 型が付きまとうことがある。そのため、関数の出力に Option 型が存在するのは一般的で、これは意図した例外が発生する可能性があることを示唆している。一方、関数の入力に Option 型が存在する場合は、設計が適切でない可能性が高い。