モナドは箱ではなく合成戦略である
モナドを「値を入れる箱」と説明するのが一番よく見る入り方だが、この比喩は早い段階で壊れる。IO a から a は取り出せないし、関数モナド (->) r に至っては中に何も入っていない。箱として理解しようとすると、箱として振る舞わない例に当たった時点で行き止まる。
実際のモナドは容器ではなく、「次に何をするか」の繋ぎ方の共通形である。繋ぎ方に、その文脈固有のロジックを1箇所だけ差し込める、という構造をしている。
定義は二つの操作だけ
型構築子 M がモナドであるとは、次の二つを持ち、後述の法則を満たすこと:
return :: a -> M a -- 素の値を文脈に持ち上げる
(>>=) :: M a -> (a -> M b) -> M b -- 「文脈つきの値」と
-- 「値を受けて文脈つきの値を返す関数」を繋ぐ
要は >>=(bind)である。右辺が関数であることが全部で、M a の中身を取り出して関数に渡し、返ってきた M b を返す。ただし取り出し方と返し方は M ごとに違い、そこがその文脈のロジックになる。
>>= の定義だけ。「打ち切り」「分岐」「配線」の違いは全部そこに入っている。差し込まれるロジックの実例
Maybe — 失敗の伝播。 Nothing が来たら f を呼ばない。だから m >>= f >>= g >>= h と書くだけで、途中の失敗が自動的に最後まで抜ける。nullチェックを各行に書く代わりに、>>= の定義1箇所に閉じ込めている。
List — 可能性の分岐。 xs >>= f = concat (map f xs)。各要素に適用して平坦化する。結果として全組み合わせが列挙される(baseの実装はリスト内包表記だが外延的には同じ。下記参照)。
State / IO — 状態と順序の受け渡し。 >>= が状態を次に渡す配線を担う。ユーザーのコードにはその配線が現れない。
同じ >>= という記法で「打ち切り」「分岐」「配線」が書ける。違いは全部 >>= の定義の中にある。これが「合成戦略」と呼ぶ意味である。
モナド則は「繋ぎ方が素直であること」の要求
| 法則 | 意味 |
|---|---|
return a >>= f ≡ f a | 左単位元 — 持ち上げてすぐ繋ぐのは、直接呼ぶのと同じ |
m >>= return ≡ m | 右単位元 — 何もしない継続を繋いでも変わらない |
(m >>= f) >>= g ≡ m >>= (\x -> f x >>= g) | 結合律 — 括り方が結果を変えない |
三つ目が効く。括り方が結果を変えないので、do 記法のような「上から順に書く」構文に安全に脱糖できる。逆に言えば、法則を満たさないものを >>= の形で書くと、リファクタリングで括りを変えた瞬間に意味が変わる。
JavaScriptの Promise.then はこの意味では厳密にモナドではない。resolveがthenableを見つけると再帰的に同化するので Promise<Promise<T>> が作れず、値がthenableのときに左単位元 — Promise.resolve(v).then(f) と f(v) — が食い違う。法則は「すべての値について」成り立たねばならないので、この一例で落ちる。動機は同じだが、法則を落としている例として見ておくとよい。
「モナド = 副作用」でもない
よくある理解
モナドとは副作用を扱うための仕組みである。IO がその代表で、純粋関数型言語が副作用を型で囲い込むために導入した。
実際は
Maybe も List も副作用と無関係である。IO がたまたまモナドなだけで、そこから「モナドとは副作用のことだ」と読むのは、例を定義と取り違えている。
一文でいうなら、flatMap が定義できて、それが素直に振る舞う型のことである。Rustの Option/Result と ? 演算子、Array.prototype.flatMap はどれもこの形の具体例になっている。
この定義がIaCの議論に効く場所
ここで押さえた「右辺が関数である」という一点が、そのままモノイドとモナドを分けるのは継続を持てるかどうかであるの主題になる。継続を関数として持てるかどうかが、成果物を実行前に全部見られるかどうかを決めている。
Pulumiの Output<T> が意図的に then を持たないのは、この構造への対応として読める。then があると await できてしまい、未知値が素の値として漏れる。apply だけを入口にすることで、bindを通る経路を1本に絞っている。未知値の規律と段階分離は三すくみである
根拠の確度
三つのモナド則は Haskell 2010 Language Report §6.3.6 に等式として載っている。ただし左単位元 / 右単位元 / 結合律という呼び名はReportには無い — Reportは三本の等式を名前なしで並べているだけで、この名前は Typeclassopedia 以降の慣用である。
Maybe のbindは GHC.Internal.Base の定義そのまま。List は実際にはリスト内包表記 xs >>= f = [y | x <- xs, y <- f x] で定義されている(内包表記が >>= に脱糖されると無限ループになるため、と実装にコメントがある)。本文に書いた concat (map f xs) は外延的に等しい説明用の形で、baseの字面ではない。
なお本文の M はGHCの現在の Monad を念頭に置いている。Haskell 2010の Monad クラスは fail を持ち Applicative を上位クラスに持たないので、Reportはあくまで法則の出典として引いている。
Promiseがモナド則を満たさないことは、resolveがthenableを再帰的に同化することから従う既知の議論。仕様上の該当箇所は ECMA-262 CreateResolvingFunctions で、Get(resolution, "then") がcallableなら値として履行せず NewPromiseResolveThenableJob に回す。かつて "Promise Resolve Functions" と呼ばれていた節で、その名前でのアンカーは現在は別名として残っているだけである。
Output<T> が then を持たないことは、Pulumiの実装位置(Proxyで then を undefined に潰す)を参照ノートに記録してある。
「箱ではなく合成戦略」という言い方は説明のための整理であって、圏論側の定式化(自己関手の圏のモノイド対象)とは別の切り口である。関数モナド (->) r を「中に何も入っていない」と書いたのも同じく口語で、正確には値を蓄えるのではなく環境への依存が文脈になっている、という意味である(instance Monad ((->) r) where f >>= k = \r -> k (f r) r)。