Help us understand the problem. What is going on with this article?

DI と後出しモナド:Tagless Final / Free Monad

More than 1 year has passed since last update.

以前書いた記事『Scalaでいろんな DIを試してみる Cake 〜 Free+ReaderT』で、モナドの決定までを含めて依存と考えて、Cake パターンや Free Monad などDIの方式を比べてみたが、Tagless Final の試用などいくつか課題が残った。これらについて、前回と同じ単純な題材で試してみる。

Future を使っていた部分を monix.eval.Task に変更。(2019/02/18)

使用バージョンなど

  • Scala: 2.12.8
  • cats: 1.6.0
  • monix: 3.0.0-RC2

方針

以下の記述で、特定のモナドに依存しないコードが属する層をドメイン層1、具体的なモナドについてドメインモデルを実行/評価する層をアプリケーション層と呼ぶことにする。

この2層を念頭に置きながら、「ドメイン層でMovieServiceMovieRepoに依存する。具体的なMovieRepo実装とモナドはアプリケーション層で初めて関連づけられる。」という共通のお題を考える。

これを以下の順でやってみる。

  • Tagless Final 単体
  • Minimal Cake + Tagless Final
  • Minimal Cake + Free Monad
  • Tagless Final + Free Monad
  • Minimal Cake + Tagless Final + Free Monad

Tagless Final 単体

まず前回課題として残った Tagless Final から試してみる。簡単に言うと、パラメータ化した型コンストラクタでモナドの部分を置き換えて、特定モナド用のインタプリタは後で与えるというものになる。

前回の Free Monad 版ではひと組の ADT を、sealed trait と case class で定義したが、Tagless Final では、trait で束ねたメソッドの組で表現する。Oleg Kiselyov の講義資料では、これを symantics と呼んでいるが、巷のブログなどでは'algebra'や'language'と呼んでいるものもある。

trait MovieRepoSym[F[_]] {
  def getMovie(id: Int): F[Option[Movie]]
}

使う側では渡されたsym: MovieRepoSymのメソッドをシンプルにただ評価する。

class MovieService[F[_]: Monad](sym: MovieRepoSym[F]) {
  def getMovie(id: Int): F[Option[Movie]] = sym.getMovie(id)
}

ただしsymの渡し方については、上のコードのようにクラスのコンストラクタで渡す方式(参考)や、implicit パラメータでメソッドに渡す方式(参考)など、バリエーションが色々あるらしい。

アプリケーション層では、具体的なモナドを決めたインタプリタを定義する。Free Monad では自然変換(FunctionK あるいは ~>)だったが、Tagless FinalではMovieRepoSymの実装クラスになる。

object TaskInterpreter extends MovieRepoSym[Task] {
  def getMovie(id: Int): Task[Option[Movie]] = Task(db.get(id))
}
val service = new MovieService(TaskInterpreter)
val movie: Task[Option[Movie]] = service.getMovie(42)

ここでは monix の Task をモナドとしてみた。このTaskインタプリタを与えてMovieServiceインスタンスを生成し、プログラムを記述する(ここでは getMovieだけだが)。

得られた Taskを「宇宙の終り」に実行して以下のように結果を得る。

import scala.concurrent.Await
import scala.concurrent.duration._
import monix.execution.Scheduler.Implicits.global

Await.result(movie.runToFuture, 1.second)
//Some(Movie(42,A Movie))

Task を使ったので遅延評価になっているが、これがもし Future だったとするとgetMovie の評価と同時にその中身も実行されることになる。一方、Free Monad では ADTを組み合わせてプログラムを構築するだけなので、たとえ Futureでも最後にインタプリタを適用するまで中のコードは実行されないので、この点は異なる。

また以下のように、モナドを換えて別途作成したインタプリタを関連づけて評価することもできる。

object TestInterpreter extends MovieRepoSym[Id] {
  def getMovie(id: Int): Option[Movie] = Option(Movie(-1, "Test"))
}
val id1 = new MovieService(TestInterpreter).getMovie(42)
//Some(Movie(-1,Dummy))
Tagless Final 単体 まとめ
  • シンプルで Scala のクラス定義の文法に自然に馴染む。
  • symantics が増えたときに、どうやってインタプリタを渡すか悩むかもしれない。複数のパラメータでわたすか、合成したものを定義するか、などなど。
  • ソース

Minimal Cake + Tagless Final

前回の Minimal Cake のコードFuture決め打ちにしていた部分を型パラメータにすると、自然と Tagless Final になる。

インジェクトするMovieRepoは、上の Tagless Final 単体の例で書いたMovieRepoSymと同じもので、つまりこれが Tagless Final のsymanticsに相当する。

trait MovieRepo[F[_]] {
  def getMovie(id: Int): F[Option[Movie]]
}
trait UsesMovieRepo[F[_]] {
  val movieRepo: MovieRepo[F]
}

インジェクト先のMovieServiceでもモナドがパラメータ化される(ここまでがドメイン層)。

trait MovieService[F[_]] extends UsesMovieRepo[F] {
  def getMovie(id: Int): F[Option[Movie]] = movieRepo.getMovie(id)
}

アプリケーション層では、具体的なモナドについて*ImplMixIn*を書く。特に*Implは Tagless Final のインタプリタに相当し、上の Tagless Final 単体の例でいえばTaskInterpreterと同じコードになる。

trait MixInMovieRepo {
  val movieRepo: MovieRepo[Task] = MovieRepoImpl
}
object MovieRepoImpl extends MovieRepo[Task] {
  def getMovie(id: Int): Task[Option[Movie]] = Task(db.get(id))
}

先の例では、MovieServiceのコンストラクタでインタプリタを渡していたが、Minimal Cake ではwith MixInMovieRepoでミックスインとしてインジェクトされる。実行も上と同様。

object MovieService extends MovieService[Task] with MixInMovieRepo
val program: Task[Option[Movie]] = MovieService.getMovie(42)

モナドの差し替えも自由にできる。以下ではWriterを使って、呼び出しを記録してダミーを返すようにしてみた。

type LogWriter[V] = Writer[Chain[String], V]

val logWriterMovieRepo = new MovieRepo[LogWriter] {
  def getMovie(id: Int): LogWriter[Option[Movie]] = for {
    _ <- Chain(s"getMovie($id)").tell
  } yield Movie(id, "Dummy").some
}

object TestMovieService extends MovieService[LogWriter] {
  val movieRepo = logWriterMovieRepo
}
TestMovieService.getMovie(42).run
//(Chain(getMovie(42)),Some(Movie(42,Dummy)))
Minimal Cake + Tagless Final まとめ
  • Tagless Final 単体の場合にメソッドやコンストラクタに渡していたsymanticsのインタプリタは、継承関係の仕組みを使って mixin する。
  • MovieReposymanticsMovieRepoImplがインタプリタにそれぞれそのまま相当する。すでに書いた通り、Minimal Cake のモナドを型パラメータにすると、自然と Tagless final になる。
  • ソース

Minimal Cake + Free Monad

ADTは、前回に見たFree Monad 単体版と同じ。

sealed trait Query[A]
case class GetMovie(id: Int) extends Query[Option[Movie]]

type QueryF[T] = Free[Query, T]

Minimal Cake 単体版Future決め打ちだった、MovieRepoMovieServiceの各部位をQueryFに置き換える。UsesMovieRepoはそのまま。

trait MovieRepo {
  def getMovie(id: Int): QueryF[Option[Movie]]
}
trait UsesMovieRepo {
  val movieRepo: MovieRepo
}
trait MovieService extends UsesMovieRepo {
  def getMovie(id: Int): QueryF[Option[Movie]] = movieRepo.getMovie(id)
}

Free Monad を使う通常のコーディングでは、ADT をFreeliftFする特有のボイラープレートを書くことになるが、Minimal Cake と併用する場合*Implに書くと自然になじむ。

object MovieRepoImpl extends MovieRepo {
  def getMovie(id: Int): QueryF[Option[Movie]] = liftF(GetMovie(id))
}

具体的な依存をワイアリングするコードもドメイン層に書ける2

trait MixInMovieRepo {
  val movieRepo: MovieRepo = MovieRepoImpl
}
object MovieService extends MovieService with MixInMovieRepo

インタプリタはFree Monad 単体版と同じ。実行も同様。

def taskInterpreter: Query ~> Task = new (Query ~> Task) {
  def apply[A](fa: Query[A]): Task[A] = fa match {
    case GetMovie(id) => Task(db.get(id))
  }
}
val program = MovieService.getMovie(42).foldMap(taskInterpreter)

モナドを差し替えたテストも同じなので割愛する。

Minimal Cake + Free Monad まとめ
  • Free Monad を導入する場合、ボイラープレートとなるliftFのブリッジをどこに置くか悩む予感がしていたが、自然と*Implに置き場所が定まる。
  • ソース

Tagless Final + Free Monad

Tagless Final のパラメータ化されたモナドに Free Monad を当てはめると、二段階(あるいは多段階)でモナドを差し替えられるような構成になる。

以下の部分は Tagless Final と Free Monad のそれぞれの単体版と同じ。

sealed trait Query[A]
case class GetMovie(id: Int) extends Query[Option[Movie]]

type QueryF[T] = Free[Query, T]

trait MovieRepoSym[F[_]] {
  def getMovie(id: Int): F[Option[Movie]]
}
class MovieService[F[_]: Monad](alg: MovieRepoSym[F]) {
  def getMovie(id: Int): F[Option[Movie]] =
    alg.getMovie(id)
}

インタプリタは Tagless Final 分と Free Monad 分の2通り書くことになるが、前者については以下のようにモナドをQueryFに半固定したデフォルト実装が書ける。内容は、上の Mininal Cake + Free Monad版のMovieRepoImplとほぼ同じ。

object QueryFInterpreter extends MovieRepoSym[QueryF] {
  def getMovie(id: Int): QueryF[Option[Movie]] = liftF(GetMovie(id))
}
val MovieServiceImpl = new MovieService(QueryFInterpreter)

このデフォルト実装を使う場合、Free Monad 分のインタプリタとして、変換先を任意のモナド--例えば Task に固定した自然変換先をアプリケーション層で定義し、これをfoldMapに渡して結果を得る。

def taskInterpreter: Query ~> Task = new (Query ~> Task) {
  def apply[A](fa: Query[A]): Task[A] = fa match {
    case GetMovie(id) => Task(db.get(id))
  }
}
val program = MovieServiceImpl.getMovie(42).foldMap(taskInterpreter)

さらに、tagless final のパラメータとしてQueryFとは別のモナドを直接与えることもできる。

val testMovieRepoImpl = new MovieRepoSym[Id] {
  def getMovie(id: Int): Option[Movie] = Movie(id, s"Movie($id)").some
}
val testMovieServiceImpl = new MovieService(testMovieRepoImpl)

testMovieServiceImpl.getMovie(42)
// Some(Movie(42,Movie(42)))
Tagless Final + Free Monad まとめ
  • Tagless Final の方が、symantics (algebra) の合成がやりやすいという説があるが、このシンプルすぎる例では特に利点はわからない。
  • Tagless Final 単体の時と同様に、複数のsymanticsに依存する場合に渡し方で悩むかもしれない。
  • ソース

Minimal Cake + Tagless Final + Free Monad

一応やってみる・・・

「Minimal Cake + Free Monad」の Minimal Cake 部分をパラメータ化するか、あるいは「Tagless Final + Free Monad」の Tagless Final 部分を Minimal Cake 形式にするかすれば、「Minimal Cake + Tagless Final + Free Monad」になる。解説省略。

ドメイン層
case class Movie(id: Int, title: String)

sealed trait Query[A]
case class GetMovie(id: Int) extends Query[Option[Movie]]

type QueryF[T] = Free[Query, T]

trait MovieRepo[F[_]] {
  def getMovie(id: Int): F[Option[Movie]]
}
trait UsesMovieRepo[F[_]] {
  val movieRepo: MovieRepo[F]
}
trait MovieService[F[_]] extends UsesMovieRepo[F] {
  def getMovie(id: Int): F[Option[Movie]] = movieRepo.getMovie(id)
}

object MovieRepoImpl extends MovieRepo[QueryF] {
  def getMovie(id: Int): QueryF[Option[Movie]] = liftF(GetMovie(id))
}
trait MixInMovieRepo {
  val movieRepo: MovieRepo[QueryF] = MovieRepoImpl
}
object MovieServiceImpl extends MovieService[QueryF] with MixInMovieRepo
アプリケーション層
def taskInterpreter: Query ~> Task = new (Query ~> Task) {
  def apply[A](fa: Query[A]): Task[A] = fa match {
    case GetMovie(id) => Task(db.get(id))
  }
}
val program = MovieServiceImpl.getMovie(42).foldMap(taskInterpreter)

所感

  • 記事を書き始めた当初、なんとなく Tagless Final + Free Monad を本命と考えていたが、やってみると Minimal Cake + Tagless Final か、 Minimal Cake + Free Monadが良さげに見える。
  • 個人的には、ドメインモデル全体のブラックボックステストへの関心などから'program as data'という考え方に可能性を感じるので、Free Monad はオワコン的な空気を微妙に感じつつも、Minimal Cake + Free Monad に惹かれるものがある。
  • Tagless Final と Free Monad を併用した上の二組は、いまのところ特に用途が思いつかない。ただし今回試さなかったが、ある程度、規模と複雑さが増したリアルなドメインを考えると複数の algebraADTの合成が肝になるはずで、その辺りから効いてくるのかもしれない。

参考



  1. モナドを差し替え可能にすることのそもそもの動機は、単に実運用とテストでモナドを換えたいということよりも、むしろドメインモデルから「どのように実行されるか(How)」を減らして、「なにを実行するのか(What)」を記述するビジネスロジックとしての純度を高めたいという意図もある。 

  2. *ImplMixIn*、*Service`(object の方)について、特定のモナドに依存していないとはいえ、純粋にビジネスロジックかというと若干微妙なので、ドメイン層に置くべきかどうかは一考の余地あり。 

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした