Hot Heart, Cool Mind.

会計×IT の深層へ

サルでもわかるIOモナド③-アクションはモナドの夢を見るか

 少し間が空いてしました。前回はアクション(IO操作の仕様書)とモナドが何とかつながったところで終わりました。 今回は両者の関係をもう少し緻密に検討してみましょう。


====

アクションとモナド要件

 (>>=)演算を備えただけでは「なりかけモナド」に過ぎません。アクションはモナドの要件すべてを満たしえるのでしょうか。 最初に、モナドの定義を確認しておきましょう。

 まず、モナドは、以下の三つの要素から構成されるシステムです:

  • 任意の型(基底型といいます)からモナド型を作りだす手段。アクションの場合、型コンストラクタ IO がこれにあたります。任意の型 a に対して、IO a とすれば、a に対応するアクション型を作り出せます。
  • 基底型の任意の値からモナド型の値を作り出す関数[型は、a->m a]。Haskell では、これは return 関数と呼ばれます。
  • いわゆる bind 関数。Haskell では、この関数は一般に(>>=)と名付けられます[型は、(m a)->(a->m b)->(m b)] 。僕たちは、前回すでにアクションについて(>>=)関数を定義しました。

 さらに、(>>=) と return は、以下の規則を守る必要があります(モナド則)。

  • return a >>= f ≡ f a
  • m >>= return ≡ m
  • (m >>= f) >>= g ≡ m >>= (\x -> f x >>= g)

 最初の2つの規則は、2つ合わせて、return と (>>=) が逆の働きをする、ということを言っています。 return は、a 型の値を包んで m a 型の値を作る操作です(m は包みの素材です)。 一方、(>>=) は、m a 型の包みから a 型の値をいったん取り出して、右辺の関数を適用する操作です。
 1つ目の規則が主張しているのは、値をいったん「包んで」、次にその包みから値を「取り出し」て関数を適用するのなら、 包む前の値に関数を適用するのと同じですよ、ということです。2つ目の規則は逆に、包みから値を「取り出し」て、 また「包んで」やるともとの包みに戻りますよ、という主張です。
 規則が2つに分かれているのは、「値」を出発点とする場合と「包み」を出発点とする場合に分けて書く必要があるからで、 2つ合わせれば、return と (>>=) が逆の働きをするという意味になっています。

 Maybeモナドや Listモナドならともかく、アクションを「包み」と考えるのは少し馴染みにくいかもしれません。 しかし、実行結果である値を「取り出せる」という点からすれば、アクションを「包み」と見てもよいわけです。 ここまでの話の中で、アクションについて return が必要な局面はありませんでした。 とはいえ、必要であれば上記のような条件を満たす return 関数をを作ることはさほど難しくなさそうですね。

 そうなると、一番ややこしげな3番目の規則が焦点になってきます。 これは一種の結合則だという説明をどこかでお読みになったことがあるかもしれません。結合則が成り立つ簡単な例は足し算です:

	( a + b ) + c  ≡  a + ( b + c ) 

 結合則とは、この足し算の例でわかるように、「どこから計算を始めても結果が同じ」ということです。

 では、アクションの(>>=)演算でこれが成り立つのでしょうか。検証してみましょう。 3番目の規則の左辺は、足し算の例と同じように (>>=)を2つ含み、左から順に結合する式です:

	( m >>= f ) >>= g   

 この式の意味(セマンティックス)を決定するには、(>>=)の意味を定義しておく必要があります。これは前回やりました。 すなわち、アクションに関する(>>=)演算は、左辺のアクションと右辺の関数からなる「合成アクション」を返すのでした。 さらに、式 x >>=f の評価結果である合成アクションの「実行」とは「①アクション x を実行し、②その結果得られた値 a をもとに 式 f a を評価し、 ③その評価結果であるアクションを実行する」ことである、と決めました。
 この定義にしたがうと、( m >>= f ) >>= g は合成アクションを返します。その合成アクションが実行されると:

  1. 合成アクション m >>= f を実行(実行結果を b とする)
  2. 式 g b を評価
  3. g b の評価結果であるアクションを実行

という一連の処理が行われます。
 最初のステップを、定義に従ってさらに展開すると、以下のようになります:

  1. 合成アクション m >>= f を実行(実行結果を b とする)、すなわち:
    1. アクション m を実行(実行結果を a とする)
    2. 式 f a を評価
    3. f a の評価結果であるアクションを実行(実行結果を b とする)
  2. 式 g b を評価
  3. g b の評価結果であるアクションを実行

 さて、それでは右辺はどうなるでしょうか。

	m >>= (\x -> f x >>= g)

 足し算の例より複雑に見えますが、それは、(>>=)が左右対称ではないからに過ぎません。f と g をもとに新しい関数を先に作り、 そのあとで、m とその関数を(>>=)で結合していますね。左辺が、m と f の結合を優先させたのに対して、 右辺のこの式は、f と g の結合を優先しているわけです。
 この式もやはり合成アクションを返します。その合成アクションが実行されると:

  1. アクション m を実行(実行結果を a とする)
  2. 式 f a >>= g を評価
  3. f a >>= g の評価結果である合成アクションを実行

という一連の処理が行われます。
 第3ステップを、やはり定義に従って展開しましょう:

  1. アクション m を実行(実行結果を a とする)
  2. 式 f a >>= g を評価
  3. f a >>= g の評価結果である合成アクションを実行、すなわち:
    1. 式 f a を評価
    2. f a の評価結果であるアクションを実行(実行結果を b とする)
    3. 式 g b を評価
    4. g b の評価結果であるアクションを実行

 左辺と比べてみましょう。式の結合順序が異なるので、式評価に関わるステップには違いがあります。 しかし、実行されるアクションとその順序(太字部分)は、左辺、右辺ともまったく同じですね。 アクションはIO処理の指示書ですから、2つのアクションがあって、 どちらに従っても、実行されるIO処理の内容が、順序も含めてまったく同じであれば、 その2つのアクションは同じと言って良いはずです。 このことから、アクションについてモナド結合則が成り立っていることがわかります。

 以上により、アクションを(数学的意味での)モナドにすることは可能だとわかりました。

モナドの意味

 ここで、いったん目先を変えて、モナドの意味について考えてみましょう。 最初に白状しておくと、僕は、モナドの数学についてまったく無知です。 ですからここでは、数学的側面ではなくもっぱら実利的側面からモナドの意味を考えてみます。

モナドの形

 アクションに関するこれまでの検討や、Maybe, List といった他のモナドに関する知識からなんとなくわかるのは、 モナド処理の鎖を作るための枠組みのようなものだということでしょう。しかし、ただ単に処理を繋げるだけであれば、 モナドでなくとも、Haskell得意の関数合成で十分のはずです。

 関数合成とモナドは、処理の鎖という点では同じなのですが、関数合成では鎖の輪が1種類しかないのに対して、 モナドでは鎖の輪が2種類あり、それが交互につながっていると考えるとわかりやすいように思います。

 鎖の輪の一方はモナドが担い、もう一方はモナドを返す関数が担います。

モナドの種類〕 〔責任分担〕
モナドの責任〕 モナド関数の責任〕
Maybe 自分が包んでいるデータをもとに、後続処理の実行を制御。 各段ごとに異なる固有の処理を実行。
List 自分が包んでいる複数のデータひとつずつに対して、後続処理を実行。
IO(アクション) 自分が表すIO操作を実行した結果得られたデータを引き渡して、後続処理を実行。 IO操作の指示書を作成。

 いずれの場合でも、処理に関する責任がモナドモナド関数に、あるいは、言い換えれば、モナドの実装者とモナド関数の実装者にうまく割り当てられています。
 一方で、何がモナドの責任で、何がモナド関数の責任なのかという点については、すべてのモナドに通用するルールは無いように、僕には思われます。 Maybe や List モナドは「処理制御」を担っていると言えるかもしれませんが、IOモナドの仕事は処理制御とは呼び難いと感じます。
 してみると、モナドは処理の全体を分割して2つの責任に割り当てる枠組みを提供しているのですが、その分割の基準は提供していないのです。おもしろいですね!
 一般には、モナドを実装するのは処理系かライブラリの提供者ですから、どんな基準で分割するかはその人たちのセンスに委ねられているということですね。 Maybe や List の場合は「処理制御」と「処理の中身」という視点で分割されていて、IOの場合は「IO操作そのもの」と「IO操作指示書の作成」という切り口で 責任が分割されています。

モナドの組み立て

 以上で、モナドとは何なのか、だいたい見えてきたような気がしますが、モナドにはもうひとつ大事な特長があります。 「組み立て順序が自由」という点です。モナド則の3番目が言っているのはこのことです。 すなわち、長くつながったモナドの鎖のどこを切ってもモナドになるし、それをもとの位置に嵌めこめばもとのモナドになります。 モナドの鎖の任意の部分を部品化できるということです。いったん部品化すれば、それが「単独の」モナドなのか、 鎖の輪をいくつか含む合成モナドなのか、気にせずに使うことができます。これは便利かもしれませんね。僕は、Haskell ではまだ、 Hello, World! とそのお友達しか書いたことが無いので断言はしかねますが、きっと便利でしょう。

point of no return

 最後に return の意味について考えておきましょう。
 これまでの説明で、モナドは便利かもしれないという気がしてきたでしょうか。 であれば、モナドなしで書けるどんな計算処理でも、モナドを用いた版に簡単に書き換えることができる、という保証が欲しくなりませんか?
 モナドを使って何かの処理をかなり書き進めてから、既存のライブラリが提供する複雑な関数が必要だとわかったとします。 その関数があなたの使っているモナドに対応していないとしたらどうでしょう。 その関数を全面的に書き直すか、モナドの使用をやめるか、二つに一つ、なんてことになったらいやですね。
 モナド則の1番目と2番目は、実は、こんなことにならないための保証なんです。

 任意のモナド m と、任意の関数 f::a->b について、合成関数 return.f の型は、a->m b となります。 この関数の返り値であるモナドから(>>=)で取り出した値は、元の関数 f の返り値と同じです(第1則)。 また、その値を再度 return して後段に引き継いでいっても、値が変わらないことが保証されます(第2則)。 このような保証によって、任意の関数 f をモナドの鎖の輪として使うことができます。

 まとめると、return があれば、モナドを使わないどんな計算でも、モナドを使った計算に置き換えることができる、ということになります。

 これが、return の意味です。

数学と実利

 以上、モナドの意味について考えてきました。テキストなどを読むとモナドは数学の一分野である「圏論」の概念であり、 それが「応用されて」Haskellに用いられているのだと書かれたりしています。 そんな風に言われると、圏論を理解しないとモナドを「本当には」理解できないと思ってしまってあたり前です。

 しかし、これは、はっきり言って、専門家がよく使う「猫だまし」です。

 別の例を挙げましょう。僕たちは日常生活で、足し算や掛け算をするのに何の不自由もないですね。 この2種類の計算のルールや使い方を僕らは知り尽くしています。 だけど、足し算や掛け算は実は数学でいう「群論」の一部なのだ、と言われたらどうでしょう(これは本当です)。 僕らは、群論を理解していないから、「本当のところは」足し算も掛け算も理解できないのでしょうか。 そんなことはない筈です。足し算や掛け算をまとめて「群」として扱うのは数学者の研究上の便宜のためであって、 僕らはそんなことを知らなくても、足し算や掛け算を個々に十分に理解することができますし、両者の似ている点( 結合則や交換則が成り立ち、単位元と逆元が存在すること)も完全に理解できます。

 僕は圏論モナドの関係も、これと同じじゃないかと思うのです。足し算・掛け算もモナドもある種の性質をもった計算システムです。 その性質の実利的意味を理解することと、 似た種類の計算システムに共通する性質を効率的に研究するために専門家が使うフレームワーク(これが群とか圏です)を理解することの間には直接の関係はないのです。

アクションはモナドの夢を見るか

 以上で、アクションはモナドになり得るし、モナドにする意味もあると感じて頂けたでしょうか。 正直いうと僕は、アクションに限れば、本当に return が必要なのか、まだ腑に落ちていません。 でも、Hello, World!プログラマとしては、それほど声高に否定的意見をぶちあげる根拠もないのです。

 こういう場合には、とりあえず長いものに巻かれておくのが僕の主義です。

 というわけで、アクションをモナドにする方に一票いれておきましょう。

残りの話題

 さて今日も、力尽きてしまいました。
 毎回、予定の半分しか書けないというのはどういうわけでしょう。
 今回は、モナドと do 記法の関係、それに、世界変換関数にも一度触れて、この話題を締めくくりたいと考えていました。

 次回に回します。