StatefulFunc/StateBuilderとは何なのか


 これまで書いてきたことを読んでいただけると、StatefulFunc/StateBuilderについて「入力してコンパイルすると確かに動くけど、なぜどのように動いているかさっぱりわからない」という状態は脱出できると思います。しかしこれは単に機械が動く仕組みを理解できただけで、その設計意図や目的が理解できたわけではありません。第一、普通のプログラムを読むときにこのような読み方はしません。変数の意図などを考え、その値がどのように使われた結果、どこでどのようなことが起こって結果が発生しているのか、という道筋でプログラムを解釈するのが普通だと思います。
 ここでは、StatefulFunc/StateBuilderが、意味的にどのように動いているかを解説したいと思います。とはいえ、StateffuiFunc/StateBuilderはHaskellでいうところのStateモナドそのもので、「Stateモナド」でググれば日本語の解説は結構ひっかかるのですが、後発を承知であえて僕なりにF#上で解説していきたいと思います。

状態を引数にするということ


 このプログラムを見て最初に感じる違和感は、実行時に与える引数の違いです。普通のプログラムでは、関数の挙動のパラメータのようなものを引数にし、結果を戻り値やプロパティ、インスタンス変数、参照型の引数、言語によってはグローバル変数の値の変化として戻すのが普通ですが、このプログラムは実行時に状態を引数に取り状態を戻しているのです。この違いは何なのでしょうか。
 関数型言語の勉強をしていると、時折世界の天地がひっくり返ったような、ものの裏表がひっくり返ったようなトポロジカルかつドラマチックな転回を感じることがあるのですが、この違いはまさにそういった類のものです。
 まず普通の手続き型プログラムの実行イメージを確認してみましょう。
  • 状態は、プログラム空間全体にちらばっており、見える範囲で参照、変えられる範囲で変更できる。
  • 関数は実行パラメータを引数にとり、状態や参照型の引数の値を変えるか戻り値を返すことで実行結果を返す。


 これに対し、StetefulFunc/SteteBuilderは以下のように動いていることになります。
  • ビルダーのインスタンス(コンピュテーション式)に実行パラメータを渡し、実行パラメータを内包した関数(この場合StatefulFunc)を作成する。
  • 作成した関数に初期状態を渡し、結果として変わった状態を返す。


 プログラムの裏表が枕カバーのようにひっくり返っている感じが伝わるでしょうか。
 すでにご存知でしょうが、関数型言語ではグローバル変数は最も忌み嫌われるものです(OOPでも相当に嫌われていますが)。しかし、状態自体を引数で渡せるなら、変化した状態を受け取ることもできるわけです。これがSteteBuilder(Stateモナド)の基本的な考え方です。

GetStateの役割


 状態ワークフローが「状態」を引数にするのはいいとしても、これをそのまま実践しようとすると、全ての関数が引数stateを取って戻り値と一緒にstateを戻すように作らないといけなくなります。これでは関数の参照透明性を守るためとはいえ、払う代償が高くなりすぎます。そんなことが強要されてしまうような言語は、結局誰にも使ってもらえなくなるでしょう。
 この不便を解消するために考えられたのが、この状態ワークフロー(StatefulFunc/SateBuilder、Stateモナド)なのです。これを使うと、明示的に処理に必要なものを引数として記述するだけで、それ以外のものをまるで机の下から同時に受け渡すようなことができます。
 Add関数を例に見てみましょう。
let Add x =
  state {
    let! currentTotal, history = GetState
    do! SetState (currentTotal + x, (sprintf "%dを加算" x) :: history)
  }
 ここで、1行目の意味を理解するためGetStateの定義を見ると、
let GetState = StatefulFunc (fun state -> state, state)
 という、実に素っ気ない関数になっています。ワークフローを読み慣れないうちは、
let! currentTotal, history = GetState
 とう文と、GetStateの定義を見て、なんとなく
StatefulFunc (fun state -> let! currentTotal, history = state, state in ~)
 というような読み方をしてしまい、currentTotalとhistoryに同じものが入るような理解にはまってしまいそうになりますが、ここにはまってしまうとなかなか脱出できなくなります。何が違っているのでしょう。
 実装だけを見ると、このGetState関数は、「stateを受け取って、同じその値を持つペアを作って返す関数を持つStatefulFuncを返す関数」ということになります。間違ってはいないのですが、この一見無意味に思える関数には大きな意味があります。
 実はこのとき作成されるペアは、
(机の上で明示的に渡される値, 机の下でナイショで渡される値)
 という意味のペアになっているのです。
 GetStateは、こっそり机の下で受け取ったstateを机の上に出しながら、同じものを机の下からも同時に受け渡す関数、という風にイメージしてもらってもいいと思います。ということを踏まえて、Addの定義を見てもらうと、
let! currentTotal, history = GetState
 という代入文で、左側の変数に束縛されるのは、GetStateが返したペアのうちの左側、つまり机の上で渡したものだけがここに束縛されてくると解釈できます。このlet!の=は、単に=で結合されているので左右が等しいようなイメージを受けますが(このへんは、Haskellの表現のほうが真っ当な印象を受けます)、この間には見えないミゾがあり、机の下で渡された値はそのミゾに落ちて机の下に飲み込まれたようになっているのです。


SetStateの役割


let SetState newState = StatefulFunc (fun prevState -> () , newState)

 一方、SetStateは何をしているのでしょうか。実装の字面だけを見ると、「ステータス1を受け取り、『ステータス2を受け取って、()とステータス1のペアを返す関数を持つStatefulFunc』を返す関数」ということになります。
 このステータス1、2とは何かというと、
  • ステータス1は、机の上で明示的に受け渡された値
  • ステータス2は、机の下からナイショで受け渡された値
 という風に理解してもらえるといいと思います。
 つまり、SetStateは、机の下で受け取った値を無視して(捨てて)、「机の上で()を渡し、机の下では机の上で受け取った値を渡すStatefulFunc」を作っていたのです。
 この流れを図にすると以下のようになります。


 この動きを支えているのが、、StateBulderのBindメソッドです。Add展開編の項8で展開された式を追うと、机の上の値(赤い線)は「result → (currentTotal, history) へ、机の下の値(緑の線)がupdateStateからinitialStateに渡され、prevStateとなって消えていくのが分かるでしょう。


机の上に出さないようにしてみる


 それでは、この値の受け渡しを机の上に出してやる、というのをやめて、全て机の下でおこなうことはできるのでしょうか。SetState/GetStateを使わないようにすればこれが可能になります。
 先ほどSetStateの説明で「ステータス2に机の下を通ってきた値がくる」という説明をしましたが、このステータス2に直接入力のstatusが渡るStatefulFuncを考え、その入力を使って()と新しいステータスのペアを作ればいいのです。
 ステータスを机の上に出す必要もないので、1行目は完全に不要になります。また、ステータス1は来ないので引数で渡す必要もなくなり、結局以下のようになります。

let Add2 x = StatefulFunc(fun (currentTotal, history) -> (), (currentTotal + x, (sprintf "%dを加算" x) :: history))

 この式に見覚えはありますか?
 この式は、Add展開編の最後に出来上がった式と全く同じものです。Add展開の長い作業は、意味で言えば机の上と下で受け渡されているものが同じであることに着目して、机の上→机の下に受け渡し場所を変えただけだったのです。簡約の過程でresultとupdateStateが両方ともinitialStateに置き換えられ、(currentTotal, history)になってすり替わっていく様子を観察して下さい。

ちょっとだけ机の上に出してみる


 裏で渡されるステータスのうち、どれを机の上にだして作業するか、についてはBuilderの設計に依存しているわけではなく、GetState/SetStateの仕組みをちょっと修正するだけで変更することができます。ためしに、currentTotalだけを机の上に出して作業するように変更してみましょう。

let GetState2 = StatefulFunc (fun (currentTotal, history) -> currentTotal, (currentTotal, history))
let SetState2 newTotal comment = 
  StatefulFunc (fun (currentTotal_under, history_under) -> () , (newTotal, comment :: history_under))

let Add2 x =
  state {
    let! currentTotal = GetState2
    do! SetState2 (currentTotal + x) (sprintf "%d を加算" x)
  }

let calculatorActions2 =
  state {
    do! Add2 3
    do! Add2 4
  }
  

let res, stat = Run calculatorActions2 (0, [])
 実行結果

val calculatorActions2 : StatefulFunc<(int * string list),unit> =
  StatefulFunc <fun:Delay@30>
val stat : int * string list = (7, ["4 を加算"; "3 を加算"])
val res : unit = ()

>  

 Add2の1行目を見ると、historyが表に出てきていないのが分かります。その代わり、historyに追加する文字列をSetState2に与えるようにしています。
 このように必要なものだけ表に出して作業をしてもいいのですが、出すものの種類ごとにGetState/SetState的なものを書く必要があります。これはこれで無駄な苦労のような気もしますので、「出すときは全部出す」というのもまた潔い決断かもしれません。

まとめ


 calculatorActionsも、同じような考え方で動いていますが、こちらは机の上に一切何も出さず、全てのステータスが机の下でやりとりされています。こちらも興味がある方は追ってみてください。do!の展開が
do! expr in expr2   ⇒      b.Bind(expr, fun () -> expr2)
 のように、Bindの二つ目の引数になっている匿名関数の引数が()になっている理由が納得してもらえると思います。

 この、床下配線感覚というか、見えないところで汚れ作業をするイメージは、OOPの「内側からのポリモルフィズム」に似ていますが、業務側で書く関数(AddやcalculatorActions)の参照透明性が守られると言う点が大きく異なります。関数の戻り値に影響を与えるのは引数と不変の自由変数だけ、関数が外に影響を与えるのは戻り値だけというルールを高潔に守ることで得られるものは大きく、それを守るためにこのような仕組みが用意されているのです。
 実はここで説明したのは最初にも書いたようにHaskellのStateモナドの仕組みそのものです。
 これだけ書いておいて言うのもなんですが、Stateモナドは決してやさしいものではありません。高階関数、カリー化、糖衣構文、多相性、ラムダ計算といった関数型言語のトピックを総動員しなければ納得できるものではありません。「Programming F#」が説明を完全に投げ出していることからも分かるように、これを説明する側にも相当なエネルギーが必要になります。
 インターネット上にはこの文も含め「モナドの解説」が沢山あります。それらと合わせてこの文を根気よく読んでもらえることで、読者のモナド理解の一助になれれば幸いというものです。

(文責:片山 功士  2012/01/20)


今日: -
昨日: -
トータル: -
最終更新:2012年02月03日 01:58