新しい仕事に入り、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] = ???
高階型に包まれていない値をメソッドの引数にしていない
map
はfmap
の型を見ていると、Option
やEither
などの高階型に包まれていない値を引数としてとる関数を書いていった方が、for-comprehensionをうまく使えるのではないかと思いました。
for-comprehensionはmap
や flatMap
, 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つを意識するとどう変わるか様子を見てみようと思います。