エラー処理


Scalaでのエラー処理は例外を使う方法と、OptionやEitherやTryなどのデータ型を使う方法があります。
この2つの方法はどちらか一方だけを使うわけではなく、状況に応じて使い分けることになります。

エラーを表現するデータ型
OptionはScalaで最も多様されるデータ型の一つです。これはJavaのnullの代替として使われることが多いデータ型です。
Option型は簡単に言うと、値を1つだけ入れることができるコンテナです。ただし、Optionのまま様々なデータ交換処理ができるように便利な機能を持ち合わせています。

Option型は具体的には"Some"と"None"という2つの具体的な値が存在します。Someは何かしらの値が格納されているときのOptionの型、 Noneは値が何も格納されていないときのOptionの型です。

具体的な動きを見ていきます。
Optionに具体的な値が入った場合は以下のような動きをします。

val o: Option[String] = Option("hoge")
println(o.get + "," + o.isEmpty + "," + o.isDefined)
//nullが入った場合は以下のように動きます。
val o2: Option[String] = Option(null)
println(o2.isEmpty + "," + o2.isDefined)

Optionにはコンパニオンオブジェクトのapplyには引数がnullであるかどうかのチェックが入っており、引数がnullの場合、値がNoneになります。
getメソッドを叩いた時に例外が起こります。これではNPEと同じと思われるかもしれません。
しかし、Optionには以下のような便利なメソッドがあり、それらを使い回避することができます。

println(o2.getOrElse(""))

これは、Option[String]の中身がNoneだった場合に、空文字を返すというコードです。

値以外にも処理を書くこともできます。

Optionは型を持っているため、パターンマッチを使って処理をすることもできます。

val s: Option[String] = Some("foo")
val result = s match {
  case Some(str) => println(str)
  case None => println("not matched")
}

上記のようにSomeかNoneにパターンマッチを行い、Someにパターンマッチする場合には、その中身の値をstrという別の変数に束縛することも可能です。

Optionはコレクションの性質があります。よって、関数を内容の要素に適用できるという性質もそのまま持ちます。

println(Some(3).map(_ * 3).get)

このようにmapで関数を適用することもできます。 値がNoneの場合はNoneのままとなります。 Noneの場合に実行し、値を返す関数を定義できるのがfoldです。

val fo: Option[Int] = Some(3)
val fo2: Option[Int] = None
println( fo.fold(throw new RuntimeException)(_ * 3) )
//println( fo2.fold(throw new RuntimeException)(_ * 3) )

このように、Noneの際に実行する処理を定義し、かつ、関数を適用した中身の値を取得することができます。

実際のアプリケーションの中では、Optionの値が取得されることがよくあります。
たとえば、キャッシュから情報を取得する場合には、キャッシュヒットする場合とキャッシュミスする場合があり、それらはScalaではよくOption型で表現されます。
このようなキャッシュ取得が連続して繰り返された場合はどうなるのでしょうか。例えば、1つ目と2つ目の整数の値がOptionで返ってきてそれをかけた値を求めるような場合です。

val v1: Option[Int] = Some(2)
val v2: Option[Int] = Some(2)
println(v1.map(i1 => v2.map(i2 => i1 *i2)))

しかし、この場合Optionの入れ子構造になってしまいます。 Option[Option[Int]]のようになる。 このような入れ子を解消するために用意されたのが、flattenです。

println(v1.map(i1 => v2.map(i2 => i1 *i2)).flatten)

最後にflattenを実行することで、Optionの入れ子を解消することができます。
なお、v2がNoneである場合にもflattenは成立します。

ここまでmapとflattenを使用しましたが、実際のプログラミングではこの両方を組み合わせて使うことが多々あります。
そのためにその2つを適用してくれるflatMapというメソッドがOptionには用意されています。
名前はflatMapなのですが、意味としてはOptionにmapをかけてflattenを適用してくれます。

println(v1.flatMap(i1 => v2.map(i2 => i1 * i2)))
val v3: Option[Int] = Some(3)
println(v1.flatMap(i1 => v2.flatMap(i2 => v3.map(i3 => i1 * i2 * i3))))

もちろんNoneの場合でも成立します。

for式は実際にはflatMapとMap展開されて実行されているので、先程のコードをforで書くこともできます。

 val f1 = for { i1 <- v1
                i2 <- v2
                i3 <- v3 } yield i1 * i2 * i3
 println(f1)

このようにflatMapとmapを複数回使うような場合はfor式のほうがよりシンプルに書くことができます。

Optionによりnullを使う必要はなくなりました。しかし、Optionでは処理の成功か否かしか判別できません。
エラーの状態は取得できないので、Optionを使用できるのはエラーの種類が問題にならない場合のみです。
そんな、Optionと違い、エラー時にエラーの種類まで取得できるのがEitherです。Eitherは2つの値のどちらかを表現するデータ型です。
具体的にはOptionではSomeとNoneの2つの値を持ちましたが、EitherではRihgtとLeftの2つの値を持ちます。

val v10: Either[String, Int] = Right(123)
//パターンマッチも使えます。
v10 match {
  case Right(r) => println(r)
  case Left(l) => println(l)
}

一般的にEitherを使う場合、Left値をエラー、Right値を正常な値とみなす場合が多いです。
Leftに用いるエラー値ですが、これは代数的データ型(sealed traitとcase classで構成される一連のデータ型のこと)
で定義すると良いでしょう。代数的データ型を用いることでエラーの処理が漏れているかどうかをコンパイラが検知してくれるようになります。
単にThrowable型をエラー型に使うのなら後述のTryで十分です。

例としてEitherを使ったログインのエラー処理を表現してみます。

sealed trait LoginError
case object InvalidPassword extends LoginError
case object UserNotFound extends LoginError
case object PasswordLocked extends LoginError

case class User(id: Long, name: String, password: String)
object LoginService {
  def login(name: String, password:String): Either[LoginError, User] = Right(new User(456, "hal", "tokyo"))
}

LoginService.login(name = "hal", password = "tokyo") match {
  case Right(user) => println(s"id: ${user.id}")
  case Left(InvalidPassword) => println(s"Invalid Password!")
}

Leftのパターンマッチで、UserNotFoundとPassowrdLockedの処理が抜けています。
これをコンパイルすると、コンパイラがwarningで教えてくれます。

EihterはOptionと同様にmapやflatMapを使用できます。しかし、mapやflatMapでは強制的にRightの値が使用されます。注意してください。

ScalaのTryはEitherと同じように正常な値とエラー値のどちらかを表現するデータ型です。
Eitherとの違いは2つの型が平等ではなく、エラー値がThrowableに限定されており、型引数を1つしか取らないことです。
具体的には以下の2つの値をとります。

  • Success
  • Failure

ここでSuccessは型変数を取り、任意の値を入れることができますが、FailureはThrowableしかいれることができません。
そしてTryにはコンパニオンオブジェクトのapllyで生成する際に、例外をcatchし、Failureにする機能があります。
この機能を使って、例外が起こりそうな箇所をTryで包み、Failureにして値として扱えるようにするのがTryの特徴です。

val t: Try[Int] = Try(throw new RuntimeException("to be caught"))
println(t)

また、TryはEitherと違い、正常な値を片方に決めているのでmapやflatMapをそのまま使うことができます。

val t1: Try[Int] = Try(3)
val t2: Try[Int] = Try(4)
val t3: Try[Int] = Try(5)

val t4 = for {
  i1 <- t1
  i2 <- t2
  i3 <- t3 } yield i1 * i2 * i3
println(t4)

Try.apllyがキャッチするのはすべての例外ではありません。NonFatalという種類の例外だけです。

NonFatalではないエラーはアプリケーション中で復旧が困難な非常に重度なものです。
なので、NonFatalではないエラーはcatchせずにアプリケーションを終了させて外部から再起動などをしたほうが良いです。

ではエラー処理においてOptionとEitherとTryはどのように使い分けるべきなのでしょうか。
まず基本的にJavaでnullを使うような場面はOptionを使うのが良いでしょう。
次にEitherですが、Optionを使うのでは情報が不足しており、かつ、エラー状態が代数的データ型としてちゃんと定められるものに使うのが良いでしょう。
TryはJavaの例外をどうしても値として扱いたい場合に用いると良いです。
非同期プログラミングで使ったり、実行結果を保存しておき、あとで中身を参照したい場合などに使うことも考えられます。