読者です 読者をやめる 読者になる 読者になる

新人SEの学習記録

14年度入社SEの学習記録用に始めたブログです。気づけば社会人3年目に突入。

学習記録:ドワンゴ 新人向けScala研修テキスト

14. エラー処理(続き)

エラーを表現するデータ型を使った処理

ここでは正常の値とエラー値のどちらかを表現できるデータ構造を紹介し,Scalaにおける関数型のエラー処理の方法を見ていく。

Option

Scalaでもっとも多用されるデータ型の1つで,Javaのnullの代替として使われることが多い。
Option型には,SomeかNoneの2つの具体的な値が存在し,Someは何かしらの値が格納されているとき,Noneは何も格納されていないときの型になる。

scala> val o:Option[String] = Option("hoge")
o: Option[String] = Some(hoge)

scala> o.isEmpty
res1: Boolean = false

scala> o.get
res0: String = hoge

scala> val n:Option[String] = Option(null)
n: Option[String] = None

scala> n.isEmpty
res2: Boolean = true

scala> n.get
java.util.NoSuchElementException: None.get
  at scala.None$.get(Option.scala:347)
  at scala.None$.get(Option.scala:345)
  ... 42 elided

Option作成時の引数がnullの場合には値がNoneになっていることがわかる。getメソッドを呼び出すとNoSuchElementExceptionが発生しているので,これがぬるぽと同様と思われるかもしれないが,Optionには以下の便利なメソッドによりそれを回避することができる。

scala> n.getOrElse("")
res5: String = ""

上記は中身がNoneだった場合に空文字を返すという意味のコードになる。値以外にも,下記のような処理を書くこともできる。

scala> n.getOrElse(throw new RuntimeException("nullは受られません"))
java.lang.RuntimeException: nullは受け入れられません
  at $anonfun$1.apply(<console>:13)
  at $anonfun$1.apply(<console>:13)
  at scala.Option.getOrElse(Option.scala:121)
  ... 44 elided

また,パターンマッチを使用して処理をすることもできる。

scala> n match {
     |   case Some(str) => println(str)
     |   case None => throw new RuntimeException
     | }
java.lang.RuntimeException
  ... 45 elided

Optionにはコレクションの性質があり,mapなどの関数を中の要素に適用できる。

scala> val o: Option[Int] = Option(3)
o: Option[Int] = Some(3)

scala> o.map(_ * 10)
res11: Option[Int] = Some(30)

値がNoneの場合には,map関数などを適用してもNoneのままになる。

scala> val n: Option[Int] = None
n: Option[Int] = None

scala> n.map(_ * 10)
res10: Option[Int] = None

この性質により,中身がSomeとNoneのどちらであったとしても同様の処理で記述でき,処理を分岐させる必要がない。
Javaで同様の処理を書こうとすると,n.isEmptyなどの結果によって分岐させ,nullの場合は例外を投げるような書き方になる。
Scalaで同様のこと,つまりNoneの場合にのみ実行する処理を記述するためには,fold関数を使用できる。
fold関数により,Noneの際に実行する処理を定義し,かつ関数を適用した中身の値を取得することができる。

scala> o.fold(throw new RuntimeException)(_ * 10)
res13: Int = 30

scala> n.fold(throw new RuntimeException)(_ * 10)
java.lang.RuntimeException
  at $anonfun$2.apply(<console>:13)
  at $anonfun$2.apply(<console>:13)
  at scala.Option.fold(Option.scala:158)
  ... 42 elided

実際の複雑なアプリケーションでは,Optionの値が入れ子になることがある。
例えば,2つの整数値がOptionで返ってきて,それを掛けあわせた値を求めるような場合である。

scala> val v1 = Some(3)
v1: Some[Int] = Some(3)

scala> val v2 = Some(7)
v2: Some[Int] = Some(7)

scala> v1.map(i1 => v2.map(i2 => i1 * i2))
res14: Option[Option[Int]] = Some(Some(21))

シンプルに実装すると,殘念なことにOption[Option[Int]]のように入れ子になってしまう。これを解消するためにはflattenを使う。

scala> v1.map(i1 => v2.map(i2 => i1 * i2)).flatten
res15: Option[Int] = Some(21)

さて,ここまででmapとflattenを説明したが,実際にはこの両方を組み合わせて使うことが多々ある。そのため,両方を適用するflatMapというメソッドが用意されている。

scala> v1.flatMap(i1 => v2.map(i2 => i1 * i2))
res19: Option[Int] = Some(21)

なお,flatMapとmapを複数回使用するような場合,for式を使うとシンプルに書ける。

scala> for { i1 <- v1
     |       i2 <- v2
     |       i3 <- v3 } yield i1 * i2 * i3
res0: Option[Int] = Some(105)
Either

Optionによりnullを使う必要はなくなったが,処理が成功したかどうか(値があるかないかどうか)しかわからないという問題がある。
Noneの場合,値が取得できなかったことはわかるが,エラーの種類まではわからないので,エラーの種類が問題にならないような場合にしか使用でいない。

Eitherは,エラー時にエラーの種類まで取得できる。EitherはRightとLeftの2つの値のどちらかを表現する。

scala> val v1: Either[String, Int] = Right(123)
v1: Either[String,Int] = Right(123)

scala> val v2: Either[String, Int] = Left("abc")
v2: Either[String,Int] = Left(abc)

一般的に,Leftをエラー値,Rightを正常な値に使用することが多い。
エラー値には,代数的データ型(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

以下のユーザクラスを使い,ログインに使用するloginメソッドを書いてみる。
loginメソッドは,ユーザ名とパスワードをチェックして,正しい組み合わせの場合はUserオブジェクトを返し,エラーが起きた場合はLoginErrorを返す。

case class User(id: Long, name: String, password: String)

object LoginService {
  def login(name: String, password: String): Either[LoginError, User] = ???
}

loginメソッドを呼び出して,printlnで中身を表示してみる。

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

上記コードは,PasswordLockedとUserNotFoundの処理が漏れているため,コンパイル時に警告が出る。

このように,EitherはOptionの拡張版に近いが,mapとflatMapの動作に少し癖がある。
ScalaのEitherは直接mapやflatMapメソッドを持たない。というのも,ScalaのEitherは左右の型を平等に扱っているためである。例えばmapメソッドをEitherに適用するときを考えると,暗黙的に左右どちらかのうち片方を優先するか,明示的に左右どちらを優先するか指定する,の二択になる。前者がHaskellのアプローチで,後者がScalaのアプローチになる。
Eitherにはleftとrightというメソッドがあり,これが左右どちらを優先するか決めるメソッドになる。

scala> val v: Either[String, Int] = Right(123)
v: Either[String,Int] = Right(123)

scala> val e = v.right
e: scala.util.Either.RightProjection[String,Int] = RightProjection(Right(123))

scala> v.right.map(_ * 2)
res0: Product with Serializable with scala.util.Either[String,Int] = Right(246)

Eitherにrightメソッドを使うと,RightProjectionというオブジェクトになり,ListやOptionで見慣れたメソッドを使えるようになる。
ただし,RightProjectionのmapメソッドの返り値がEitherであることに注意してほしい。つまり,mapやflatMapを連鎖させて使う場合,毎回EitherをRightProjectionに変化させる必要があるということだ。
これはHaskellのように暗黙的にRightを優先するのに比べてわずらわしいと言われることもあるが,とりあえず,Eitherのmap/flatMapを使う場合,rightでRightProjectionに変化させるものだと覚えてしまってよい。

Try

ScalaのTryは,Eitherと同じように正常な値とエラー値のどちらかを表現するデータ型になる。
Eitherとの違いは2つの型が平等ではなく,エラー値がThrowableに限定されていることで,具体的にはTryはSuccessかFailureの2つの値をとる。
Successは型変数を取り任意の値を入れることができるが,FailureはThrowableしかいれることができない。

scala> import scala.util.Try
import scala.util.Try

scala> val s: Try[Int] = Try(2)
s: scala.util.Try[Int] = Success(2)

scala> val f: Try[Int] = Try(throw new RuntimeException("hoge"))
f: scala.util.Try[Int] = Failure(java.lang.RuntimeException: hoge)

Tryにはコンパニオンオブジェクトのapplyで生成する際に例外をcatchし,Failureにする機能がある。
この機能を使って,例外が起こりそうな箇所をTryで包み,Failureにして値として扱えるようにするのがTryの特徴である。
また,TryはEitherと異なり正常な値を片方に決めているので,mapやflatMapをそのまま使える。

Option/Either/Tryの使い分け

基本的にJavaでnullを使うような場面はOptionを使うのがよい。コレクションの中に存在しなかったり,ストレージ中から条件に合うものが見つからないような場合にはOptionで十分である。

Eitherは,Optionを使うのでは情報が不足しており,かつエラー状態が代数的データ型として定められるものに使うのがよい。Javaでチェック例外を使っていたようなところ,つまり復帰可能なエラーだけに使うという考えでもよい。Eitherと例外を併用するのもあり。

TryはJavaの例外をどうしても値として扱いたい場合に用いるとよい。非同期プログラミングで使ったり,実行結果を保存してあとで中身を参照したいときなど。

Optionの例外処理をEitherでリファクタする実例

RDBを使う場合,どのタイミングで情報が取得できなかったのかを返さねばならないことがある。

例えば,ユーザとアドレスがそれぞれ格納されており,ユーザidからユーザを検索して,ユーザが持つアドレスidでアドレスを検索し,さらにその郵便番号を取得するような場合を考える。
失敗結果としては,1) ユーザが見つからない,2) ユーザがアドレスを持っていない, 3) アドレスが見つからない, 4) アドレスが郵便番号を持っていない,という4つの失敗パターンがある。
これらを結果オブジェクトとして返さなくてはならない。

  ...(省略)...
 // どこでNoneが生じたか取得しようとするとfor式がつかえず地獄のようなネストになる
  def getPostalCodeResult(userId: Int): PostalCodeResult = {
    findUser(userId) match {
      case Some(user) =>
        user.addressId match {
          case Some(addressId) =>
            findAddress(addressId) match {
              case Some(address) =>
                address.postalCode match {
                  case Some(postalCode) => Success(postalCode)
                  case None => AddressNotHasPostalCode
                }
              case None => AddressNotFound
            }
          case None => UserNotHasAddress
        }
      case None => UserNotFound
    }
  }

  def findUser(userId: Int): Option[User] = {
    userDatabase.get(userId)
  }
  ...(省略)...

Noneをただ処理するだけならflatMapやfor式でスッキリ書けるが,どのタイミングでNoneが取得されたのかを返したい場合にはそういうわけにはいかず,結局match caseの深いネストになってしまう。
このような場合,全てのfindメソッドでEitherを使うようにし,FailureをLeftに正常結果をRightに入れるようにして書き直すことですっきり書ける。

  // 本質的に何をしているかわかりやすくリファクタリング
  def getPostalCodeResult(userId: Int): PostalCodeResult = {
    (for {
      user <- findUser(userId).right
      address <- findAddress(user).right
      postalCode <- findPostalCode(address).right
    } yield Success(postalCode)).merge
  }

  def findUser(userId: Int): Either[Failure, User] = {
    userDatabase.get(userId).toRight(UserNotFound)
  }