モナドトランスフォーマーの型クラスを提供する 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
Tell は WriterT#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
の他に、Tuple2
、RWST
のインスタンスが提供されている。
ソース: tell.worksheet.sc
Ask
Ask は Kleisli#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 では、値のみが Ior
の Right
で得られる。1〜3、1〜4では、ログと値の両方が Both
で得られる。1〜5、1〜6では、ログのみが Left
で得られるが、func(5)
で confess
されるため、1〜6 でも 6 は含まれない。
Listen
Listen は、文脈に蓄積されたログにアクセスするためのメソッド listen
をTell
に追加したもの。たとえば、上の 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 は、ローカルに「環境」を変更するメソッド local
を Ask
に加えたもの。例えば以下のように書ける。
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
の他には、EitherT
、OptionT
のインスタンスが提供される。
ソース: 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 で書いた