Clojureでモナド(Part1)

前置き

Clojureでもモナドが使える、という話しです。

モナドは抽象的な概念なので、「モナドとは○○である」みたいな定義を読んでも何のことか分かりません。逆に、応用例を示しながら詳細に解説されたとしても、具体的すぎて「で結局モナドって何?」と感じることが多いと思います。本記事もその例に漏れないので、覚悟のうえで読んで頂けると幸いです。

準備

Clojureのモナドライブラリ

clojure.algo.monadsを使います。

project.clj
[org.clojure/algo.monads "0.1.5"]
利用
(ns myproj.core
  (:use
    clojure.algo.monads))

登場人物

v
value。普通の値。型は任意(関数かも)。明示的にmvと区別したい場合、standard 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の実装が、モナドを性質を決める。

モナドとは

  • 型である
  • 型引数を持った抽象型である
  • 単なる型にとどまらず、プラスαの味付けが加わる
  • その味付けは、2つの関数(m-resultとm-bind)をどう実装するかによって決まる
  • 「味付け」 ≒ 「コンテキスト」 ≒ 「コンピュテーション(computation)」 ≒ 「コンテナ」

この、正体のよく分からない「プラスα」がモナドの要です。以降、「味付け」とか「コンテキスト」という言葉が出たら、この「プラスα」のことだと思って下さい。

モナド内包

モナド内包(monad comprehension)は、モナドの応用形態の一つです。

まず関数mfに注目します。mfは、vを引数にとりmvを返す関数でした。そもそもモナドは関数型言語の概念であり、関数型言語の基では、関数は "pure" です(関数には参照透明性があり副作用がない)。よってmfの本質は、(引数の)vだけを使った式になるはずです。仮に、この式をMEと呼ぶことにしましょう(monadic expressionの意図)。MEの評価結果は、mvになります。

モナド内包では、このMEが活躍します。モナド内包の目的は、複数のMEを使って何らかの式を構成することなのですが、これは実はletフォームの目的と似ています。次の2つのフォームを見て下さい。

(let
  [a E1
   b E2
   c E3]
  E4)

(domonad some-m
  [a ME1
   b ME2
   c ME3]
  E4)
  • E1~E4は、normal valueに評価される、normal valueの式
  • ME1~ME3は、monadic valueに評価される、normal valueの式
  • a~cは、normal value

後者がモナド内包です。letフォームとソックリですね。動きも似てますが、以下の点がletと異なります。

  • ME1~ME3の評価結果はmonadic valueだが、a~cはnormal value
  • E4の評価結果はnormal valueだが、domonadフォーム自体の評価結果はmonadic value

ME1~ME3でmonadicな演算をしつつ、それぞれの結果をnormal value(a~c)として利用してE4を書き、E4の評価結果にmonadicな味付けをして最終結果とします。どうやら、裏でvとmvの相互変換が行われているように見えますが、それが表に現れないというのがモナド内包の利点です。

ただ、ここで「変換」という言葉を使うのは不適切かもしれません。実際の所、mvからvへの変換は定義できないことも多いです。モナドによっては、1つのmvから複数のvが取り出せたり、1つも取り出せなかったりすることがあります。以下、clojure.algo.monadsに定義されているモナドの具体例を見ていきましょう。

maybe-m

性質

maybe-mは、モナド例の定番です。「評価結果が無い」とか「計算の失敗」といった概念を表現するときに使います。

  • vとmvは、型に関しては違いが無い
  • ただし、mvのnilは、「値が無い」あるいは「失敗」を意味する
  • m-resultはidentity関数と同じ(つまり、vそのままをmvとする)
m-resultとm-bind
;;m-result
(fn [v]
  v)

;;m-bind
(fn [mv mf]
  (if (nil? mv)
    nil
    (mf mv)))

使用例

数値を返す関数mf1, mf2, mf3があり、何らかの原因で失敗したときはnilを返す仕様だったとしましょう。このような関数を使って以下のようなletフォームを書くのは危険ですね。

(let
  [a (mf1 "hello" 123)
   b (mf2 a)
   c (mf3 a b)]
  (+ a b c))

mf1~mf3の呼び出し結果を利用する前にnilかどうかをチェックすべきですが、そうするとコードの見通しが悪くなってしまうのは明らかでしょう。そこで、モナド内包とmaybe-mを使って書いてみます。

(domonad maybe-m
  [a (mf1 "hello" 123)
   b (mf2 a)
   c (mf3 a b)]
  (+ a b c))

前述のletフォームとほとんど同じですが、このdomonadフォームは、もしmf1がnilを返したら、mf2やmf3は呼ばずに、(+ a b c) も評価せずにnilを返します。mf2やmf3がnilを返した場合も、残りの式を無視してnilを返します。この「nilになったら即終了」という味付けがmaybe-mの性質です。

いったいどんな仕組みで、これが実現されているのか? それについては、ひとまず棚上げしておきましょう。それよりも、domonadフォームの形(レイアウト)を頭に刻み付けることの方が重要です。もう一度上記コードを見て、以下の点を意識して下さい。

  • ローカルバインド部の左辺(a~c)はnormal value
  • 右辺はmonadic value
  • ボディ部の式はnormal value
  • domonadフォームが返す値はmonadic value

maybe-mの場合、値を見ただけではvとmvを区別できません。vは、「nilは失敗」という特殊な解釈を与えることでmvになります。例えばvの100, "hello", nilは、mvではそれぞれ、100, "hello", 失敗を意味します。

sequence-m

性質

sequence-mもモナド例の定番と言えるでしょう。「値が一つに決まらない」とか「複数の可能性がある」といった概念を表現するときに使います。この概念は「非決定性」と呼ばれます。

  • 任意の型のvに対し、mvはvのシーケンス
  • 空シーケンスも、mvとして許される
  • m-resultはlist関数と同じ(つまり、任意のvに対して、「値がv一つに決まる」をmvとする)
m-resultとm-bind
;;m-result
(fn [v]
  (list v))

;;m-bind
(fn [mv mf]
  (apply concat
    (map mf mv)))

使用例

分母を返す関数denoと、分子を返す関数numeがあったとしましょう。これらを使って、分数を作ります。

(let
  [d (deno)
   n (nume)]
  (/ n d))

denoもnumeも返す値は1つなので、letフォームの結果も1つです。これに対し、分母と分子の候補をリストで返す関数denosとnumesがあったらどうでしょうか。このようなケースを扱うのが、sequence-mです。

(defn denos [] '(3 8 5))
(defn numes [] '(2 9))

(domonad sequence-m
  [d (denos)
   n (numes)]
  (/ n d))     ;=> (2/3 3 1/4 9/8 2/5 9/5)

欲しいのは分数n/dなのですが、dとnそれぞれに複数の可能性があるので、結果も複数個になります。この「複数の可能性を許容する」というのがsequence-mの味付けです。上記例のように複数の変数(dとn)がある場合は、全組み合わせが最終的な可能性として挙がります。味付けの面倒はモナドが見てくれるので、利用者は、組み合わせを意識する必要がありません。

maybe-mのときと同様に、domonadフォームの(つまりモナド内包の)レイアウトを意識して下さい。

  • ローカルバインド部の左辺(dとn)はnormal value
  • 右辺はmonadic value
  • ボディ部の式はnormal value
  • domonadフォームが返す値はmonadic value

sequence-mを使ったモナド内包が、forフォームとソックリなことに気付いたでしょうか。

(for
  [d (denos)
   n (numes)]
  (/ n d))     ;=> (2/3 3 1/4 9/8 2/5 9/5)

forフォームの本質は、sequence-mに基づくモナド内包に他なりません。その別名はリスト内包(list comprehension)です。

identity-m

性質

identity-mは、何の味付けもしないモナドです。用途は限定的ですが、モナドの一般性を示す好例と言えるでしょう。

  • vとmvは、同じ型
  • m-resultはidentity関数
m-resultとm-bind
;;m-result
(fn [v]
  v)

;;m-bind
(fn [mv mf]
  (mf mv))

使用例

identity-mのモナド内包は、実はletフォームそのものです。

(let
  [a 100
   b (inc a)
   c (+ a b)]
  (* a b c))   ;=> 2030100

(domonad identity-m
  [a 100
   b (inc a)
   c (+ a b)]
  (* a b c))   ;=> 2030100

モナドの舞台裏

モナド内包のような動作を実現するために必要な要素は以下の4つです。

  • clojure.algo.monadsが提供するdomonadマクロ
  • clojure.algo.monadsが提供するwith-monadマクロ
  • 各モナドが実装するm-return関数
  • 各モナドが実装するm-bind関数

domonadマクロ

以下、maybe-mを例に説明します。

(domonad maybe-m
  [a (mf1 "hello" 123)
   b (mf2 a)
   c (mf3 a b)]
  (+ a b c))

このdomonadマクロフォームは、以下のように展開されます。

(with-monad maybe-m
  (m-bind   (mf1 "hello" 123) (fn [a]
  (m-bind   (mf2 a)           (fn [b]
  (m-bind   (mf3 a b)         (fn [c]
  (m-result (+ a b c)))))))))

展開前のフォームと比べ易いようにインデントを調整してあります。以下の点に注意して展開の前後を比べてみて下さい。

  • 展開前のローカルバインド部の各行が、展開後はm-bindフォームになる
  • そのとき、ローカルバインドの左辺と右辺が、展開後のフォームにも表れる(順番は逆)
  • 展開前のボディ部の式が、展開後はm-resultフォームになる

m-bind関数

展開後のフォームを、通常のインデントで整形すると以下のようになります。

(with-monad maybe-m
  (m-bind
    (mf1 "hello" 123)
    (fn [a]
      (m-bind
        (mf2 a)
        (fn [b]
          (m-bind
            (mf3 a b)
            (fn [c]
              (m-result (+ a b c)))))))))

m-bind呼び出しがネストしています。m-bindは、mvとmfを引数に取るのでした。ここではmfの実引数として無名関数を渡しています。その無名関数の中に別のm-bind呼び出しがあり、その第2引数にはまた別の無名関数を渡す、といった具合にネストしていきます。

基本的に、(m-bind mv mf) というフォームを評価すると、mvからvを取り出し、そのvを実引数としてmfを呼びます。モナド内包で、ローカルバインド部の左辺にあったa~cは、実は、呼ばれたmfの仮引数の名前だったのです。これはすべてのモナドに共通して言えることです。一方、mvからvを取り出す方法や、mfを呼ぶ方法は、モナドごとに(より具体的に言えばm-bindの実装ごとに)異なります。

しかし、maybe-mのm-bind実装の話しをする前に、with-monadマクロを片付けておきましょう。

with-monadマクロ

すべてのモナドは、自分専用のm-bindとm-resultを実装しています。どのモナドも同じ名前を使うので、使用するモナドに応じた適切な実装に結び付ける必要があります。これを行うのがwith-monadマクロです。

(with-monad some-m
  (m-bind mv mf))

(with-monad another-m
  (m-bind mv mf))

(with-monad yet-another-m
  (m-return 123))

上記の各フォームで呼び出されるm-bindやm-resultの実体は、with-monadフォームの第1引数に指定されたモナドによって決まります。with-monadは、一種のポリモーフィックな関数呼び出しを実現していると考えて良いでしょう。

m-bind再び

maybe-mのm-bind実装は以下の通りです。

;;m-bind
(fn [mv mf]
  (if (nil? mv)
    nil
    (mf mv)))

mvは、「nilは失敗」という点を除けばvと同じなので、mvからvを取り出すのは簡単です。あとはmfを呼ぶだけですが、mvがnilのときだけはmfを呼びません。

これを踏まえて、with-monadフォームに戻ってみましょう。

(with-monad maybe-m
  (m-bind                 ;★1
    (mf1 "hello" 123)
    (fn [a]               ;☆1
      (m-bind                  ;★2
        (mf2 a)
        (fn [b]                ;☆2
          (m-bind                   ;★3
            (mf3 a b)
            (fn [c]                 ;☆3
              (m-result (+ a b c)))))))))  ;★4

もしmf1呼び出しが失敗に終わったら、最初のm-bind呼び出し(★1)の仮引数mvがnilなので、仮引数mfは呼ばずにnilを返します。

もしmf1呼び出しが成功して3を返したら、3を実引数にしてmfを呼びます。このmfの実体は☆1なので、その仮引数aに3をバインドして、m-bindを呼びます(★2)。

以下同様に、

  • mf2呼び出しが失敗に終わったら、★2のmfは呼ばずにnil
  • mf2呼び出しが成功したらmf(つまり☆2)を呼ぶ
  • ☆2の仮引数がバインドされ、m-bindを呼ぶ(★3)
  • mf3呼び出しが失敗に終わったら、★3のmfは呼ばずにnil
  • mf3呼び出しが成功したらmf(つまり☆3)を呼ぶ
  • ☆3の仮引数がバインドされ、m-resultを呼ぶ(★4)

となります。

コードにしても文章にしても複雑そうに見えますが、実は単調です。要するに、mf1やmf2が生成するmonadic valueからnormal valueを取り出して、あとの式で使えるように、aやbへローカルバインドしているだけです。そして、最後にできたnormal valueをmonadic valueに戻すのがm-resultです。

m-result関数

maybe-mのm-bind実装は以下の通りです。

;;m-result
(fn [v]
  v)

maybe-mの場合は、vがそのままmvになります。つまり、identity関数ですね。もう少し回りくどく意味を踏まえて、以下のように書いても良いかもしれません。

;;m-result
(fn [v]
  (if (nil? v)
    nil
    v))

vがnilなら「失敗」を意味する値(つまりnil)を返し、vがnil以外ならvをそのまま返します。

sequence-mの場合

sequence-mについても同じ流れで説明できますが、m-bindの実装が難所になりそうです。

;;m-result
(fn [v]
  (list v))

;;m-bind
(fn [mv mf]
  (apply concat
    (map mf mv)))

繰り返しになりますが、基本的にm-bindは、mvからvを取り出して、mfを呼びます。ただsequence-mの場合は、1つのmvから複数のvが取り出せてしまうので、直接mfを呼ぶことができません。そこで、mv(つまり複数のvからなるシーケンス)に、mfをmapすることで、間接的にmfを呼ぶのです。

m-bindはmonadic valueを返さないといけません。mapした結果はシーケンスになるので、一見すると好都合ですが、実はこのままではm-bindがネストしたときに困ったことになります。そこで、sequence-mのm-bind実装では、mapしたあとにconcatするようになっています。mapだけだとどうして困るのか、詳しい説明は省きますが、以下のコードを見て確認して下さい。

;; 失敗のケース。
(defn seq-bind [mv mf] (map mf mv))
(defn seq-result [v] (list v))

(seq-bind
  (list 1 2 3)
  (fn [a]
    (seq-bind
      (list \a \b)
      (fn [b]
        (seq-bind
          (list 'A 'B 'C)
          (fn [c]
            (seq-result (str a b c))))))))  ;=> (((("1aA") ("1aB") ("1aC"))
                                                  (("1bA") ("1bB") ("1bC")))
                                                 ((("2aA") ("2aB") ("2aC"))
                                                  (("2bA") ("2bB") ("2bC")))
                                                 ((("3aA") ("3aB") ("3aC"))
                                                  (("3bA") ("3bB") ("3bC"))))

;; 成功のケース。
(defn seq-bind [mv mf] (apply concat (map mf mv)))

(seq-bind
  (list 1 2 3)
  (fn [a]
    (seq-bind
      (list \a \b)
      (fn [b]
        (seq-bind
          (list 'A 'B 'C)
          (fn [c]
            (seq-result (str a b c))))))))  ;=> ("1aA" "1aB" "1aC" "1bA" "1bB" "1bC"
                                                 "2aA" "2aB" "2aC" "2bA" "2bB" "2bC"
                                                 "3aA" "3aB" "3aC" "3bA" "3bB" "3bC")

つまり、mapしただけだとシーケンスがネストしてしまうので、平らに均す(flatten)必要があるわけです。ScalaやJava8では、bind相当のメソッドをflatMapと呼ぶらしいですが、この辺に由来がありそうですね。

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

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