Processing math: 100%

2015年1月14日水曜日

Operationalモナドの合成

ScalazでOperationalモナドが簡単に使えることが分かったので、次の段階としてxuweiさんの書かれた「CoproductとInjectを使ったFree Monadの合成とExtensible Effects」を参考に、Operationalモナド(Freeモナド)の合成を試してみました。

CoproductとInjectについて、理論や動作メカニズムを把握できていないのでほぼ写経の状態です。

ConsoleService

まず最初は前回作成したConsoleServiceを合成可能にチューニングします。

プログラムの見通しをよくするため今回のテーマに関係しないinterpreterTaskとrunTaskは省いています。

  1. package sample  
  2.   
  3. import scala.language.higherKinds  
  4. import scalaz._, Scalaz._  
  5.   
  6. object ConsoleService {  
  7.   sealed trait ConsoleOperation[_]  
  8.   case class PrintLine(msg: String) extends ConsoleOperation[Unit]  
  9.   case object ReadLine extends ConsoleOperation[String]  
  10.   
  11.   def printLine(msg: String) = Free.liftFC(PrintLine(msg))  
  12.   def readLine = Free.liftFC(ReadLine)  
  13.   
  14.   val interpreter = new (ConsoleOperation ~> Id) {  
  15.     def apply[T](c: ConsoleOperation[T]): Id[T] = {  
  16.       c match {  
  17.         case PrintLine(msg) => println(msg)  
  18.         case ReadLine => scala.io.StdIn.readLine()  
  19.       }  
  20.     }  
  21.   }  
  22.   
  23.   def run[T](f: Free.FreeC[ConsoleOperation, T]): T = {  
  24.     Free.runFC(f)(interpreter)  
  25.   }  
  26.   
  27.   class ConsolePart[F[_]](implicit I: Inject[ConsoleOperation, F]) {  
  28.     def printLine(msg: String): Free.FreeC[F, Unit] = Free.liftFC(I.inj(PrintLine(msg)))  
  29.     def readLine: Free.FreeC[F, String] = Free.liftFC(I.inj(ReadLine))  
  30.   }  
  31.   
  32.   object ConsolePart {  
  33.     implicit def instance[F[_]](implicit I: Inject[ConsoleOperation, F]): ConsolePart[F] = new ConsolePart[F]  
  34.   }  
  35. }  

追加したのは、scalaz.InjectによってOperationalモナドの合成を行うための受け皿となるクラスConsolePartとそのコンパニオンオブジェクトです。

  1. class ConsolePart[F[_]](implicit I: Inject[ConsoleOperation, F]) {  
  2.     def printLine(msg: String): Free.FreeC[F, Unit] = Free.liftFC(I.inj(PrintLine(msg)))  
  3.     def readLine: Free.FreeC[F, String] = Free.liftFC(I.inj(ReadLine))  
  4.   }  
  5.   
  6.   object ConsolePart {  
  7.     implicit def instance[F[_]](implicit I: Inject[ConsoleOperation, F]): ConsolePart[F] = new ConsolePart[F]  
  8.   }  

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モナド合成のために必要な定義です。

  1. package sample  
  2.   
  3. import scala.language.higherKinds  
  4. import scalaz._, Scalaz._  
  5.   
  6. object AuthService {  
  7.   sealed trait AuthOperation[_]  
  8.   case class Login(user: String, password: String) extends AuthOperation[Unit]  
  9.   
  10.   val interpreter = new (AuthOperation ~> Id) {  
  11.     def apply[T](c: AuthOperation[T]): Id[T] = {  
  12.       c match {  
  13.         case Login(login, password) => println(s"login:password")  
  14.       }  
  15.     }  
  16.   }  
  17.   
  18.   class AuthPart[F[_]](implicit I: Inject[AuthOperation, F]) {  
  19.     def login(user: String, password: String): Free.FreeC[F, Unit] = Free.liftFC(I.inj(Login(user, password)))  
  20.   }  
  21.   
  22.   object AuthPart {  
  23.     implicit def instance[F[_]](implicit I: Inject[AuthOperation, F]): AuthPart[F] = new AuthPart[F]  
  24.   }  
  25. }  

この例では、プログラムの意図を見やすくするためAuthService本体ではユーティリティ関数を定義していませんが、実応用時にはAuthService本体とAuthPartの両方で定義して、合成非使用時、合成使用時のどちらのケースでも動作可能にしておくことになります。

Utility

Operationalモナド合成のために必要なユーティリティ関数orを定義します。これは「CoproductとInjectを使ったFree Monadの合成とExtensible Effects」にある関数をそのまま持ってきています。

  1. package sample  
  2.   
  3. import scala.language.higherKinds  
  4. import scalaz._, Scalaz._  
  5.   
  6. object Utility {  
  7.   def or[F[_], H[_], G[_]](  
  8.     fg: F ~> G, hg: H ~> G  
  9.   ): ({ type f[x] = Coproduct[F, H, x]})#f ~> G = {  
  10.     new (({type f[x] = Coproduct[F,H,x]})#f ~> G) {  
  11.       def apply[A](c: Coproduct[F,H,A]): G[A] = c.run match {  
  12.         case -\/(fa) => fg(fa)  
  13.         case \/-(ha) => hg(ha)  
  14.       }  
  15.     }  
  16.   }  
  17. }  

2つの自然変換(NaturalTransformation)を合成した自然変換を作成する関数のようです。

積(product)の双対であるCoproduct(余積)は直和と同等ということらしく、型レベルのEither(Disjoint union)と考えてよさそうです。Scalazの内部実装もScalazの「\/」を使っています。

合成後の自然変換はCoproductとして渡されてきた型が合成された2つの自然変換のどちらに該当するかを判定して、該当する自然変換によって変換を行う、というロジックだと思います。

ConsoleAuthService

さていよいよ合成です。

ConsoleServiceとAuthServiceを合成したConsoleAuthServiceを定義してみました。

  1. package sample  
  2.   
  3. import scalaz._, Scalaz._  
  4.   
  5. object ConsoleAuthService {  
  6.   import ConsoleService._, AuthService._  
  7.   
  8.   type ConsoleAuth[A] = Coproduct[ConsoleOperation, AuthOperation, A]  
  9.   val interpreter = Utility.or(ConsoleService.interpreter, AuthService.interpreter)  
  10.   def run[T](f: Free.FreeC[ConsoleAuth, T]) = Free.runFC(f)(interpreter)  
  11. }  

定義はとても簡単で以下の3行だけです。

  • CoproductによるOperationalモナドの合成
  • 実行エンジンの合成
  • runメソッド
CoproductによるOperationalモナドの合成

Coproductを使って2つのOperationalモナド(ConsoleOperation, AuthOperation)を合成します。

  1. type ConsoleAuth[A] = Coproduct[ConsoleOperation, AuthOperation, A]  

正確には、2つのOperationalモナドのいずれかを保持するCoproductを定義する、ということになろうかと思います。

実行エンジンの合成

Utilityのor関数でConsoleServiceの実行エンジンとAuthServiceの実行エンジンを合成します。実行エンジンは自然変換なので、汎用の自然変換の合成機能で合成することができます。

  1. val interpreter = Utility.or(ConsoleService.interpreter, AuthService.interpreter)  

意味的には、「2つのOperationalモナドのいずれかを保持するCoproduct」をターゲットの関手に変換する自然変換、ということになろうかと思います。

runメソッド

runメソッドでは、「2つのOperationalモナドをCoproductを使って合成したもの」をFreeモナド化したものをパラメタで受取り、「2つのOperationalモナドをCoproductを使って合成したもの」の実行エンジンをFree.runFC関数で適用して処理を実行しています。

  1. def run[T](f: Free.FreeC[ConsoleAuth, T]) = Free.runFC(f)(interpreter)  

App

2つのOperationalモナドを合成したConsoleAuthServiceの使用方法は以下になります。

  1. package sample  
  2.   
  3. import scala.language.higherKinds  
  4. import scalaz._, Scalaz._  
  5.   
  6. object App {  
  7.   import ConsoleService.ConsolePart, AuthService.AuthPart  
  8.   
  9.   def program[F[_]](implicit C: ConsolePart[F], A: AuthPart[F]) = {  
  10.     import C._, A._  
  11.     for {  
  12.       password <- readLine  
  13.       _ <- login("user", password)  
  14.     } yield ()  
  15.   }  
  16.   
  17.   def main(args: Array[String]) {  
  18.     ConsoleAuthService.run(program)  
  19.   }  
  20. }  

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

2015年1月8日木曜日

Operationalモナド

おぼろげな記憶ではFreeモナドが話題になったのが一昨々年、Operationalモナドは一昨年だったと思うので、いまさらの感もありますが、ScalazでOperationalモナドが簡単に使えることが判明したのでメモしておきます。

Freeモナドは、FunctorをMonad化することができるモナドですが、以下の性質を持ったインタープリターのDSLを簡単に作れることで知られています。

  • トランポリン化によりjoin演算時の再帰呼び出しによるスタックオーバーフローを回避
  • インタープリターの実行エンジンが疎結合になっており取替え可能
  • 副作用を実行エンジン側に分離(2015-01-14追記)

Operationlモナドは、Coyonedaにより型パラメータが1つの1階カインド型のデータ(Scala的にはcase classで実装。以下case class。)をFunctor化する事が可能な性質を利用して、case classから直接Freeモナド化したものです。

ざっくりいうとcase classでインタープリターのオペレーションを定義すれば、ここから簡単にインタープリターの(monadicな)DSLを作ることができるわけです。

プロダクトではScalaz 7.0.6を使っているのですが、このバージョンでOperationalモナドを使おうとするとかなり込み入った呪文を唱えないといけません。

Operationalモナドは非常に便利そうなんですが、呪文が必要になるとすると、あまり気軽に使えなくなってしまいます。

この問題がScalaz 7.1.0で解消されていることが判明したわけです。(Scalaz 7.1.0のリリースが昨年8月のようなのでいまさらですが。)

ConsoleService

Scalaz 7.1.0の機能を使用したOperationalモナドの実装は以下になります。

  1. package sample  
  2.   
  3. import scalaz._, Scalaz._  
  4. import scalaz.concurrent.Task  
  5.   
  6. object ConsoleService {  
  7.   sealed trait ConsoleOperation[_]  
  8.   case class PrintLine(msg: String) extends ConsoleOperation[Unit]  
  9.   case object ReadLine extends ConsoleOperation[String]  
  10.   
  11.   def printLine(msg: String) = Free.liftFC(PrintLine(msg))  
  12.   def readLine: Int = Free.liftFC(ReadLine)  
  13.   
  14.   val interpreter = new (ConsoleOperation ~> Id) {  
  15.     def apply[T](c: ConsoleOperation[T]): Id[T] = {  
  16.       c match {  
  17.         case PrintLine(msg) => println(msg)  
  18.         case ReadLine => scala.io.StdIn.readLine()  
  19.       }  
  20.     }  
  21.   }  
  22.   
  23.   def run[T](f: Free.FreeC[ConsoleOperation, T]): T = {  
  24.     Free.runFC(f)(interpreter)  
  25.   }  
  26.   
  27.   val interpreterTask = new (ConsoleOperation ~> Task) {  
  28.     def apply[T](c: ConsoleOperation[T]): Task[T] = {  
  29.       Task.delay {  
  30.         c match {  
  31.           case PrintLine(msg) => println(msg)  
  32.           case ReadLine => scala.io.StdIn.readLine()  
  33.         }  
  34.       }  
  35.     }  
  36.   }  
  37.   
  38.   def runTask[T](f: Free.FreeC[ConsoleOperation, T]): Task[T] = {  
  39.     Free.runFC(f)(interpreterTask)  
  40.   }  
  41. }  
case classの定義

まずインタープリターのコマンドとなるcase classを定義します。

ここではコンソールに文字列を出力するPrintLineとコンソールから文字列を入力するReadLineを定義しています。この2つのcase classの親クラスとしてConsoleOperationトレイトを定義しています。このConsoleOperationトレイトが型パラメータを1つ取る構造になっているためCoyonedaによるOperationalモナド化が可能になっています。

定義時のイメージとしてはcase classの引数にコマンドの引数、case classの親クラスであるConsoleOperationの型パラメータにコマンドの返り値を定義します。

  1. sealed trait ConsoleOperation[_]  
  2.   // PrintLine(msg: String): Unit  
  3.   case class PrintLine(msg: String) extends ConsoleOperation[Unit]  
  4.   // ReadLine(): String  
  5.   case object ReadLine extends ConsoleOperation[String]  
ユーティリティ関数

次にFreeモナドとして使いやすくするためのユーティリティ関数を定義します。

定義は簡単で、scalaz.FreeのliftFC関数に(条件に合致した)case classを渡すだけです。

Scalaz 7.0.6ではこのliftFC関数がなかったために呪文が必要だったわけです。

  1. def printLine(msg: String) = Free.liftFC(PrintLine(msg))  
  2.   def readLine: Int = Free.liftFC(ReadLine)  
インタープリター

ConsoleServiceの利用時には、Freeモナドの内部構造としてPrintLineやReadLineの列が構築されますが、これを解釈して実行するインタープリターを定義します。

これはScalazのNaturalTransformation(自然変換)を用いて実装します。NaturalTransformation自体は1階カインド型間の変換(圏論的には関手間の射?)の機能ですが、ここではモナド間の変換に適用します。

まずConsoleOperationモナド(Operationalモナド化したもの)をIdモナドに変換する自然変換によるインタープリターの実行エンジンです。

  1. val interpreter = new (ConsoleOperation ~> Id) {  
  2.     def apply[T](c: ConsoleOperation[T]): Id[T] = {  
  3.       c match {  
  4.         case PrintLine(msg) => println(msg)  
  5.         case ReadLine => scala.io.StdIn.readLine()  
  6.       }  
  7.     }  
  8.   }  

Idモナドは実行結果をモナドにくるまず直接取得する時に使用するモナドです。

変換先としてIDモナドを指定することで、インタープリターの実行結果の値を取得できるようになります。

実行ユーティリティ

NaturalTransformationとして実装されたインタープリターはscalaz.FreeのrunFC関数で実行することができます。

Scalaz 7.0.6ではこのrunFC関数もなかったために、ここでも呪文が必要でした。

  1. def run[T](f: Free.FreeC[ConsoleOperation, T]): T = {  
  2.     Free.runFC(f)(interpreter)  
  3.   }  
Task版インタープリターと実行ユーティリティ

scalaz.concurrent.Taskは、scala.util.Tryとscala.concurrent.Futureの機能を併せ持ったようなモナドです。例外のキャッチ、遅延評価、並列実行といった目的で汎用的に使用することができます。

scala.util.Tryとscala.concurrent.FutureはScalaz 7.1.0的にはモナドとして定義されていないので、Monadicプログラミングをする上ではTaskの方が都合がよいです。

ここではFreeモナドConsoleOperationをTaskに変換するインタープリターを実装しました。正確にはインタープリターの実行を行う関数が束縛されたTaskが返ってきます。

実行はID版と同様にscalaz.FreeのrunFC関数を用います。

  1. val interpreterTask = new (ConsoleOperation ~> Task) {  
  2.     def apply[T](c: ConsoleOperation[T]): Task[T] = {  
  3.       Task.delay {  
  4.         c match {  
  5.           case PrintLine(msg) => println(msg)  
  6.           case ReadLine => scala.io.StdIn.readLine()  
  7.         }  
  8.       }  
  9.     }  
  10.   }  
  11.   
  12.   def runTask[T](f: Free.FreeC[ConsoleOperation, T]): Task[T] = {  
  13.     Free.runFC(f)(interpreterTask)  
  14.   }  

Operationalモナドを使う

OperationalモナドによるConsoleServiceの使用方法は以下になります。

  1. package sample  
  2.   
  3. import ConsoleService._  
  4.   
  5. object App {  
  6.   def main(args: Array[String]) {  
  7.     console_id  
  8.     console_task  
  9.   }  
  10.   
  11.   def console_id {  
  12.     val program = for {  
  13.       msg <- readLine  
  14.       _ <- printLine(msg)  
  15.     } yield Unit  
  16.     run(program)  
  17.   }  
  18.   
  19.   def console_task {  
  20.     val program = for {  
  21.       msg <- readLine  
  22.       _ <- printLine(msg)  
  23.     } yield msg  
  24.     val task = runTask(program)  
  25.     task.run  
  26.   }  
  27. }  
IDモナド

console_idメソッドではConsoleServiceのrunメソッドを使用して、プログラムの実行を行います。IDモナドを使っているため、(処理を行う関数が束縛された)モナドが返されるのではなく処理が直接実行されます。

Taskモナド

console_taskメソッドではConsoleServiceのrunTaskメソッドを使用してプログラムの実行を行います。

runTaskメソッドは処理を行う関数が束縛されたTaskモナドを返すので、さらにrunメソッドでプログラムの実行を行います。

まとめ

Operationalモナドは敷居が高いので今まで使っていなかったのですが、Scalaz 7.1.0で簡単に利用できるようになっているのが分かりました。

プロダクトで使っているライブラリのバージョンを上げるのは勇気が要るところですが、Operationalモナド目的で、そろそろ上げておくのがよさそうです。