LoginSignup
9
6

More than 1 year has passed since last update.

Cats MTL 〜 8つの型クラス

Last updated at Posted at 2019-04-07

モナドトランスフォーマーの型クラスを提供する Cats の MTL について。
※ Scala 3 と最新ライブラリで書き直した (2022-08-14)

はじめに

モナドあるいはエフェクトの合成といえば、昨今では書法のシンプルさでもパフォーマンスでも Eff が有力な選択肢になるかもしれない。ただしモナドトランスフォーマーの型クラスを提供する MTL(あるいは MTLスタイル)も結構使われている1ので、MTL を活用した既存ライブラリを使う際にも、ぱっと見てだいたい理解できるくらいにはしておきたい。この記事では、Cats Mtl の8つの型クラスに着目してみる。

表にすると以下のようになる。

型クラス 使いみち 主なメソッド 継承元
Tell ログ(値の累積) tell
Ask 環境(設定、依存オブジェクト) ask
Stateful 状態 get, set
Chronicle ログ+エラーによる中断 dictate, confess
Listen 途中で読めるログ listen Tell
Local ローカルに変更できる環境 local Ask
Raise エラーによる中断 raise
Handle エラーとエラー処理/リカバリー handleWith Raise

合成・併用をせずに単体で使う場合は単なるモナドとあまり変わりないが、まず使い勝手を見るために、以下、一個ずつサンプルコードを書いてみる。

Tell

TellWriterT#tell を型クラスとして抽出したようなもので、典型的には計算過程で何かの値を「文脈」に蓄積するような、たとえばログ目的で使われる。たとえばString値を Chain に蓄積する以下のような関数が書ける。

type Logs = Chain[String]

def tellFunc[F[_]](n: Int)(using F: Tell[F, Logs], S: Sync[F]): F[Unit] =
  for
    _ <- F.tell(one("begin"))
    _ <- S.delay { println(s"n = $n") }
    _ <- F.tell(one("end"))
  yield ()

F[_] の制約として Tell の他に、for comprehension のための FlatMap と、遅延IOのための Sync を指定している。次のように実行できる。

def program: IO[Logs] = tellFunc[WriterT[IO, Logs, *]](42).written

program.unsafeRunSync()
// n = 42
// res0: Logs = Chain(begin, end)

Monix の Taskでも、Cats Effect の IO でも良いが、ここでは後者を WriterT と組み合わせた。

WriterT の他に、Tuple2RWST のインスタンスが提供されている。

ソース: tell.worksheet.sc

Ask

AskKleisli#ask を型クラスとして抽出したようなもので、「文脈」から何かの値、例えば、環境、設定、依存オブジェクトなどを取得する目的で使われる。たとえば以下のように Map[String, String]型の設定情報にアクセスする関数が書ける。遅延IO で println している部分は、他サービスや永続化層へのアクセスなどを模している。

type Config = Map[String, String]

def askFunc[F[_]](key: String)(using F: Ask[F, Config], S: Sync[F]): F[String] =
  for
    config <- F.ask
    result =  config.getOrElse(key, "none")
    _      <- S.delay { println(s"$key -> $result") }
  yield result

Kleisli と Cats Effect IO を組み合わせて traverse すると、以下のような結果が得られる。

val program: IO[List[String]] =
  List("21", "42", "63")
    .traverse(askFunc[Kleisli[IO, Config, *]])
    .run(Map("42" -> "foo"))

program.unsafeRunSync()
// 21 -> none// 42 -> foo
// 63 -> none
// res0: List[String] = List(none, foo, none)

Kleisli=ReaderT以外にも、環境 => ? となる関数や、RWST のインスタンスも提供される。

ソース: ask.worksheet.sc

Stateful

Stateful はその名の通り StateT の型クラスで、「文脈」が持つ状態の取得・変更のために使われる。たとえば以下のように、整数を3倍する計算をMap[Int, Int] 型の状態にキャッシュする関数が書ける。

type Cache = Map[Int, Int]

def triple[F[_]: Sync](n: Int): F[Int] =
  Sync[F].delay { println(show"triple($n)") } as (n * 3)

def tripleWithCache[F[_]: Sync](n: Int)(using F: Stateful[F, Cache]): F[Int] =
  for
    memo  <- F.get
    value <- memo.get(n)
      .fold(triple[F](n) >>= { v => F.modify(_.updated(n, v)) as v })(_.pure[F])
  yield value

実行すると以下のような結果になる。1 はリストに二つ含まれているが、キャッシュされるので triple は一度しか呼ばれない。3は最初からキャッシュに入っているので全く呼ばれない。

List(1, 3, 1, 2, 3)
  .traverse(tripleWithCache[StateT[IO, Cache, *]])
  .run(Map(3 -> 9))
  .unsafeRunSync()
// triple(1)
// triple(2)
// (Map(3 -> 9, 1 -> 3, 2 -> 6), List(3, 9, 3, 6, 9))

StateTの他に RWST のインスタンスも提供される。

ソース: state.worksheet.sc

Chronicle

Chronicle は他のMTL型クラスたちと違って、既存のモナドの動きからは類推しにくい。ドキュメントによれば Tell と後述の Handle のハイブリッドで、以下が主な操作になるという(定義を見ると他にもたくさんある)。

 trait MonadChronicle[F[_], E] {
  def dictate(c: E): F[Unit]               // Tell#tell
  def confess[A](c: E): F[A]               // Raise#raise
  def materialize[A](fa: F[A]): F[E Ior A] // 直前の計算が dictate か confess かで変わる Ior
}

例えば以下のような関数が書ける。与えられたn が5以上ならログに追加して処理中断、3以上ならログに追加して続行、2以下ならそのまま n を値とするような計算としてみた。

def func[F[_]: Monad](n: Int)(using F: Chronicle[F, Chain[Int]]): F[Int] =
  if      n > 4 then F.confess(Chain(-n))     // Raise#raise 相当
  else if n > 2 then F.dictate(Chain(n)) as n // Tell#tell 相当
                else n.pure[F]

以下のように計算を実行できる。IorT のインスタンスも提供されているが、簡単のためここでは Ior を使った2

type IorC[A] = Ior[Chain[Int], A]

(1 to 1).toList.traverse(func[IorC]) // Right(List(1))
(1 to 2).toList.traverse(func[IorC]) // Right(List(1, 2))
(1 to 3).toList.traverse(func[IorC]) // Both(Chain(3),List(1, 2, 3))
(1 to 4).toList.traverse(func[IorC]) // Both(Chain(3, 4),List(1, 2, 3, 4))
(1 to 5).toList.traverse(func[IorC]) // Left(Chain(3, 4, -5))
(1 to 6).toList.traverse(func[IorC]) // Left(Chain(3, 4, -5))

1、1〜2 では、値のみが IorRight で得られる。1〜3、1〜4では、ログと値の両方が Both で得られる。1〜5、1〜6では、ログのみが Left で得られるが、func(5)confess されるため、1〜6 でも 6 は含まれない。

ソース: chronicle.worksheet.sc

Listen

Listen は、文脈に蓄積されたログにアクセスするためのメソッド listenTell に追加したもの。たとえば、上の Tellのサンプルで書いたメソッド tellFunc のログを、同じF[_]のコンテキストで別の関数(ここではログ収集サーバへの送信を模した)に渡すプログラムが、以下のように書ける。

def send[F[_]: Sync](logs: Logs): F[Unit] =
  Sync[F].delay { println(s"Sending to log server: $logs") }

def program[F[_]: Sync: [F[_]] =>> Listen[F, Logs]](n: Int) =
  tellFunc[F](n).listen >>= ((_, logs) => send[F](logs))

実行すると以下のようになる。

program[WriterT[IO, Logs, *]](12345).run.unsafeRunSync()
// n = 12345
// Sending to log server: Chain(begin, end)
// res0: (cats.data.Chain[String], Unit) = (Chain(begin, end),())

提供されるインスタンスは Tell と同じ。

ソース: listen.worksheet.sc

Local

Local は、ローカルに「環境」を変更するメソッド localAsk に加えたもの。例えば以下のように書ける。

type Config = Map[String, String]

def get42[F[_]: Applicative](using F: Ask[F, Config]): F[String] =
  F.ask.map(_.getOrElse("42", "none"))

def both[F[_]: Monad](using L: Local[F, Config]): F[(String, String)] =
  for
    m <- L.local(get42[F])(_.updated("42", "modified"))
    o <- get42[F]
  yield (m, o)

both は Apply 構文でも書けるが、先に local を使った後で、オリジナルの環境にアクセスしても変更が無いことを確認するために、あえて for comprehension で書いた3。実行すると以下のようになる。

both[ReaderT[IO, Config, *]].run(Map("42" -> "original")).unsafeRunSync()
//  (modified, original)

ソース: local.worksheet.sc

Raise

Raise は、ApplicativeError#raiseError と同等のメソッド raise を提供するが、F[_]Functor であれば充分で、必ずしも ApplicativeError は求められない。

下の関数は公式サンプルとほぼ同じ parseNumber で、、、

def parseNumber[F[_]: Applicative](in: String)(using F: Raise[F, String]): F[Double] =
  if in.matches("-?[0-9]+") then in.toDouble.pure[F]
                            else F.raise(in) // raise 構文

これに Validated[String, ?] を指定して、エラーとなる入力を含めて実行すると以下のようになる。

val result: Validated[String, (Double, Double, Double)] = Semigroupal.tuple3(
  parseNumber[Validated[String, *]]("abc"),
  parseNumber[Validated[String, *]]("123"),
  parseNumber[Validated[String, *]]("xyz")
)
// result = Invalid(abcxyz)

Validated の他には、EitherTOptionT のインスタンスが提供される。

ソース: raise.worksheet.sc

Handle

Raise にエラー処理メソッド handle を追加したものが Handle。 上の Raise サンプルの parseNumber を使って、handle 有り版と無し版で比較すると下のようになる。

List("100", "abc", "200", "def") traverse parseNumber[Validated[String, *]]
// Invalid(abcdef)

List("100", "abc", "200", "def") traverse { s =>
  parseNumber[Validated[String, ?]](s).handle[String](_ => Double.NaN) // handle 構文。
}
// Valid(List(100.0, NaN, 200.0, NaN))

提供されるインスタンスは Raise と同様。

ソース: handle.worksheet.sc


次に軽く合成して、他の方式と比較してみる。

普通のモナドトランスフォーマー及び Eff との比較

このブログで、普通の Monad Transformers、MTL、Eff が比較されているが、MTL の記述がかなり古いので、今の Cats MTL にアップデートしつつ比べなおしてみたい。ここではパフォーマンスは一旦置いておいて4、書きっぷりに着目して比較する。

お題としては、Int 型の現在「状態」が1以上であればデクリメントし、0以下であればエラーとするような、StateT と EitherT の合成を考える。

普通のモナドトランスフォーマー

まずこれを普通のモナドトランスフォーマーで書くと次のようになる。

type SET[E, S, R] = StateT[Either[E, *], S, R]
type EST[E, S, R] = EitherT[State[S, *], E, R]

def decrementSE: SET[String, Int, Unit] = for
  x <-               StateT.get[Either[String, *], Int]
  _ <- if x > 0 then StateT.set[Either[String, *], Int](x - 1)
                else StateT.liftF[Either[String, *], Int, Unit](Left("error"))
yield ()

def decrementES: EST[String, Int, Unit] = for
  x <-               EitherT.liftF[State[Int, *], String, Int](State.get[Int])
  _ <- if x > 0 then EitherT.liftF[State[Int, *], String, Unit](State.set(x - 1))
                else EitherT.leftT[State[Int, *], Unit]("error")
yield ()

モナドスタックの重ね方で2通りに書ける。というか重なり方の順序に従って書き分ける必要がある。

使う側のコードも、当然、モナドの順序によって変わってくる。

def runSE(n: Int): Either[String, (Int, Unit)] = decrementSE.run(n)
def runES(n: Int): (Int, Either[String, Unit]) = decrementES.value.run(n).value

実行結果は以下のようになる。

runSE(0) // Left(error)
runSE(1) // Right((0,()))
runES(0) // (0,Left(error))
runES(1) // (0,Right(()))

ソース: monad_transformer.worksheet.sc

Eff 版

次に Eff で書いてみる。

type _eitherString[R] = Either[String, *] |= R
type _stateInt[R]     = State[Int, *]     |= R

def decr[R: _eitherString: _stateInt]: Eff[R, Unit] = for
  x <- get
  _ <- if x > 0 then put(x - 1) else left("error")
yield ()

さすがにスッキリと書ける。実行も簡潔。

type ETree = Fx.fx2[State[Int, *], Either[String, *]]

def runSE(n: Int): Either[String, (Unit, Int)] = decr[ETree].runState(n).runEither.run
def runES(n: Int): (Either[String, Unit], Int) = decr[ETree].runEither.runState(n).run

モナドトランスフォーマー版とほぼ同じような結果になるが、Eff の state 構文の仕様で State のタプルの左右が逆になる。

runSE(0) // Left(error)
runSE(1) // Right(((), 0))
runES(0) // (Left(error),0)
runES(1) // (Right(()),0)

ソース: eff.worksheet.sc

MTL 版

最後に MTL。敢えて Eff 風に書いてみた。参考にした記事とは違って、今の Cats MTL では結構シンプルに書ける。

type MSI[F[_]] = Stateful[F, Int]
type FRS[F[_]] = Raise[F, String]

def decr[F[_]: Monad: MSI: FRS]: F[Unit] = for
  x <- get
  _ <- if x > 0 then set(x - 1) else raise("error")
yield ()

関数の定義自体は、Eff と似たような感じで書けるが、使う側のコードは Eff に比べると合成した型の指定に少し違いが出る。

  type SET[R] = StateT [Either[String, *], Int   , R]
  type EST[R] = EitherT[State [Int   , *], String, R]

  def runSE(n: Int): Either[String, (Int, Unit)] = decr[SET].run(n)
  def runES(n: Int): (Int, Either[String, Unit]) = decr[EST].value.run(n).value

モナドの重ね方を明示した上で、値の取り出し方もそれに合わせる必要がある。結果の値は、普通のモナドトランスフォーマー版と同じものになる。

ソース: mtl.worksheet.sc

補足

バージョン等

  • Scala: 3.1.3
  • cats-core: 2.8.0
  • cats-effect: 3.3.12
  • cats-mtl-core: 1.3.0
  • eff: 6.0.1
  • その他 この辺り

※ サンプルは VSCode の worksheet で書いた

資料

  1. AecorMonadActionMonadActionRejectなど。

  2. Ior は Either のように「どちらか」だけではなく「どちらも」を含む型

  3. FlatMap で実行順序を保証

  4. 合成するエフェクトの数/深さで変わってくる。

9
6
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
9
6