15
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

DI とモナドの差し替え:Tagless Final / Free Monad

Last updated at Posted at 2018-04-02

以前書いた記事『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層を念頭に置きながら、「ドメイン層で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 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)}

アプリケーション層では、具体的なモナドについて*ImplMixIn*を書く。特に*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 する。
  • MovieReposymanticsMovieRepoImplがインタプリタにそれぞれそのまま相当する。すでに書いた通り、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決め打ちだった、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))

具体的な依存をワイアリングするコードもドメイン層に書ける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 を併用した上の二組は、いまのところ特に用途が思いつかない。ただし今回試さなかったが、ある程度、規模と複雑さが増したリアルなドメインを考えると複数の algebraADTの合成が肝になるはずで、その辺りから効いてくるのかもしれない。
  • Future を使っていた部分を monix.eval.Task に変更。(2019/02/18)
  • Scala を 3 に、その他のライブラリも最新に変更。(2022/08/10)
  • Task を使っていた部分を IO に変更。(2022/08/10) 4

参考


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

  2. 純粋関数型界隈ではサイド・エフェクトを伴う関数によって組み立てられたプログラムを実行する最後の瞬間を "the end of the world/universe" と言う。

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

  4. この時点での monix が Cats Effect 3に対応していなかったため

15
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?