for-comprehensionをうまく使うために必要そうだと思ったこと

新しい仕事に入り、1週間が経過しました。 私としては有給消化中にコップ本を読んでおけば事前準備としてはよくやった方ではないかと思っていたのですが、Play FrameworkやDBとのやりとりに使っているScalikeJDBCなど、新しく知るべきことはたくさんあり、逆にコップ本すら読んでいなかったらどれほどの混乱が待ち受けていたのか、少し怖くなりました。

一方で、for-comprehensionはHaskellのdo notation同様、複数のOption, Either値を扱う際、ネストしたパターンマッチングの平坦化などにおいて強力な武器になるすばらしい構文だと感じています。 しかし、私の職場ではどうもうまく扱えていないケースが多いと感じたので原因を考えたところ、以下のような部分に原因があるのではないかと思いました。

エラーの原因が分かるように型を設計していない

たとえば、Web APIを提供するある外部システムに対して、2回連続でリクエストを送るとします。 2回目のリクエストは、1回目のリクエストが正常に処理された場合のみ行うものとします。

sealed abstract class ApiError
case class ApiError1(reason: String) extends ApiError
case class ApiError2(reason: String) extends ApiError

def requestA(): Either[ApiError, String] = ???
def requestB(): Either[ApiError, String] = ???

for-comprehensionを使わずに書くと大体このようになるでしょう。

requestA() match {
  case Left(ApiError1(reason)) => println(s"requestAが失敗しました ${reason}")
  case Right(resultA) =>
    requestB() match {
      case Left(reason) => println(s"requestBが失敗しました ${reason}")
      case Right(resultB) => {
        println("成功しました")
        println(resultA)
        println(resultB)
      }
  }
}

次に、for-comprehensionを使うとこうなります。

val wholeResult = for {
  resultA <- requestA()
  resultB <- requestB()
} yield (resultA, resultB)

wholeResult match {
  case Left(ApiError1(reason)) => println(s"requestAが失敗しました ${reason}")
  case Left(ApiError2(reason)) => println(s"requestBが失敗しました ${reason}")
  case Right((resultA, resultB)) => {
    println("成功しました")
    println(resultA)
    println(resultB)
  }
}

途中でLeft値が返ってきた場合、for-comprehensionがその時点でLeft値を返し、最後まで成功した時のみRight値が返ってくるので、最後にfor-comprehensionが返した値に対して一度だけパターンマッチングを書いてハンドリングをすればよくなります。 こちらの書き方の方が、リクエストとそれに対するハンドリングが分離していて読みやすいと思います。

しかし、たとえばrequestA()requestB()がOption型で失敗を表していたとしたらどうでしょう。

def requestA(): Option[String] = ???
def requestB(): Option[String] = ???

val wholeResult = for {
  resultA <- requestA()
  resultB <- requestB()
} yield (resultA, resultB)

wholeResult match {
  case None => // このパターンにマッチした時requestAが失敗したのかrequestBが失敗したのかわからない!
  case Some((resultA, resultB)) => {
    println("成功しました")
    println(resultA)
    println(resultB)
  }
}

このように、for-comprehensionが返した値に対してハンドリングすればいいというわけには行かなくなってしまいます。 ではどうするのかというと、最初にやったように、requestA()requestB()が返した値を逐一パターンマッチングし、Noneが返ってきたタイミングによってハンドリングの内容を決定するというやり方になってしまいます。 最終的に得られる値ではなく、Noneが返ってくるタイミングにハンドリングが委ねられてしまうんですね。

requestA() match {
  case None => println(s"requestAが失敗しました")
  case Some(resultA) =>
    requestB() match {
      case None => println(s"requestBが失敗しました")
      case Some(resultB) => {
        println("成功しました")
        println(resultA)
        println(resultB)
      }
  }
}

さすがにAPIの成否を、エラーメッセージを返せないOption型で表すことはあまり無いと思いますが、私が見た中にこのようにエラーを表現しているケースがありました。

case class ApiError(errorNo: Int, message: String)

この型ですと、2つ以上のエラー原因に対して同じエラーNoが割り当てられていたり、もっとよくないケースとしてはAPIが返してくるエラーNoが仕様書に残されていなかったりすると、結局はLeft値が返ってきたタイミングでエラーハンドリングするしか無くなってしまいます。

結局、私は以下のようなApiError値をラップする型を作り、ApiErrorを返してくる関連するメソッドの処理でこれら型の値を返すようにすることで解決しました。

case class ApiError(errorNo: Int, message: String)

sealed abstract class RequestError
case class RequestAFailed(error: ApiError) extends RequestError
case class RequestBFailed(error: ApiError) extends RequestError

def requestA(): Either[RequestError, String] = ???
def requestB(): Either[RequestError, String] = ???

高階型に包まれていない値をメソッドの引数にしていない

mapfmapの型を見ていると、OptionEitherなどの高階型に包まれていない値を引数としてとる関数を書いていった方が、for-comprehensionをうまく使えるのではないかと思いました。

for-comprehensionはmapflatMap, withFilterの構文糖ですが、それぞれをC[A]型の値のメソッドとして引数と返り値の型を見てみると、

  • map(A => B) => C[B]
  • flatMap(A => C[B]) => C[B]
  • withFilter(A => Boolean) => C[A]

となります。 引数に渡している関数を見ると、どれもC[A]型ではなくA型の値を引数に取ることがわかります。 なので、高階型に包まれていないA型の値を引数にするようなメソッドを積極的に定義することで、for-comprehensionも使いやすくなってくるのではないかと感じました。

他にも原因があると思いますが、しばらくこの2つを意識するとどう変わるか様子を見てみようと思います。