Skip to content

Instantly share code, notes, and snippets.

@halcat0x15a
Last active December 17, 2015 06:08
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save halcat0x15a/5562947 to your computer and use it in GitHub Desktop.
Save halcat0x15a/5562947 to your computer and use it in GitHub Desktop.
Monad Comprehension in Clojure

ぼくのかんがえたさいきょうのMonad Syntax in Clojure

タイトル通りです。

最近はClojureを書いていて、モナドがなくてもThreading Macroがあればまあいいかなと思うようになってきたのですが、

Clojureのprotocolで型クラス的な多態な関数を作る

この記事に触発されたので、なにか書いてみます。

私も前にモナド内包表記について書いていたりします。

Threading Macro

->, as->, some->, cond->などはThreading Macroと呼ばれます。

このThreading Macroはモナドの利点の1つである、手続き的に記述ができるという特徴があります。

(-> a f g)

(g (f a))

と等価です。

これはポイントフリーで記述できるという利点がある反面、途中の計算結果を変数に束縛できません。

また、->someは"計算の途中でnilが返ると計算を中断してnilを返す"というコンテキストを持った->であると考えられます。

->にコンテキストを持たせたい時には新たなマクロを定義する必要があります。

モナドはこれらの問題を解決するための構文を提供します。

モナド

動的型付き言語でdo記法を模倣する際に問題になるのが、Monadにおけるreturnですが、これはdo記法ではなく、Scalaのfor式のようなモナド内包表記を採用すればよい話しだと思っています。

さらに、ClojureにはProtocolという便利な仕組みがあるので、これを活用してモナドを定義します。

(defprotocol Functor
  (fmap [m f]))

(defprotocol Monad
  (bind [m f]))

(defprotocol MonadPlus
  (mfilter [m p]))

それっぽい名前が付いていますが、細かいことは気にしないでいきましょう。

モナド内包表記において、一番大事な機能は、bindとfmapへの展開です。

(for-m [a m] (f a))
(for-m [a m b m'] (g a b))
(for-m [a m b m' c m''] (h a b c))

はそれぞれ、

(fmap m (fn [a] (f a)))
(bind m (fn [a] (fmap m' (fn [b] (g a b)))))
(bind m (fn [a] (bind m' (fn [b] (fmap m'' (fn [c] (g a b)))))))

このように展開されます。

実装は単純です。

(defmacro for-m [[var val & exprs] expr]
  (cond (empty? exprs) `(fmap ~val (fn [~var] ~expr))
        :else `(bind ~val (fn [~var] (for-m ~exprs ~expr)))))

さらに、よくある機能として、ガードと束縛を実装します。

(for-m [a m :if (p a)] (f a))
(for-m [a m :let [x (f a)]] x)

はそれぞれ、

(fmap (mfilter m (fn [a] (p a))) (fn [a] (f a)))
(fmap (fmap m (fn [a] [a (f a)])) (fn [[a x]] x))

このように展開されます。

実装は少し複雑になりますが、clojure.core/forより遥かにシンプルなので安心してください。

(defmacro for-m [[var val & exprs] expr]
  (let [[key expr' & exprs'] exprs]	
    (cond (identical? key :if)
          `(for-m [~var (mfilter ~val (fn [~var] ~expr'))
                   ~@exprs']
                  ~expr)
          (identical? key :let)
          (let [bindings (partition 2 expr')]
            `(for-m [[~var ~@(map first bindings)]
                     (for-m [~var ~val]
                            [~var ~@(map second bindings)])
                     ~@exprs']
                    ~expr))
          (empty? exprs)
          `(fmap ~val (fn [~var] ~expr))
          :else
          `(bind ~val (fn [~var] (for-m ~exprs ~expr))))))

この実装はScalaのfor式を参考にしました。

実際に使ってみましょう。

(extend-type clojure.lang.IPersistentCollection
  Functor
  (fmap [m f] (map f m))
  Monad
  (bind [m f] (mapcat f m))
  MonadPlus
  (mfilter [m p] (filter p m)))

(assert
 (= (for-m [a (range 3) b (range 3) :if (< a b)] [a b])
    '([0 1] [0 2] [1 2])))

いい感じですね。

コンテキスト

現在はfor-mで束縛される型によってコンテキストが選択されます。

Maybeモナドを定義してみましょう。

(deftype None []
  Functor
  (fmap [m f] m)
  Monad
  (bind [m f] m)
  MonadPlus
  (mfilter [m p] m))

(deftype Some [value]
  Functor
  (fmap [m f] (Some. (f value)))
  Monad
  (bind [m f] (f value))
  MonadPlus
  (mfilter [m p] (if (p value) m (None.))))

これは以下のように使います。

(let [m {:foo 1 :bar 2}
      mget (fn [m k] (if-let [v (get m k)] (Some. v) (None.)))]
  (assert
   (= (.-value (for-m [a (mget m :foo) b (mget m :bar)] (+ a b)))
      3)))

しかし、いちいち値をモナドで包む、値を取り出すことは面倒に思えます。

そこで、コンテキストを明示的に指定できるように変更します。

モナドにおけるpointを動的束縛を用いて設定し、内包表記の中では値をモナドにpointします。

(def ^:dynamic *monad* identity)

(defn point [x] (*monad* x))

pointを定義したことによってMonadにfmapのデフォルト実装を定義することができます。

(extend-type emerald.monad.Monad
  Functor
  (fmap [m f]
    (bind m (comp point f))))

直接コンテキストを指定する時、殆ど場合はモナドは内包表記のなかでのみ必要とされるので、最終的な値はモナドから取り出すことにします。

(defprotocol Comonad
  (extract [m]))

extendがないとか気にしないでください。

for-mの実装は以下のようになります。

(defmacro for-m
  ([m exprs expr]
     `(binding [*monad* #(new ~m %)]
        (extract (for-m ~exprs ~expr))))
  ([[var val & exprs] expr]
     (let [[key expr' & exprs'] exprs]
       (cond (identical? key :if)
             `(for-m [~var (mfilter (point ~val) (fn [~var] ~expr'))
                      ~@exprs']
                     ~expr)
             (identical? key :let)
             (let [bindings (partition 2 expr')]
               `(for-m [[~var ~@(map first bindings)]
                        (for-m [~var ~val]
                               [~var ~@(map second bindings)])
                        ~@exprs']
                       ~expr))
             (empty? exprs)
             `(fmap (point ~val) (fn [~var] ~expr))
             :else
             `(bind (point ~val) (fn [~var] (for-m ~exprs ~expr)))))))

使い方はこのようになります。

(deftype Maybe [value]
  Monad
  (bind [m f] (if-not (nil? value) (f value) m))
  MonadPlus
  (mfilter [m p] (if (p value) value))
  Comonad
  (extract [m] value))

(let [m {:foo 1 :bar 2}]
  (assert
   (= (for-m Maybe [a (get m :foo) b (get m :bar)] (+ a b))
      3)))

まとめ

今回は束縛される値の型によってコンテキストを変えるパターンと明示的に指定するパターンの両方を実装したことになります。

どちらもそれぞれに利点が存在するので、必要に応じて選ぶことができるような構文になりました。

Scalaのfor式は、オブジェクトシステムが存在する言語でモナドのための構文を定義する際に参考になると思います。

今回書いたプログラムはGithubで公開しています。

@halcat0x15a
Copy link
Author

追記

コンテキストを直接指定する場合に、:letによる束縛ができないことがわかりました。

右辺値がモナドにあるにもかかわらずpointされてしまうのですね。

そこで、fmapやbindの呼び出し毎ではなく、for-mの呼び出しのところで書きかえることにします。

(defmacro for-m
  ([m exprs expr]
     (let [bindings
           (mapcat (fn [[var val :as binding]]
                     (if (keyword? var) binding [var `(point ~val)]))
                   (partition 2 exprs))]
       `(binding [*monad* ~m]
          (for-m ~bindings ~expr))))
  ([[var val & exprs] expr]
     (let [[key expr' & exprs'] exprs]
       (cond (identical? key :if)
             `(for-m [~var (mfilter ~val (fn [~var] ~expr'))
                      ~@exprs']
                     ~expr)
             (identical? key :let)
             (let [bindings (partition 2 expr')]
               `(for-m [[~var ~@(map first bindings)]
                        (for-m [~var ~val]
                               [~var ~@(map second bindings)])
                        ~@exprs']
                       ~expr))
             (empty? exprs)
             `(fmap ~val (fn [~var] ~expr))
             :else
             `(bind ~val (fn [~var] (for-m ~exprs ~expr)))))))

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment