以前書いた記事『Scalaでいろんな DIを試してみる Cake 〜 Free+ReaderT』で、モナドの決定までを含めて依存と考えて、Cake パターンや Free Monad などDIの方式を比べてみたが、Tagless Final の試用などいくつか課題が残った。これらについて、前回と同じ単純な題材で試してみる。
使用バージョンなど
- Scala: 3.1.3
- cats: 2.8.0
- cats effect: 3.3.12
方針
以下の記述で、特定のモナドに依存しないコードが属する層をドメイン層1、具体的なモナドについてドメインモデルを実行/評価する層をアプリケーション層と呼ぶことにする。
この2層を念頭に置きながら、「ドメイン層でMovieService
はMovieRepo
に依存する。具体的な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 IOInterpreter extends MovieRepoSym[IO]:
def getMovie(id: Int): IO[Option[Movie]] = IO(db.get(id))
val service = MovieService(IOInterpreter)
val program = service.getMovie(42)
ここでは cats effect の IO
をモナドとしてみた。このIO
インタプリタを与えてMovieService
インスタンスを生成し、プログラムを記述する(ここでは getMovie
だけだが)。
得られた IO
を「宇宙の終り2」に実行して以下のように結果を得る。
program.unsafeRunSync()
//Some(Movie(42,A Movie))
IO
を使ったので遅延評価になっているが、これがもし Future
だったとするとgetMovie
の評価と同時にその中身も実行されることになる。一方、Free Monad では ADTを組み合わせてプログラムを構築するだけなので、たとえ Future
でも最後にインタプリタを適用するまで中のコードは実行されないので、この点は異なる。
また以下のように、モナドを換えて別途作成したインタプリタを関連づけて評価することもできる。
object TestInterpreter extends MovieRepoSym[Id]:
def getMovie(id: Int): Option[Movie] = Option(Movie(-1, "Dummy"))
val id1 = MovieService(TestInterpreter).getMovie(42)
//Some(Movie(-1,Dummy))
Tagless Final 単体 まとめ
- シンプルで Scala のクラス定義の文法に自然に馴染む。
- symantics が増えたときに、どうやってインタプリタを渡すか悩むかもしれない。複数のパラメータでわたすか、合成したものを定義するか、などなど。
- ソース: DI_tagless.worksheet.sc
Minimal Cake + Tagless Final
前回の Minimal Cake のコードでIO
決め打ちにしていた部分を型パラメータにすると、自然と 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)}
アプリケーション層では、具体的なモナドについて*Impl
とMixIn*
を書く。特に*Impl
は Tagless Final のインタプリタに相当し、上の Tagless Final 単体の例でいえばIOInterpreter
と同じコードになる。
trait MixInMovieRepo:
val movieRepo: MovieRepo[IO] = MovieRepoImpl
object MovieRepoImpl extends MovieRepo[IO]:
def getMovie(id: Int): IO[Option[Movie]] = IO(db.get(id))
先の例では、MovieService
のコンストラクタでインタプリタを渡していたが、Minimal Cake ではwith MixInMovieRepo
でミックスインとしてインジェクトされる。実行も上と同様。
object MovieService extends MovieService[IO] with MixInMovieRepo
val program: IO[Option[Movie]] = MovieService.getMovie(42)
モナドの差し替えも自由にできる。以下ではWriter
を使って、呼び出しを記録してダミーを返すようにしてみた。
type LogWriter[V] = Writer[Chain[String], V]
val logWriterMovieRepo: MovieRepo[LogWriter] = new:
def getMovie(id: Int): LogWriter[Option[Movie]] =
Chain(s"getMovie($id)").tell as 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 する。
-
MovieRepo
がsymantics、MovieRepoImpl
がインタプリタにそれぞれそのまま相当する。すでに書いた通り、Minimal Cake のモナドを型パラメータにすると、自然と Tagless final になる。 - ソース:DI_tagless_min_cake.worksheet.sc
Minimal Cake + Free Monad
ADTは、前回に見たFree Monad 単体版と同じ。
enum Query[A]:
case GetMovie(id: Int) extends Query[Option[Movie]]
type QueryF[T] = Free[Query, T]
Minimal Cake 単体版でFuture
決め打ちだった、MovieRepo
、MovieService
の各部位を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 をFree
にliftF
する特有のボイラープレートを書くことになるが、Minimal Cake と併用する場合*Impl
に書くと自然になじむ。
object MovieRepoImpl extends MovieRepo:
def getMovie(id: Int): QueryF[Option[Movie]] = liftF(GetMovie(id))
具体的な依存をワイアリングするコードもドメイン層に書ける3。
trait MixInMovieRepo:
val movieRepo: MovieRepo = MovieRepoImpl
object MovieService extends MovieService with MixInMovieRepo
インタプリタはFree Monad 単体版と同じ。実行も同様。
def ioInterpreter: Query ~> IO = new:
def apply[A](fa: Query[A]): IO[A] = fa match
case GetMovie(id) => IO(db.get(id))
val program = MovieService.getMovie(42).foldMap(ioInterpreter)
モナドを差し替えたテストも同じなので割愛する。
Minimal Cake + Free Monad まとめ
- Free Monad を導入する場合、ボイラープレートとなる
liftF
のブリッジをどこに置くか悩む予感がしていたが、自然と*Impl
に置き場所が定まる。 - ソース:DI_free_monad_min_cake.worksheet.sc
Tagless Final + Free Monad
Tagless Final のパラメータ化されたモナドに Free Monad を当てはめると、二段階(あるいは多段階)でモナドを差し替えられるような構成になる。
以下の部分は Tagless Final と Free Monad のそれぞれの単体版と同じ。
enum Query[A]:
case GetMovie(id: Int) extends Query[Option[Movie]]
type QueryF[T] = Free[Query, T]
trait MovieRepoAlg[F[_]]:
def getMovie(id: Int): F[Option[Movie]]
class MovieService[F[_]: Monad](alg: MovieRepoAlg[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 MovieRepoAlg[QueryF]:
def getMovie(id: Int): QueryF[Option[Movie]] = liftF(GetMovie(id))
val MovieServiceImpl = MovieService(QueryFInterpreter)
このデフォルト実装を使う場合、Free Monad 分のインタプリタとして、変換先を任意のモナド--例えば IO
に固定した自然変換先をアプリケーション層で定義し、これをfoldMap
に渡して結果を得る。
def ioInterpreter: Query ~> IO = new:
def apply[A](fa: Query[A]): IO[A] = fa match
case GetMovie(id) => IO(db.get(id))
val program = MovieServiceImpl.getMovie(42).foldMap(ioInterpreter)
さらに、tagless final のパラメータとしてQueryF
とは別のモナドを直接与えることもできる。
val testMovieRepoImpl: MovieRepoAlg[Id] = new:
def getMovie(id: Int): Option[Movie] = Movie(id, s"Movie($id)").some
val testMovieServiceImpl = MovieService(testMovieRepoImpl)
testMovieServiceImpl.getMovie(42)
// Some(Movie(42,Movie(42)))
Tagless Final + Free Monad まとめ
- Tagless Final の方が、symantics (algebra) の合成がやりやすいという説があるが、このシンプルすぎる例では特に利点はわからない。
- Tagless Final 単体の時と同様に、複数のsymanticsに依存する場合に渡し方で悩むかもしれない。
- ソース:DI_tagless_final_free_monad.worksheet.sc
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)
enum Query[A]:
case 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 ioInterpreter: Query ~> IO = new:
def apply[A](fa: Query[A]): IO[A] = fa match
case GetMovie(id) => IO(db.get(id))
val program = MovieServiceImpl.getMovie(42).foldMap(ioInterpreter)
所感
- 記事を書き始めた当初、なんとなく 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 を併用した上の二組は、いまのところ特に用途が思いつかない。ただし今回試さなかったが、ある程度、規模と複雑さが増したリアルなドメインを考えると複数の algebra /ADTの合成が肝になるはずで、その辺りから効いてくるのかもしれない。
Future
を使っていた部分をmonix.eval.Task
に変更。(2019/02/18)- Scala を 3 に、その他のライブラリも最新に変更。(2022/08/10)
-
Task
を使っていた部分をIO
に変更。(2022/08/10) 4
参考
- Free and tagless compared - how not to commit to a monad too early
- Free Monad vs Tagless Final
- Exploring Tagless Final pattern for extensive and readable Scala code
- Typed Tagless Final Interpreters
-
モナドを差し替え可能にすることのそもそもの動機は、単に実運用とテストでモナドを換えたいということよりも、むしろドメインモデルから「どのように実行されるか(How)」を減らして、「なにを実行するのか(What)」を記述するビジネスロジックとしての純度を高めたいという意図もある。 ↩
-
純粋関数型界隈ではサイド・エフェクトを伴う関数によって組み立てられたプログラムを実行する最後の瞬間を "the end of the world/universe" と言う。 ↩
-
*Impl
、MixIn*、
*Service`(object の方)について、特定のモナドに依存していないとはいえ、純粋にビジネスロジックかというと若干微妙なので、ドメイン層に置くべきかどうかは一考の余地あり。 ↩ -
この時点での monix が Cats Effect 3に対応していなかったため ↩