atnos-effのEff.addLastをEffでエミュレーション
はじめに
atnos-effはScalaでEff(More Extensible Effects)を扱うためのライブラリーである。EffはOlegさんと石井さんの論文で提案されたものであるが、atnos-effではこの論文には述べられていないEff.addLast
という機能を提供している。addLast
はEffにインタープリターを適用して最後に何か副作用のようなものを実行するために存在する。この文書ではaddLast
の動きについてまずは説明して、その次に問題点を述べる。そのあとに論文で述べられているEffの機能を用いてaddLast
をエミュレーションすることを目指し、それによって既存のaddLast
の問題の解決を目指す。
またこの記事に登場するコードの一部はGitHubで公開されている。
addLast
と既存の問題
addLast
とは?
前述のとおりatnos-effはEff.addLast
というものがあり、これは次のようなインターフェースとなっている。
def addLast(l: =>Eff[R, Unit]): Eff[R, A] =
addLast(Last.eff(l))
private[eff] def addLast(l: Last[R]): Eff[R, A]
Eff
はADTとして3つのデータ型であるPure
とImpure
と、そしてImpureAp
を持つ[1]。それらにそれぞれ次のようなlast
というフィールドが追加されているため、このようにaddLast
を利用することができる。ただしImpureAp
はApplicativeで合成されたときには専用の処理(並列化など)が書けるようにするための拡張である。
case class Pure[R, A](value: A, last: Last[R] = Last.none[R]) extends Eff[R, A] {
def addLast(l: Last[R]): Eff[R, A] =
Pure(value, last <* l)
}
case class Impure[R, X, A](union: Effect[R, X], continuation: Continuation[R, X, A], last: Last[R] = Last.none[R]) extends Eff[R, A] {
def addLast(l: Last[R]): Eff[R, A] =
Impure[R, X, A](union, continuation, last <* l)
}
case class ImpureAp[R, X, A](unions: Unions[R, X], continuation: Continuation[R, Vector[Any], A], last: Last[R] = Last.none[R]) extends Eff[R, A] {
def addLast(l: Last[R]): Eff[R, A] =
ImpureAp[R, X, A](unions, continuation, last <* l)
}
このように全てのデータlast
というフィールドが存在しており、addLast
は既存のlast
と入力のl
を<*
(= productL
)で結合するような形で実装されている。この<*
は次のように実装されている。
def <*(last: Last[R]): Last[R] =
(value, last.value) match {
case (None, None) => this
case (Some(r), None) => this
case (None, Some(l)) => last
case (Some(r), Some(l)) => Last(Option(r.map2(l)((a, b) => b <* a)))
}
そして、このようにして結合されたlast
は最後にEff[NoFx, A] => A
とするための関数Eff.run
で実行される。
問題点
ここでmap2
を利用していることが大きな問題であると著者は考えている。ImpureAp
の説明でも少し言及したが、atnos-effにはApplicativeの場合とMonadの場合で、効率化など目的にあわせて別の振る舞いができるようになっている。これはたとえばFuture
の挙動を考えると分かりやすい。
- Monadの場合
-
ma.flatMap(a => f(a))
となり、ma
に依存した関数f
を実行するため、ma
を実行してから逐次f
を実行する
-
- Applicativeの場合
-
ma.map2(mb)((a, b) => f(a, b))
となり、ma
とmb
は結果に依存関係がないため2つを同時に実行してからf
を実行すればよい
-
このような違いにより、flatMap
(モナド)からmap2
などのApplicativeのインスタンスは自動生成できるが、あえて別の実装を作ることで効率を得ている。例としたFuture
のようにScalaではこのようなApplicativeにおいては挙動を変更する(Monadインスタンスとの一貫性が実はない)ことが普通に行なわれているため、おそらくそれをサポートするためにatnos-effもApplicativeはより効率のよい別の処理が書けるようになっていると思われる。
この議論を踏まえてaddLast
の合成に注目する。いま合成のコードはLast(Option(r.map2(l)((a, b) => b <* a)))
となっているが、これに型をつけると次のようになる。
Last(Option(
(r: Eval[Eff[R, Unit]]).map2(l: Eval[Eff[R, Unit]])
((a: Eff[R, Unit], b: Eff[R, Unit]) => b <* a)
))
つまり2つのEff
な値であるa
とb
は<*
(= b.map2(a){(b, _) => b}
)で合成されている。したがってaddLast
は最後にApplicativeな値として実行されることになる。これはインタープリターによってはaddLast
の追加順序と実際の実行順序がまったく一致しない可能性を孕んでいるといえる。したがって、たとえばメインのコードはTask
などで非同期にしつつも、終了処理は順序通りにやってもらうというようなことはできない。
atnos-eff-last-action
そこで今回はこのような挙動をEffの改造を使うことなく対応した。ソースコードは下記のリポジトリーに存在している。
まずはケースクラスなどを定義する。
sealed trait LastAction[A]
case class SideEffectLastAction(
value: Eval[Unit]
) extends LastAction[Unit]
type _last[R] = LastAction |= R
そしてインタープリターは次のようになっている。
def runLast[U](implicit
m: Member.Aux[LastAction, R, U]
): Eff[U, A] =
Interpret
.runInterpreter(eff)(new Interpreter[LastAction, U, A, Eval[A]] {
override def onPure(a: A): Eff[U, Eval[A]] = {
Eff.pure(Eval.now(a))
}
override def onEffect[X](
x: LastAction[X],
continuation: Continuation[U, X, Eval[A]]
): Eff[U, Eval[A]] = {
x match {
case SideEffectLastAction(value) =>
continuation(())
.map { eval =>
value >> eval
}
}
}
override def onLastEffect[X](
x: LastAction[X],
continuation: Continuation[U, X, Unit]
): Eff[U, Unit] =
x match {
case SideEffectLastAction(value) =>
continuation(()).map(_ => value.value)
}
override def onApplicativeEffect[X, T[_] : Traverse](
xs: T[LastAction[X]],
continuation: Continuation[U, T[X], Eval[A]]
): Eff[U, Eval[A]] = {
continuation
.apply(xs.map { case SideEffectLastAction(_) => () })
.map { eval =>
xs.foldLeft(Eval.Unit) {
case (acc, SideEffectLastAction(value)) =>
value >> acc
} >> eval
}
}
})
.map(_.value)
軽く解説すると次のようになる。
-
SideEffectLastAction
にパターンマッチすることでGADTsであるLastAction[X]
の型X
をUnit
に固定している- よって継続(
continuation
)を(): Unit
で起動することができる
- よって継続(
- 結合には
*>
ではなくて>>
(=ma.flatMap(a => f(a).map(a))
)を使っている
このように専用のエフェクトとすることで、Eff.addLast
のようにMonadなのかApplicativeなのかの差を気にしなくてよくなるうえ、さらにGo言語のdefer
のように逆順に実行されるといった挙動も簡単に作ることができる。
まとめ
いちおうすでにatnos-effにはPRを送信してある。
ただこれがマージされるかどうか?についてはよく分からない。元々の論文にはない挙動であり、著者にもどちらがいいか?という判断がつかなかったからである。
いずれにしてもaddLast
を狙ったとおり動かすなら、場合によってはインタープリターを自作などしても解決しないためforkする必要があるなど面倒になる。一方でこのようにインタープリターを使った手法なら自作すればいくらでも挙動を変更することができるため、よりよいと考えている。
-
ImpureAp
は元となった論文には登場しない。 ↩︎
Discussion