Clojureでモナド(Part2)

Part2

Part1に続いて、Clojureでモナドする話しです。Part2では、やや複雑なモナドの具体例を紹介します。

おさらい

登場人物

v
value。普通の値。mv(monadic value)と区別するため、normal valueなどと呼ばれることもある。
mv
monadic value。モナドコンテキストをまとった値。
mf
monadic function。vを引数にとり、mvを返すタイプの関数すべて。
m-result
mfの一種。引数vに対して、vを表現する最も単純なmvを返す。unitと呼ばれることもある。
m-bind
mvとmfを引数にとり、別のmvを返す関数。flatmapと呼ばれることもある。

モナドとは

  • 型引数を持った抽象型である
  • 単なる型にとどまらず、プラスαの味付けが加わる
  • その味付けは、m-resultとm-bindをどう実装するかによって決まる

state-m

性質

state-mの味付けは、ズバリ「状態」です。「環境」と呼んでも良いかもしれません。命令型言語に例えると、複数の変数を持った「スコープ」のイメージです。この変数は、参照したり変更することができます。つまりstate-mを使うと、"pure" な関数型プログラミングの世界に居ながら、参照透明性や副作用を利用できるようになるのです。

これまでに登場したモナドとは異なり、state-mでは、mvが関数になります。

  • mvは関数
  • この関数は、状態sを引数にとり、[v ss]を返す(ssは新しい状態)
  • m-resultが返すmvは、状態sを引数にとり、[v s]を返す関数(つまり状態を変えない関数)
m-resultとm-bind
;;m-result
(fn [v]
  (fn [s] [v s]))

;;m-bind
(fn [mv mf]
  (fn [s]                  ;★1
    (let [[v ss] (mv s)]   ;★2
      ((mf v) ss))))       ;★3

m-bindの実装について補足しておきましょう。m-bindはmvを返す関数であり、state-mのmvは状態sを引数にとる関数でなければならないので、それを反映して、上記コードでは無名関数(★1)を返すようになっています。

これまで、「m-bindの仕事はmvからvを取り出してmfを呼ぶことだ」と説明してきました。これは、state-mの場合にも当てはまります。ただstate-mの場合はmvが関数なので、そこからvを取り出すためには、関数mvを呼んでやる必要があります。そして、Destructuringによりvを取り出します(★2)。

その後、取り出したvを引数としてmfを呼びますが、その結果は別のmv値になります(これをmv'と名付けましょう)。一方★1の無名関数は、normal valueと状態からなるベクタを返す必要があるので、それを得るために、ssを引数としてmv'を呼びます(★3)。

m-bind呼び出しを連ねることにより、状態を変化させながら引き継いでいけそうな雰囲気が感じられますね。

小道具

前述の通り、「状態」は、命令型言語の「スコープ」のようなものです。Clojureではマップを使って管理するのがピッタリです。clojure.algo.monadsは、マップを通して変数の値を出し入れするための関数を提供しています。

  • fetch-val ... 変数kの値を取り出す
  • set-val ... 変数kの値をvに変えて、古い値を取り出す

Clojureのgetやassocを使えば簡単に実装できそうですが、ここでclojure.algo.monadsはひと工夫してまして、これらの関数呼び出し結果がmvになるように実装しているのです。

;;fetch-val
(fn [k]
  (fn [s] [(k s) s]))

;;set-val
(fn [k new-v]
  (fn [s]
    (let [old-v (get s k)
          new-s (assoc s k new-v)]
      [old-v new-s])))

fetch-valやset-valが、関数を返すようになってますね。そしてその関数は、sを引数にとり[v s]を返すようになっています。つまり、state-mのmvです。

こうすることのメリットは、fetch-valやset-valの呼び出し結果を、モナド内包のローカルバインド部の右辺に置くことができる、という点です。

使用例

まず命令型言語で、二つの変数の値を入れ替えるコードを書いてみます。

a = 3;
b = 5;
work = a;
a = b;
b = work;      // a=5, b=3, work=3となる。

これを、state-mとモナド内包で書いてみましょう。

(domonad state-m
  [_ (set-val :a 3)
   _ (set-val :b 5)
   a (fetch-val :a)
   _ (set-val :work a)
   b (fetch-val :b)
   _ (set-val :a b)
   work (fetch-val :work)
   _ (set-val :b work)]
  nil)

set-val呼び出しの目的は、その副作用にあるので、ローカルバインドの左辺は利用しません。また、domonadフォーム自体も副作用が目的なので、ボディ部もnilを返すようにしました。

ここで、domonadフォームが返すのはmvだということを思い出して下さい。state-mの場合、それは関数です。なので、上記のコードを実行しても、(見た目には)何も起きません。何かを起こすには、関数mvを呼んでやる必要があります。引数には、初期状態として空マップを指定しましょう。

((domonad state-m
   [_ (set-val :a 3)
    _ (set-val :b 5)
    a (fetch-val :a)
    _ (set-val :work a)
    b (fetch-val :b)
    _ (set-val :a b)
    work (fetch-val :work)
    _ (set-val :b work)]
   nil) {})   ;=> [nil {:work 3, :b 3, :a 5}]

命令型言語のコードと同じ結果が再現されました。しかしモナド内包の方は、ローカルバインドの部分が長いですね。ある変数の値を別の変数へ代入するのに2行使ってしまいます。

    a (fetch-val :a)
    _ (set-val :work a)

これを1行で書けないでしょうか?

    _ (set-val :work (fetch-val :a))    ;???

残念ながら、上記のようには書けません。fetch-valがmvを返すのに対し、set-valの第2引数はnormal valueだからです。このようなケースでは、小道具を追加するのが良いでしょう。

(defn copy-val
  [k-to k-from]
  (domonad state-m
    [v-from (fetch-val k-from)
     old-v-to (set-val k-to v-from)]
    old-v-to))

((domonad state-m
   [_ (set-val :a 3)
    _ (set-val :b 5)
    _ (copy-val :work :a)
    _ (copy-val :a :b)
    _ (copy-val :b :work)]
   nil) {})   ;=> [nil {:work 3, :b 3, :a 5}]

copy-valはコピー先の古い値を返すので、もっと短くすることも可能です。

((domonad state-m
   [_ (set-val :a 3)
    _ (set-val :b 5)
    w (copy-val :a :b)
    _ (set-val :b w)]
   nil) {})   ;=> [nil {:b 3, :a 5}]

命令型言語で良く見かける、a++; のようなコードはどうでしょうか? clojure.algo.monadsが提供するupdate-valを使うと簡単に実現できます。

((domonad state-m
   [_ (update-val :a inc)]
   nil) {:a 3})  ;=> [nil {:a 4}]

update-valの第2引数には関数を指定します。この関数を変数aの現在値に適用して、その結果を新しいaの値としてセットします。Clojureのswap!やalterと同じスタイルですね。

Writerモナド(writer-m)

性質

Writerモナドの味付けは「加算器(accumulator)」です。state-mを出力専用にしたようなイメージでしょうか。ロギングなどに応用できます。

clojure.algo.monadsは、Writerモナドを作るための関数writer-mを提供しています。writer-m自体はモナドではないので注意して下さい。

  • mvは、vと加算器accからなるベクタ
  • accは、プロトコルwriter-monad-protocolをサポートするオブジェクト
  • デフォルトで、リスト、ベクタ、セット、及び文字列は、このプロトコルをサポートする
  • 空っぽのaccを引数にしてwriter-mを呼ぶことによりモナドを生成する
m-resultとm-bind
;;m-result
(fn [v]
  [v empty-acc])

;;m-bind
(fn [mv mf]
  (let [[v acc] mv
        [new-v new-acc] (mf v)]
    [new-v (writer-m-combine acc new-acc)]))

empty-accは、writer-m呼び出しの引数に指定された空っぽのaccのことです。また、writer-m-combineはプロトコルwriter-monad-protocolで定義された関数で、2つのaccを連結した新しいaccを返します。

小道具

accに情報を加えるには、clojure.algo.monadsが提供するwrite関数を使います。

;;write
(fn [log]
  (let [[_ empty-a] (m-result nil)]
    [nil (writer-m-add empty-a log)]))

まず、空っぽの加算器empty-aを得るため、m-resultを使って適当なnormal value(ここではnil)をmonadic valueに変換します。そして、writer-m-add関数(これはプロトコルwriter-monad-protocolで定義されている)を使って、empty-aにlogを加算します。

writeが返す値が、Writerモナドのmvの形になっている点に注意して下さい。このおかげで、write呼び出しフォームを、モナド内包のローカルバインド部の右辺に置くことができます。

その他、加算器の内容を参照したり加工する関数も提供されています。

listen
mvを引数にとり、別のmv値を返す。引数 [v acc] に対して、[[v acc] acc] を返すので、normal valueとしてaccを参照することができる。
censor
関数modifierとmvを引数にとり、別のmv値を返す。censorは「検閲者」の意味。引数の [v acc] に対して、[v (modifier acc)] を返すので、modifierにより加算器を加工できる。
;;listen
(fn [mv]
  (let [[v acc] mv]
    [[v acc] acc]))

;;censor
(fn [modifier mv]
  (let [[v acc] mv]
    [v (modifier acc)]))

使用例

clojure.algo.monadsは、リスト、ベクタ、セット、及び文字列が、プロトコルwriter-monad-protocolをサポートするように、extend-protocolしてくれています。試しに、加算器として文字列を使ってみましょう。

(domonad (writer-m "")
  [a (m-result 1)
   _ (write (str "a=" a))
   b (m-result (inc a))
   _ (write (str ",b=" b))
   c (m-result (+ a b))
   _ (write (str ",c=" c))]
  (* a b c))  ;=> [6 "a=1,b=2,c=3"]

letにログ機能を付けたようなイメージですね。ローカルバインドの右辺はmvでなければならないので、いちいちm-resultを呼ぶ必要があるのが面倒ですが…。

writer-mの引数に指定する加算器は、文字通りの「空っぽ」である必要はありません。加算器の初期状態として使いたいものを指定すればOKです。

(domonad (writer-m "#")
  [a (m-result 1)
   _ (write (str "a=" a))
   b (m-result (inc a))
   _ (write (str ",b=" b))
   c (m-result (+ a b))
   _ (write (str ",c=" c))]
  (* a b c))  ;=> [6 "##a=1##,b=2##,c=3#"]

m-resultするたびに、#が増えます。writeの内部でもm-resultが呼ばれていることが伺えますね。

文字列の代わりにセットを使うと、以下のようになります。

(domonad (writer-m #{0 4})
  [a (m-result 1)
   _ (write a)
   b (m-result (inc a))
   _ (write b)
   c (m-result (+ a b))
   _ (write c)
   d (m-result 1)
   _ (write d)
   e (m-result 2)
   _ (write e)]
  (* a b c d e))  ;=> [12 #{0 1 2 3 4}]

reader-m

性質

reader-mの味付けは「共通環境」です。リードオンリーのstate-mのようなイメージで、ある環境からデータを読み込むような複数の式を連結するときに使います。基本的に、この環境は変化しません。

reader-mの実装は、かなりstate-mと似ています。

  • mvは関数
  • この関数は、環境envを引数にとり、vを返す
  • m-resultが返すmvは、引数envが何であれ、決まったvを返す関数
m-resultとm-bind
;;m-result
(fn [v]
  (fn [env] v))

;;m-bind
(fn [mv mf]
  (fn [env]
    (let [v (mv env)]
      ((mf v) env))))

このm-bindの実装をstate-mのm-bindと比べてみると、ソックリなのが分かると思います。

state-mのm-bind
;;m-bind
(fn [mv mf]
  (fn [s]
    (let [[v ss] (mv s)]
      ((mf v) ss))))

reader-mの環境は変化しないので、state-mの実装より少しシンプルですね。

小道具

state-mでは「状態」をマップで表現していましたが、reader-mでは「環境」の表現するデータ型について前提を設けていません。環境から情報を読み取る方法は利用者に任されているので、マップでも文字列でも数値でも何でも構いません。

これを踏まえつつ、reader-m用に提供されている小道具を見てみましょう。

;;ask
(fn []
  (fn [env] env))

;;asks
(fn [helper]
  (fn [env]
    (helper env)))

;;local
(fn [modifier mv]
  (fn [env]
    (mv (modifier env))))

3つともmvを返すので、モナド内包のローカルバインド部の右辺に置くことができる関数です。

ask
環境そのものをvとして返すような関数を、mvとして返す。
asks
環境に関数helperを適用した結果vとして返すような関数を、mvとして返す。
local
modifierで加工した環境にmvを適用した結果を返すような関数を、mvとして返す。

言い換えると、askは環境そのものを得るために使い、asksは環境から特定の情報を取り出すために使い、localは環境を一時的に加工してからmonadicな演算を行いたいときに使います。

使用例

まずは、マップで環境を表現してみます。

askを使って環境から情報を取り出す。
((domonad reader-m
   [e (ask)]
   (+ (:a e) (:b e))) {:a 1 :b 2})   ;=> 3
asksを使って環境から情報を取り出す。
((domonad reader-m
   [a (asks :a)
    b (asks :b)]
   (+ a b)) {:a 1 :b 2})  ;=> 3

良く考えてみると、asksの引数に渡す関数は、それ自体がmvです(envを引数にとるので)。つまり、モナド内包のローカルバインド部の右辺に置くことができます。それならasksって不要な気がしますね。

((domonad reader-m
   [a :a
    b :b]
   (+ a b)) {:a 1 :b 2})  ;=> 3

次に、少し発想を変えて、文字列を環境としてみましょう。文字列を引数としてとる関数なら、どんな関数でもmvとして使うことができます。asksを使わずに書きます。

((domonad reader-m
   [len count
    upper clojure.string/upper-case]
   [len upper]) "http://foo.bar.com/")  ;=> [19 "HTTP://FOO.BAR.COM/"]

またlocal関数を使えば、環境を一時的に加工することができます。URL文字列をapplication/x-www-form-urlencodedフォーマットでエンコードするような加工関数で試してみます。

(defn url-encode-1866
  [url]
  (java.net.URLEncoder/encode url))

((domonad reader-m
   [len count
    modified-len (local url-encode-1866 count)
    upper clojure.string/upper-case]
   [len modified-len upper]) "http://foo.bar.com/1 + 2 = 3")
     ;=> [28 40 "HTTP://FOO.BAR.COM/1 + 2 = 3"]

環境の加工はローカルに行われるので、upperの計算には影響を与えません。

Clojureでモナド(Part3)」に続きます。

Last modified:2014/10/30 17:06:15
Keyword(s):
References:[Clojureでモナド(Part1)] [Clojureでモナド(Part3)] [FP: 関数型プログラミング]
This page is frozen.