ScalazでOperationalモナドが簡単に使えることが分かったので、次の段階としてxuweiさんの書かれた「CoproductとInjectを使ったFree Monadの合成とExtensible Effects」を参考に、Operationalモナド(Freeモナド)の合成を試してみました。
CoproductとInjectについて、理論や動作メカニズムを把握できていないのでほぼ写経の状態です。
ConsoleService
まず最初は前回作成したConsoleServiceを合成可能にチューニングします。
プログラムの見通しをよくするため今回のテーマに関係しないinterpreterTaskとrunTaskは省いています。
package sample
import scala.language.higherKinds
import scalaz._, Scalaz._
object ConsoleService {
sealed trait ConsoleOperation[_]
case class PrintLine(msg: String) extends ConsoleOperation[Unit]
case object ReadLine extends ConsoleOperation[String]
def printLine(msg: String) = Free.liftFC(PrintLine(msg))
def readLine = Free.liftFC(ReadLine)
val interpreter = new (ConsoleOperation ~> Id) {
def apply[T](c: ConsoleOperation[T]): Id[T] = {
c match {
case PrintLine(msg) => println(msg)
case ReadLine => scala.io.StdIn.readLine()
}
}
}
def run[T](f: Free.FreeC[ConsoleOperation, T]): T = {
Free.runFC(f)(interpreter)
}
class ConsolePart[F[_]](implicit I: Inject[ConsoleOperation, F]) {
def printLine(msg: String): Free.FreeC[F, Unit] = Free.liftFC(I.inj(PrintLine(msg)))
def readLine: Free.FreeC[F, String] = Free.liftFC(I.inj(ReadLine))
}
object ConsolePart {
implicit def instance[F[_]](implicit I: Inject[ConsoleOperation, F]): ConsolePart[F] = new ConsolePart[F]
}
}追加したのは、scalaz.InjectによってOperationalモナドの合成を行うための受け皿となるクラスConsolePartとそのコンパニオンオブジェクトです。
class ConsolePart[F[_]](implicit I: Inject[ConsoleOperation, F]) {
def printLine(msg: String): Free.FreeC[F, Unit] = Free.liftFC(I.inj(PrintLine(msg)))
def readLine: Free.FreeC[F, String] = Free.liftFC(I.inj(ReadLine))
}
object ConsolePart {
implicit def instance[F[_]](implicit I: Inject[ConsoleOperation, F]): ConsolePart[F] = new ConsolePart[F]
}ConsoleServiceオブジェクト本体に定義されているprintLine関数、readLine関数の合成可能版を定義しています。
暗黙パラメタで渡されてきたInjectのinjメソッドを使ってcase class(PrintLine, ReadLine)をインジェクト可能にしたもの(?)をFree.liftFCでOperationalモナド化しています。
コンパニオンオブジェクトの方にはおまじないの暗黙変換関数を定義しています。
AuthService
次にConsoleServiceに合成して使用するOperationalモナドとしてAuthOperationをAuthServiceに定義します。
Operationalモナド化のターゲットとなる1階カインド型のトレイトとしてAuthOperationを、具体的なコマンドとなるcase classとしてLoginを定義してます。
インタープリターの実行エンジンはinterpreterとして定義しています。
その後ろにあるAuthPartクラスとコンパニオンオブジェクトがOperationalモナド合成のために必要な定義です。
package sample
import scala.language.higherKinds
import scalaz._, Scalaz._
object AuthService {
sealed trait AuthOperation[_]
case class Login(user: String, password: String) extends AuthOperation[Unit]
val interpreter = new (AuthOperation ~> Id) {
def apply[T](c: AuthOperation[T]): Id[T] = {
c match {
case Login(login, password) => println(s"$login:$password")
}
}
}
class AuthPart[F[_]](implicit I: Inject[AuthOperation, F]) {
def login(user: String, password: String): Free.FreeC[F, Unit] = Free.liftFC(I.inj(Login(user, password)))
}
object AuthPart {
implicit def instance[F[_]](implicit I: Inject[AuthOperation, F]): AuthPart[F] = new AuthPart[F]
}
}この例では、プログラムの意図を見やすくするためAuthService本体ではユーティリティ関数を定義していませんが、実応用時にはAuthService本体とAuthPartの両方で定義して、合成非使用時、合成使用時のどちらのケースでも動作可能にしておくことになります。
Utility
Operationalモナド合成のために必要なユーティリティ関数orを定義します。これは「CoproductとInjectを使ったFree Monadの合成とExtensible Effects」にある関数をそのまま持ってきています。
package sample
import scala.language.higherKinds
import scalaz._, Scalaz._
object Utility {
def or[F[_], H[_], G[_]](
fg: F ~> G, hg: H ~> G
): ({ type f[x] = Coproduct[F, H, x]})#f ~> G = {
new (({type f[x] = Coproduct[F,H,x]})#f ~> G) {
def apply[A](c: Coproduct[F,H,A]): G[A] = c.run match {
case -\/(fa) => fg(fa)
case \/-(ha) => hg(ha)
}
}
}
}2つの自然変換(NaturalTransformation)を合成した自然変換を作成する関数のようです。
積(product)の双対であるCoproduct(余積)は直和と同等ということらしく、型レベルのEither(Disjoint union)と考えてよさそうです。Scalazの内部実装もScalazの「\/」を使っています。
合成後の自然変換はCoproductとして渡されてきた型が合成された2つの自然変換のどちらに該当するかを判定して、該当する自然変換によって変換を行う、というロジックだと思います。
ConsoleAuthService
さていよいよ合成です。
ConsoleServiceとAuthServiceを合成したConsoleAuthServiceを定義してみました。
package sample
import scalaz._, Scalaz._
object ConsoleAuthService {
import ConsoleService._, AuthService._
type ConsoleAuth[A] = Coproduct[ConsoleOperation, AuthOperation, A]
val interpreter = Utility.or(ConsoleService.interpreter, AuthService.interpreter)
def run[T](f: Free.FreeC[ConsoleAuth, T]) = Free.runFC(f)(interpreter)
}定義はとても簡単で以下の3行だけです。
- CoproductによるOperationalモナドの合成
- 実行エンジンの合成
- runメソッド
CoproductによるOperationalモナドの合成
Coproductを使って2つのOperationalモナド(ConsoleOperation, AuthOperation)を合成します。
type ConsoleAuth[A] = Coproduct[ConsoleOperation, AuthOperation, A]
正確には、2つのOperationalモナドのいずれかを保持するCoproductを定義する、ということになろうかと思います。
実行エンジンの合成
Utilityのor関数でConsoleServiceの実行エンジンとAuthServiceの実行エンジンを合成します。実行エンジンは自然変換なので、汎用の自然変換の合成機能で合成することができます。
val interpreter = Utility.or(ConsoleService.interpreter, AuthService.interpreter)
意味的には、「2つのOperationalモナドのいずれかを保持するCoproduct」をターゲットの関手に変換する自然変換、ということになろうかと思います。
runメソッド
runメソッドでは、「2つのOperationalモナドをCoproductを使って合成したもの」をFreeモナド化したものをパラメタで受取り、「2つのOperationalモナドをCoproductを使って合成したもの」の実行エンジンをFree.runFC関数で適用して処理を実行しています。
def run[T](f: Free.FreeC[ConsoleAuth, T]) = Free.runFC(f)(interpreter)
App
2つのOperationalモナドを合成したConsoleAuthServiceの使用方法は以下になります。
package sample
import scala.language.higherKinds
import scalaz._, Scalaz._
object App {
import ConsoleService.ConsolePart, AuthService.AuthPart
def program[F[_]](implicit C: ConsolePart[F], A: AuthPart[F]) = {
import C._, A._
for {
password <- readLine
_ <- login("user", password)
} yield ()
}
def main(args: Array[String]) {
ConsoleAuthService.run(program)
}
}program関数の実装はConsoleServiceやAuthServiceを単独で使う場合と同様にfor式によるものですが以下の点が異なっています。
- 暗黙パラメタとしてscalaz.Injectを使った合成用のクラスConsolePartとAuthPartを受け取っている。
- ConsoleServiceとAuthServiceのユーティリティ関数として暗黙パラメタで受け取ったクラスのメソッドを使用している。
このあたりをおまじないとして考えると、ConsoleServiceやAuthServiceを単独で使う場合とほとんど変わらない手間で、ConsoleServiceとAuthServiceを合成したConsoleAuthServiceを使用できています。
まとめ
Scalaz 7.1.0でOperationalモナド(Freeモナド)の合成が簡単にできることが確認できました。
モナドを合成できるのもよいですが、モナドの実行エンジンも自然変換を使って簡単に合成できるのも好感触です。
今まではモナドは合成できないためモナド変換子(e.g. WriteT)を都度作るアプローチだったと思いますが、Operationalモナド/Freeモナドを使うことで合成によるアプローチを取ることも可能になったと理解してよいのかもしれません。
どこまで実用性があるかは未知数ですが、この検証も含みにアプリケーションやフレームワークで定義するモナドはOperationalモナドを第1候補に考えてみたいと思っています。
諸元
- Scala 2.11.4
- Scalaz 7.1.0
