気が向いたら書く、と言っていた解説を書きます。
Operational Monadを使用した最初のversion0.2.0を出してから、色々機能追加したり試行錯誤したりして、結構変わっています。その辺りの試行錯誤や経緯も少し含めて、内部の実装や設計の説明を書きます。
https://github.com/xuwei-k/ghscala
まず最初に、自分が作ったgithub API clientの中で、Operational Monad以外に使われているScalazの機能一覧を挙げてみます。このあたり知らない人は、まずこれらを勉強したほうがいいかも*1
- Monad始めScalazの基本的なこと
- NaturalTransformation http://d.hatena.ne.jp/xuwei/20130912/1378984786
- Scalaz独自のEither
- EitherT(EitherのMonad Transformer)
- Endo
- Function1の引数と戻り値の型が同じ場合に、Monoidとして扱えるというもの
- http://d.hatena.ne.jp/xuwei/20130519/1368973083
- httpのリクエストの設定に使ってる(type Config = Endo[Request] という感じのaliasを定義)
- WriterとWriterT(WriterのMonad Transformer)
- scalazのFuture、Task(TaskはFuture[Either[Throwable, A] ]のようなもの)
さて、ここから実装の説明に入っていきますが、まずは
「APIクライアントは、どんな処理をするか?」
を考えて、それらの処理を細かく分けて考えていくことからはじめましょう。
自分は、以下のように4つに分けることができるだろうと思いました。
- url生成の元になる引数や、クエリのパラメータなどを受け取って、リクエストのためのオブジェクトを生成する処理
- 生成したリクエストのオブジェクトを元に、実際にhttpリクエストを投げて結果を受け取る処理
- 受け取った結果(stringもしくはbyte配列、もしくはそれらのstreamなど)を一旦Jsonのオブジェクトにする処理*2
- Jsonのオブジェクトから、対応するcase classにマッピングする処理*3
これ以外にも色々あるでしょうが、「このように4つに分けることができる」という仮定で話を進めます。
さて、普通にプログラムを書く場合は、1から4の処理を順番に実行するように書くでしょう。
例えば
https://api.github.com/users/ユーザー名
というAPIについて考えます。(ここ参照 http://developer.github.com/v3/users/)
APIクライアントを使うユーザーにとっては、一番シンプルに使う場合は
def getUser(userId: String): User = { //Userは以下のようなcase class case class User(id: Int, loginName: String, //その他field
というように、userIdを渡したら、httpリクエストからjsonオブジェクトの生成、さらにはcase classへのマッピングまで全部してしまったオブジェクトを返してくれたら便利かもしれません。
しかし、今回自分が作ったAPI clientは、できる限り純粋関数型で副作用がないように作る方針なので、上記のような実装にはしません。どうするかというと以下のようにしました
def getUser(userId: String): Action[User] = {
Actionという、「Userを取得するための処理そのものを表すオブジェクト」を返します。Action[User]を返した時点ではhttpリクエストは発生していませんし、全く副作用は起きてません。また、Either使うので例外も投げません。
まぁIOモナドみたいなものですが、それをOperational Monadで実装したということです。*4
さて、Actionのシグネチャは、実際は以下のようなものです。
case class RequestF[A](req: scalaj.http.Http.Request, f: (Error \/ String) => A) type FreeC[S[_], A] = Free[({type f[x] = Coyoneda[S, x]})#f, A] type Requests[A] = FreeC[RequestF, A] type Action[A] = EitherT[Requests, Error, A]
type aliasの機能がない言語だとしたら絶対にやってられないような、一見するとだいぶ複雑な型です。
上記のコードをもう少し解説すると
- Errorというのは、httpのエラーか、StringからJson変換時のエラーか、Jsonからcase classへのマッピングのエラーか、の3つのうちどれか
- FreeにCoyonedaを当てはめることがOperational Monadと呼ばれるものなので、まぁそこはそういうものです
- for式で複数のActionを合成したりする場合にEitherTで包んでおいたほうが便利なので、EitherT使ってる
- RequestFはCoyonedaを使ってFreeに当てはめる際につかう、唯一のデータ構築子。最初のversionでは1つだったが、あとで2つに増えた。名前の最後のFは特に意味ない
という感じです。
さて、ここでRequestFについて解説していきます。reqというのとfという2つのフィールドがあります。
reqは、scalaj-httpというhttp clientのリクエストのオブジェクトそのままつかってます*5。URLやパラメータ、タイムアウト指定、認証情報などを保持するcase classです。
fは「httpのリクエストが終わった後に行う処理」を関数として保持します。そのシグネチャが
(Error \/ String) => A
となってるのは、うまく説明できないというか、試行錯誤してたらこれでうまくいった、というのが正直なところです。これは最初のversionのもので、あとから何度か変えてます。
とにかく重要なのは、Coyonedaに当てはめてFunctorにする都合上、RequestFを「型パラメータを1つとる型」にしておくことです。
さて、ここで最初のほうにあげた「4つに分けることができる」という話に戻ります。その4つのうち、通常副作用があるのは、2の「実際にhttpリクエストを投げて結果を受け取る処理」だけでしょう。
それ以外のものは、ログに関する処理などをしなければファイルIOなどもなく、メモリ上の処理のみで完結するはずです。
つまり、さきほどのRequestFは、2の「実際にhttpリクエストを投げて結果を受け取る処理」以外の1、3、4の処理をするものと考えることができます。純粋な処理の部分はセットにして一つのcase classにまとめておいて、最後に副作用の発生する部分を渡して実行するためこのようにします。
さて、そんな分離をして何が嬉しいのかというと、ActionはCoyonedaとFreeの力によりMonadになります。モナドになるということは、以下のようにfor式で合成が可能になるということです。
val newAction: Action[C] = for{ a <- getUser("foo") b <- getUser("bar") } yield { // aとbを使って、型Cを返す処理がここにくる }
生成されたnewActionはAction[C]型です。
「Actionそのものは、プログラムの構築をするだけ」
でまだ実行は行わないので、上記のfor式で新しいAction[C]を生成しても、全く副作用はありません。
これを読んでる人に対しては言うまでもないかもしれませんが、「副作用がなく柔軟に合成可能」というのは、関数型でプログラミングする際の基本的なことであり、それにより、同じオブジェクトを自分の好きな任意の単位で組み合わせて再利用できるので、コードがDRYになるし、テストもしやすいなど、様々な利点があります。
さて、このActionをどのように実行するかというと、たとえば以下のようなNaturalTransformationを与えることによって実行されます。
https://github.com/xuwei-k/ghscala/blob/v0.2.0/src/main/scala/Core.scala#L35
https://github.com/xuwei-k/ghscala/blob/v0.2.0/src/main/scala/Z.scala#L19
new (RequestF ~> Id.Id){ def apply[A](a: RequestF[A]) = { a.f(try { \/-(conf(a.req).asString) } catch { case e: scalaj.http.HttpException => -\/(Error.http(e)) }) } }
さて、ここまでだと、「Actionが合成できる」というだけです。IOモナドなどとあまり変わらない気もします。
まぁ自分もIOモナドや「実行のためのインタプリタを受け取るようにしたReaderモナド」と比べて、本当にOperationalモナドの利点を理解して使いこなしているのか?というとあやしいのですが。
しかしOperational Monadにおいて重要なのは
「NaturalTransformationの戻り値の型が、任意のモナドにすることができる」*6
という点です。*7
上記では、単なるIdモナドでした。
というわけで、ここから色々と試行錯誤あったわけですが、いくつか考えついたモナドとそのインタプリタを実装して、現在のversionでは以下のことが可能になっています
- scalazのFutureやTaskのモナド
- 戻り値がFutureで返ってくる。また並列可能な独立した演算は別スレッドで同時に行うことが可能
- Writerモナド
- 上記の2つをあわせた「非同期処理、かつloggingも行うモナド」
などです。
https://github.com/xuwei-k/ghscala/blob/v0.2.4/src/main/scala/Interpreter.scala
また、Operatonal Monadとは直接関係ないかもしれませんが、以下の様なデータ構築子を増やして、zip演算を追加したことにより、Errorを蓄積できるという、scalazのValidationのような機能も実装されています。
https://github.com/xuwei-k/ghscala/blob/v0.2.4/src/main/scala/RequestF.scala#L34-L42
上記で重要な点は
「最後のインタプリタさえ変えれば、同じActionの実行方法を柔軟に変更できる」
というところです。Action自体を合成する処理は参照透明であり、たとえば
「非同期にするか同期処理にするか?」
にかかわらず、最初のActionは*8全く同じものを再利用できます。
合成して生成したAction型は、最後に渡すインタプリタのMonadの型によって、様々な方法で処理を行うプログラムに変換されるということです。まぁつまりDependency Injectionみたいなものでしょうか(?)
Free MonadとDependency Injectionといえば、そんな感じの切り口でScalazのコミッターの人*9が2年前のnescalaで解説したスライドがあるので、見てみるといいかもしれません。
http://f.cl.ly/items/2J0v0m0x180X1c1K2E2d/Runar-NEscala-DI.pdf
ついでにIOモナドとFreeモナドが同型とか、そのあたりのFreeモナドの話のリンクも貼っておきます
http://blog.higher-order.com/blog/2013/11/01/free-and-yoneda
http://blog.higher-order.com/assets/scalaio.pdf
自分自身、ここまでやってある程度成功していても、まだ
「IOや他のモナドと比べて、Operational Monadが優れている点とはなんだろう」
というのは完全に納得がいってなくて、考え中です。確かになんとなく他のモナドを組み合わせて使うより、Operational Monadのほうが便利な気もしますが、他のモナドを組み合わせてもある程度似たようなことも可能な気もします。
あと、Coyonedaを使わずに、自前でFunctorを定義してFreeモナドを使う方法と比べた場合の話もできれば書いてみたいですね。ScalaではHaskellのGHC拡張のように、Functor定義を自動導出はできないので、たしかに便利なのですが、Functorの定義はそこまで大変なわけでもないし、Coyonedaを使うのはその手間が減るだけなのか?他にも利点があるのか?など
*1:つまり、このエントリでこのあたりは詳しく説明しません
*2:べつに、3と4は区別しなくてもいいかもしれませんが
*3:現状case classにマッピングする以外はなにもしません。その他色々やってくれるAPI clientもあるかもしれませんが。
*4:この後で説明してますが、実行のためのインタプリタを後から切り替えられる点において、IOモナドより抽象化されています
*5:最新版では、scalaj-httpに直接依存しないようになってます
*6:他にも重要な点というか、Operational Monadが優れている点あるかもしれませんが、書いてる本人も完全に理解できてなくてうまく説明できる気がしないので略
*7:逆にいうと、NaturalTransformationを渡して値を取り出す方法では、NaturalTransformationの戻り値の部分がMonadになっていないと取り出せません
*8:合成したものであろうが、そうでなかろうが、Action型である限り