モナドの本当の力を引き出す・・・モナドによる同期/非同期プログラミングの抽象化

以下の2つの続き

上記の2つ(特に最初の方)を読んだことを前提で書くので、読んでない人は先にそちらを読みましょう。


なんだか少し関連する話(?)で盛り上がっていて、書かないといけない気がしてきたので

非同期プログラミングの難しさとScalaのFuture


そのtogetterの議論について色々書きたいこと*1もありますが、それは置いておき、表題の「モナドによる同期/非同期プログラミングの抽象化」について書きます。というか、(非同期プログラミングの話より)便乗してモナドモナドトランスフォーマーの便利さを話したいだけかもしれません(?)



前回2つは「Future使って非同期にしても、だいたい関数の本体同じでいけるよ」ということを書きました。
この "大体" というものがポイントです。
「大体同じなら、完璧に同じものにして一つの関数にすればいいのでは?」
と、人は考える、思いつきますよね?そういうことにしておきます。


というわけで、再び以前の例を載せます


同期version

// followers は Either[Error, List[User]] を返す

def isFriends(user1: UserId, user2: UserId): Either[Error, Boolean] =
  for{
    a <- followers(user1).right
    b <- followers(user2).right
  } yield a.exists(_.id == user2) && b.exists(_.id == user1)

非同期version

// followers は EitherT[Future, Error, List[User]] を返す

def isFriends(user1: UserId, user2: UserId): EitherT[Future, Error, Boolean] =
  for{
    a <- followers(user1)
    b <- followers(user2)
  } yield a.exists(_.id == user2) && b.exists(_.id == user1)


もう面倒なので、いきなり答え

abstract class UsersRepository[F[_]](implicit val F: Monad[F]){
  import F.bindSyntax._

  def followers(userId: UserId): F[List[User]]

  def isFriends(user1: UserId, user2: UserId): F[Boolean] =
    for{
      a <- followers(user1)
      b <- followers(user2)
    } yield a.exists(_.id == user2) && b.exists(_.id == user1)
}

そして

  • FutureもEitherも使わない(例外投げる。単なるIdentityモナド)
  • Futureは使わないけど、Eitherは使う
  • Futureは使うけど、Eitherは使わない
  • FutureとEither組み合わせてEitherT[Future, Error, A]

などなど、上記のどの組み合わせもモナドなので、下記のようにfollowers側の実装を変えるだけで
「isFriendsのロジックは全く変えることなく、同期 or 非同期、エラーにEither使うか否か?を切り替えることができます」

// 実装は全部ダミーでNil返してます

val id =
  new UsersRepository[Id] {
    def followers(userId: UserId): List[User] =
      Nil
  }

val either =
  new UsersRepository[({type l[a] = Error \/ a})#l] {
    def followers(userId: UserId): Error \/ List[User] =
      \/.right(Nil)
  }

val future =
  new UsersRepository[Future] {
    def followers(userId: UserId): Future[List[User]] =
      Future.successful(Nil)
  }

val eitherTFuture =
  new UsersRepository[({type l[a] = EitherT[Future, Error, a]})#l] {
    def followers(userId: UserId): EitherT[Future, Error, List[User]] =
      F.point(Nil)
  }


さて、上記では4通り示しましたが「Fは任意のモナド」でよいので、先ほど示したUsersRepositoryの活用の可能性はモナドの種類(とその組み合わせ)の数だけ、広がります。

たとえば
「isFriendsメソッドは全く変えることなく、followersメソッドの呼び出しにかかった時間をWriterモナドで記録して、その実行時間の結果をisFriendsメソッド経由で返す」
などでしょうか?

val writer =
  new UsersRepository[({type l[a] = Writer[List[Long], a]})#l] {
    def followers(userId: UserId): Writer[List[Long], List[User]] = {
      val start = System.currentTimeMillis()
      val result = id.followers(userId)
      val time = System.currentTimeMillis() - start
      Writer(List(time), result)
    }
  }

// 使う側

val (times, result) = UsersRepository.writer.isFriends(1, 2).run
println("times " + times) // timesは、要素が2つ(followersメソッドの2回呼び出した分)のList[Long]のはず
println(result)


そしてWriterにもモナドトランスフォーマーがあるので、

  • Either使うか?
  • Future使うか?
  • Writerで実行時間記録するか?

で、2 × 2 × 2 で、すでに8通りくらいは可能?なわけですが、
(大事なことなので何度も言いますが)この8通りすべてにおいて、モナドの力により、isFriendsのメソッドそのものは、全く変更する必要がありません。
おそらくこのような抽象化はモナド以外の方法では難しいでしょう。(このような抽象化がどのくらい必要になるか?ここまでやるだけのコストに見合うメリットがあるか?は別の話として置いておきますが)
もちろん8通りではなく、何度も言いますが"任意のモナド"で可能なので、他にも好きなモナドを組み合わせてみてください。


このように

  • 最初から、同期でよくEither使わなくても、Idモナドと考えて抽象化して書いておく
  • 任意のモナドFに対して、正常系だけを書けばいいメソッド

に関しては、「モナドを使うかどうか?でコードが大きく変わる」というのは嘘だということがわかりますね?もちろんその2つの前提は簡単に無視できるものでもないし、今回の説明ではメリットと基本的な原理や例を説明してるだけなので、実際使っていく上でもっと色々と考慮すべきことはあるでしょう。

また「正常系だけを書けばいいメソッド」と言いました。たしかに異常系がはさまったら、今回説明したものでは単純にうまくいかなそうですね?しかし、ScalazやHaskellにはその
「(異常系の場合に具体的にどういう型で扱うか?に関わらず)異常系の場合の型を抽象化して扱う型クラス」
があります。(たぶんこのあたりとか、その他いくつか)
そこに関しては説明長くなるし、まだ自分もうまく説明できる気がしないので、別の機会に書く、かもしれません。





ところで、すでに気がついている人もいるかもしれませんが、この話、ある程度は以下のやつ
抽象的な Future
と同じです。



あと、followerFollowerのメソッドに関しても、ほぼ同じ方式でできるはずなので、やってみてください。


最後にコンパイル通る状態の全部のコードを含んだgist貼り付け

https://gist.github.com/xuwei-k/a9a48b6c984674a43bb7


というわけで、これを読んで「モナドモナドトランスフォーマーすごい!」と感動しつつ、Scalaモナドトランスフォーマー使う際の型推論の残念さとか辛さを一緒に味わいましょう(?)

*1:playのことなど