最近、関数型 Scala 界隈で物議を醸していたプレゼン『The Death of Final Tagless』と、その関連ブログ記事で紹介されていた ZIO Environment について。
ZIO の形
簡略化すると以下のような型になる。
trait ZIO[-R, +E, +A]
イメージ的には、Cats Effect の IO[A]
に環境型 R とエラー型 E を追加した型、あるいは ReaderT[EitherT[IO, E, ?], R, A]
を一つの型にしてモナドトランスフォーマーのパフォーマンス劣化を除いたような型。bifunctor IO の BIO[E, A]
に R を加えた形になるので trifunctor というワードが出ることもある1。
既存の型に似せた下のような型エイリアスも提供されている。
エイリアス | ZIO | memo |
---|---|---|
IO[+E, +A] | ZIO[Any, E, A] | いわゆる Bifunctor IO |
Task[+A] | ZIO[Any, Throwable, A] | Monix などと同様、エラー型が Throwable 固定のZIO |
TaskR[-R, +A] | ZIO[R, Throwable, A] | Task に環境 R を与えたもの |
UIO[+A] | ZIO[Any, Nothing, A] | エラーを加味しない IO |
もちろん Monix や Cats Effect が提供するような、非同期/並行といった functional effect の性質も備えているが、この記事では 環境 R に着目してみる。
サンプルコード
文字列→文字列の単純な辞書データを「環境」とするシンプルなプログラムと、これまで Tagless final や Free Monad でエンコードされてきたような複数サービスの合成を「環境」とするプログラムの二つのサンプルを書いてみる。
※ scalaz-zio のバージョンは "1.0-RC3" を使った
シンプルなプログラム
例えば以下のような関数が書ける。
def valueOf(key: String): ZIO[Map[String, String], AppError, String] =
ZIO.accessM { env => ZIO.fromEither(env.get(key).toRight(NoValue)) }
この accessM
は、Map[String, String]
型の環境 env
から key
に対応する文字列の取得を試み、もしなければ指定した AppError
型の NoValue
を結果とする。
エラー型は Throwable
でなくともなんでも良くて、以下のような型を定義した。
sealed trait AppError
case object NoValue extends AppError
この関数 valueOf
は、for comprehension 内で以下のように普通に使える。
val program: ZIO[Map[String, String], AppError, Unit] = for {
s <- IO.effectTotal(StdIn.readLine()) // コンソールから入力
v <- valueOf(s) // 所与の「環境」から値を得てみる
_ <- IO.effectTotal(println(v)) // コンソールに出力
} yield ()
※ ここではコンソール入出力を自前で書いたが、ZIO で Console
クラスが提供されている。次のサンプルではそっちを使う。
program は、scalaz.zio.App#run
をオーバーライドして、以下のように実行できる。
object SimpleMain extends App {
...
def run(args: List[String]): ZIO[Environment, Nothing, Int] =
program.provide(Map("42" -> "Foo")).fold(_ => 1, _ => 0)
}
program.provide
に「環境」Map("42" -> "Foo")
を与えて実行。失敗なら 1、成功なら 0 を返している。
振る舞いの合成と DI2
次に振る舞いの合成を試してみる。
Tagless Final や Free Monad では「代数」などと呼ばれ、M[F[_]]
といった形のインターフェイスや ADT の Sum Type でエンコードされていた「振る舞い」は、ZIO Environment では結果型を ZIO
に入れただけの、昔ながらの普通のインターフェイス定義になる。
以下のサンプルでは、独自定義の Logger
、KVStore
と、ZIO で提供される Console
の3つを合成する。
振る舞いの定義の構成として、Goes 氏流にアレンジされた Module パターンを踏襲してみる。Minimal Cake などのようなパターンを使うこともできるが、Console
を含む ZIO 内部の実装でも、元ブログ記事でもこの Module パターンが使われているので、せっかくなので合わせることにする。
Logger
Module パターンは以下のように構成する。この場合 Logger
がモジュール(HasLogger という命名習慣もある)、Logger.Service
がサービスになる。ちなみに Minimal Cake パターンでいえば、Logger
が UsesLogger、Logger.Serice
が Logger、Logger.Live
が MixinLogger に、おおむね対応すると思う。
trait Logger { val logger: Logger.Service } // モジュール
object Logger {
trait Service { def info(line: String): UIO[Unit] } // サービス
trait Live extends Logger { // モジュールの実装
val logger: Service = new Service { // サービスの実装
def info(line: String): UIO[Unit] = UIO.effectTotal(println(line))
}
}
...
}
見ての通り F[_]
を含んだ Logger[F[_]]
にはならないので、Tagless Final 的な高カインド型に扱いにくさを感じる向きにも易しく見えるのではないかと思う。
KVStore
KVStore
も同様。ここでは String -> String
の Map をダミーとして使った。
trait KVStore { val kvStore: KVStore.Service }
object KVStore {
trait Service { def valueOf(key: String): IO[AppError, String] }
trait Live extends KVStore {
private val dummy: Map[String, String] = Map("42" -> "Foo")
val kvStore: Service = new Service {
def valueOf(key: String): IO[AppError, String] =
IO.fromEither(dummy.get(key).toRight[AppError](NoValue))
}
}
...
}
valueOf
メソッドの実装では、IO.fromEither
を使って、key
に対応する value が無い場合の None
を Left(NoValue)
にしてから ZIO
を作っている。IO.fromEither
以外にも、Option
、Try
、Fiber
などから ZIO
を生成する各種メソッドが提供されている。
ヘルパ関数
利便性のため、各インターフェイスに対して以下のようなヘルパ関数を用意しておく。
object Logger {
...
def info(line: String): ZIO[UsesLogger, Nothing, Unit] =
ZIO.accessM(_.logger info s"INFO: $line")
}
object KVStore {
...
def valueOf(key: String): ZIO[KVStore, AppError, String] =
ZIO.accessM(_.kvStore valueOf key)
}
※ Goes 氏のブログやプレゼンでも必須ではないとされているが、なければやはり扱いにくそう。このあたりのヘルパ関数は、Free Monad のリフト関数とも似たボイラープレートになる気がしないでもない。
合成と使用
そもそも F[_]
を使っていないので、型の合成は普通にできる。3
type AppType = Console with Logger with KVStore
上で定義したヘルパー関数をこの AppType
と併用すると、プログラムは以下のように定義できる。
val program: ZIO[AppType, AppError, Unit] = for {
key <- getStrLn.orDie // Console で文字列入力を得て
value <- valueOf(key) // KVStore から対応する値を得て
_ <- info(s"$key -> $value") // Logger で出力する
} yield ()
implicit で悩んだり、型推論を補助するために型の明示を補ったりすることもなくシンプルに書ける。
インスタンスも型同様に合成して、以下のように実行できる。
trait Env extends Console with Logger with KVStore
object Env extends Console.Live with Logger.Live with KVStore.Live
object Main extends App {
...
def run(args: List[String]): ZIO[Environment, Nothing, Int] =
program.provide(Env).fold(
e => { println(e); -1 },
_ => 0
)
}
テストでは、例えば以下のようにして普通にコンパイル時DIで書ける。
object MainSpec {
object testEnv extends Env {
val console: Console.Service[Any] = ??? //モック的なやつをあれする
val logger: Logger.Service = ???
val kvStore: KVStore.Service = ???
}
// Main.program.provide(testEnv) をテストするコードいろいろ
}
所感など
- 「インターフェイスに対するプログラミング」といった古き良き OOP 原則がリバイバルしてきた感。作者が挙げているメリットとして教えやすさもあったが、数学っぽい関数型プログラミングの経験が少ない、オブジェクト指向寄りの Better Java な Scala プログラマにも、少しとっつきやすいのではなかろうかと思う。
-
hoge[F[_]: X: Y: Z]
といった制約や implicit のリストなどもないのでスッキリする。型推論も単純になるので、IDE との相性も良いらしく補完がいろいろ効きやすい。 - ボイラープレートの少なさも謳われていたが、上例の
Logger.info
、KVStore.valueOf
のようなボイラープレートっぽいヘルパ関数は若干残ることになりそう。 - ただそもそも論として、Cats Effect の
IO[A]
、Scalaz や Monix のTask[A]
、cats-bio や Scalaz 8 のBIO[E, A]
の進化系にはなっているかもしれないけど、(Free Monad) → Tagless Final → ZIO Environment って位置づけにはやや違和感。IO
、Task
、IO[E,?]
をパラメータ化したものが Tagless Final たちが扱ってきたF[_]
なのだから、そもそも議論の地平が違う気がする4。だからこそ プレゼンの 1h6m あたりから、F[_]
としてのZIO[R, E, ?]
を併用した、ベターな Tagless final の復活について語られているのではなかろうか。 - なので「環境+エラー+非同期並行を併せ持ったハイブリッドでハイパフォーマンスなモナド」くらいの感覚で扱うスタンスで、差し当たりは良いのでないかと思う。
参考資料
- ZIO microsite
- ZIO github
- The Death of Final Tagless
- Beautiful, Simple, Testable Functional Effects for Scala
- Final Tagless seen alive
-
trimap 関数があると思いきや今のところ見当たらない。代数的法則としては、R/E、R/A の profunctor に準じているという(詳細未読)。ちなみに Septifunctor (IO[O,M,G,_,W,T,F])というジョーク もある。 ↩
-
DI とはオブジェクトのグラフを作るものだという観点から、ZIO Environment それ自身直接には DI に関するものではなくて、effect tracking に関するものであるという注意が Final Tagless seen alive でなされている。 ↩
-
Free Monad の場合、Cats の
EitherK
や Iota のCopK
で振る舞いを合成していたが、F[_]
が無いからそうした特別な仕掛けが不要になる。 ↩ -
Final Tagless seen alive でも、Tagless final の効用には effect tracking と effect wrapper constraining の二面があって、Goes 氏の批判が当てはまるのも、ZIO によって改善されるのも前者についてのみであろうとの指摘がある。 ↩