Functor・Applicative・Monadは、次の計算が前の値に依存できるかで分かれる
F<A>を「Aを返す計算」として読む。ただの計算ではなく、失敗する、まだ終わっていない、値が決まらない、といった普通の値には無い性質を伴う。Result<A, E>なら「Aを返すかEで失敗する計算」である。箱として読まない理由はモナドは箱ではなく合成戦略であるに書いた。
Functor・Applicative・Monadは、この計算をどうつなげられるかの違いであり、分かれ目は一点しかない。
次に実行する計算は、前の計算の結果の値に依存するか?
Functor — 結果に普通の関数を後から適用する
read_port() // Result<u16, E>
.map(|p| p + 1) // Result<u16, E>
計算は1つのままで、失敗するかどうかも変わらない。結果を加工するだけなので、計算を2つ組み合わせることはできない。
fmapの型の読み方
class Functor f where
fmap :: (a -> b) -> f a -> f b
fはMaybeや[]のような、型引数を1つ受け取る型である(Maybe単体はまだ型ではなく、Maybe Intで型になる。MaybeはRustのOptionに当たる)。素直に読むと「a -> bの関数と、aを返す計算を受け取り、bを返す計算を返す」で、RustのOption::mapがこれに当たる。
->は右結合なので、この型は(a -> b) -> (f a -> f b)と同じである。こう括ると「普通の関数を、fの世界で動く関数に持ち上げる」と読める。(+1) :: Int -> Intがfmap (+1) :: Maybe Int -> Maybe Intになる。
型だけなら中身を無視する実装も書けるので、法則が2つ付く。
fmap id == id
fmap (g . h) == fmap g . fmap h
1つ目と、fmapが中身の型を知らないこと(パラメトリシティ)から、fmapは計算の形に手を出せない。JustはJustのまま、リストの長さも変わらない。「Functorは加工だけ」と言えるのはこの法則のおかげである。
Monadの>>=と並べると、違いは渡す関数の戻り値だけになる。
fmap :: (a -> b) -> f a -> f b
(>>=) :: f a -> (a -> f b) -> f b
戻り値がbなら前の値は加工されるだけだが、f bなら前の値を見て次の計算を作れる。この差がそのまま、次の計算が前の値に依存できるかどうかの分かれ目になっている。
Applicative — 独立した計算をいくつか実行し、結果を合わせる
(,) <$> readHost <*> readPort
readHostとreadPortは互いの結果を必要としない。どの計算を実行するかは、実行する前に全部決まっている。値が何であっても顔ぶれは変わらないので、実行せずに「この処理はhostとportを読む」と列挙できるし、並列に走らせることも、失敗を全部集めることもできる。
RustのOption::zipはこの形だが、Resultには対応する標準のメソッドが無く、and_thenで代用することになる。書けてしまうことと、依存していないことは別である。
Monad — 前の結果を見て、次の計算を決める
read_env().and_then(|env| match env {
Env::Prod => read_prod_secrets(),
Env::Dev => Ok(Secrets::dummy()),
})
2つ目にどの計算を実行するかは、read_envが返した値で決まる。回数も決められる(取ってきたリストの長さだけ繰り返す)。代わりに、実行してみるまで、どんな計算が走るのか全体が分からない。継続が関数として渡されることの帰結はモノイドとモナドを分けるのは継続を持てるかどうかであるに書いた。
Selective — 分岐はするが、選択肢は事前に宣言する
ApplicativeとMonadの間に段がある。
select :: f (Either a b) -> f (a -> b) -> f b
1つ目の結果がLeftなら2つ目を実行し、Rightなら飛ばす。実行されうる計算はすべて静的に並んでいて、そのうちどれを実際に実行するかだけが値で決まる。だから実行前に「最大でこれだけ走る」「最低でもこれだけ走る」という上下の見積もりが取れる。
力と解析しやすさは逆向き
| 次の計算は前の値に依存するか | 実行前に分かること | |
|---|---|---|
| Functor | 次の計算が無い(加工だけ) | すべて |
| Applicative | 依存しない | 実行される計算の一覧 |
| Selective | 依存するが、選択肢は事前に列挙 | 実行されうる計算の一覧 |
| Monad | 自由に依存する | 最初の一手だけ |
表現力は下ほど強い(Monad ⊃ Selective ⊃ Applicative ⊃ Functor)。分かることは下ほど減る。同じ性質の裏表なので、両方を同時には取れない。
設計の指針は、必要な一番弱い構造で書けるようにすることになる。強い構造を使わずに済む箇所が増えるほど、実行前の解析が正確になる。
IaCのplanに当てはめる
planは「applyする前に構造を見渡す」作業なので、静的解析そのものである。ここでの計算は「リソースを作ること」になる。
- 属性値の参照はApplicativeである。値が未確定でも、どのリソースを作るかは決まっている。予測可能性はgraph形状と属性値の二側面を持つ
- 条件付きリソース(
enabled ? 1 : 0)はSelectiveである。作りうるものは全部見えている。 - apply後に分かるリストに対する
forループはMonadである。いくつ作るかが前の結果で決まる。effectは「何がいつ確定するか」で三つに分かれる
Terraformは最後のものをplan時に拒む。countに未確定の値を渡すと「countの値がapplyまで決まらない属性に依存しているので、いくつ作られるか予測できない」というエラーになる。
出力リストの長さが入力で決まるなら、deferredは値だけの問題に格下げできるは、このMonadな依存のうち、長さが入力から決まるものをApplicativeに戻す提案として読める。
根拠の確度
ApplicativeがMonadより弱く、したがって適用範囲が広い構造として導入されたことはMcBride & Patersonの論文の要旨にある。Selectiveについて「効果はすべて静的に宣言し、どれを実行するかは動的に選ぶ」中間の抽象であることとselectの型は、Mokhovらの論文とHackageのselectiveパッケージで確認した。上下の見積もりはパッケージのOver / Underに当たる。
Monad ⊃ Selective ⊃ Applicativeの包含は、「すべてのMonadはSelectiveにでき(selectM)、すべてのApplicativeはSelectiveにできる(selectA)」という意味での表現力の順序である。型クラスの継承関係は、HaskellのMonadがSelectiveを上位クラスに持つわけではないので、それと同じではない。
Terraformのエラーはhashicorp/terraform#25815などに報告されている文言を要約した。条件付きリソースをSelectiveと見ること、forループをMonadと見ることは解釈である。