Edited at

ZIO Environment 〜 Tagless Final の後継?

最近、関数型 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 IOBIO[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 を返している。

ソース Gist


振る舞いの合成と DI2

次に振る舞いの合成を試してみる。

Tagless Final や Free Monad では「代数」などと呼ばれ、M[F[_]] といった形のインターフェイスや ADT の Sum Type でエンコードされていた「振る舞い」は、ZIO Environment では結果型を ZIO に入れただけの、昔ながらの普通のインターフェイス定義になる。

以下のサンプルでは、独自定義の LoggerKVStore と、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 が無い場合の NoneLeft(NoValue) にしてから ZIO を作っている。IO.fromEither 以外にも、OptionTryFiberなどから 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) をテストするコードいろいろ
}

ソース Gist


所感など


  • インターフェイスに対するプログラミング」といった古き良き OOP 原則がリバイバルしてきた感。作者が挙げているメリットとして教えやすさもあったが、数学っぽい関数型プログラミングの経験が少ない、オブジェクト指向寄りの Better Java な Scala プログラマにも、少しとっつきやすいのではなかろうかと思う。


  • hoge[F[_]: X: Y: Z] といった制約や implicit のリストなどもないのでスッキリする。型推論も単純になるので、IDE との相性も良いらしく補完がいろいろ効きやすい。

  • ボイラープレートの少なさも謳われていたが、上例の Logger.infoKVStore.valueOf のようなボイラープレートっぽいヘルパ関数は若干残ることになりそう。

  • ただそもそも論として、Cats Effect の IO[A]、Scalaz や Monix の Task[A]cats-bio や Scalaz 8 のBIO[E, A]の進化系にはなっているかもしれないけど、(Free Monad) → Tagless Final → ZIO Environment って位置づけにはやや違和感。IOTaskIO[E,?]をパラメータ化したものが Tagless Final たちが扱ってきた F[_] なのだから、そもそも議論の地平が違う気がする4。だからこそ プレゼンの 1h6m あたりから、F[_] としての ZIO[R, E, ?] を併用した、ベターな Tagless final の復活について語られているのではなかろうか。

  • なので「環境+エラー+非同期並行を併せ持ったハイブリッドでハイパフォーマンスなモナド」くらいの感覚で扱うスタンスで、差し当たりは良いのでないかと思う。


参考資料





  1. trimap 関数があると思いきや今のところ見当たらない。代数的法則としては、R/E、R/A の profunctor に準じているという(詳細未読)。ちなみに Septifunctor (IO[O,M,G,_,W,T,F])というジョーク もある。 



  2. DI とはオブジェクトのグラフを作るものだという観点から、ZIO Environment それ自身直接には DI に関するものではなくて、effect tracking に関するものであるという注意が Final Tagless seen alive でなされている。 



  3. Free Monad の場合、Cats の EitherK や Iota の CopK で振る舞いを合成していたが、F[_] が無いからそうした特別な仕掛けが不要になる。 



  4. Final Tagless seen alive でも、Tagless final の効用には effect trackingeffect wrapper constraining の二面があって、Goes 氏の批判が当てはまるのも、ZIO によって改善されるのも前者についてのみであろうとの指摘がある。