関数型プログラミング Ch10 モナドと入出力
資料
第1章 (副作用は型で区別 / モナドの仕組みを通す), 第2章 (IO () の詳細は省く), 第4章 (main / do を実用) で先送りした「IO とは何か」を本章で回収します. 出発点は 第9章 の Functor (関手) で, これを Applicative → Monad と 2 段階に強めていきます. その途中で, 第4章以降おまじないとして使ってきた do 記法の正体も明かします.
本章は 3 部構成です. 第 1 部「Applicative」では 独立な計算の組み合わせ を, 第 2 部「モナド」では 前の結果に依存する連鎖 を扱います. どちらも失敗を表す Maybe / Either という 純粋な 材料で手作りしてから型クラス化し, 代数構造 (第8章)・圏 (第9章) の言葉で正体を捉えます. どちらも Functor の拡張 です. Applicative にすると パターン付き計算を複数化 (多引数化) でき, モナドにすると パターンと前の結果に応じて次の計算へ移れる ようになります. 拡張の中身はどちらも「関手に自然変換を 2 本足して法則を課す」で, クラスはその数学の組を使いやすい形に変換した 利用形 です. 第 3 部「入出力」では, その同じ仕組みが IO にも当てはまることを見て, 副作用を型で扱う方法を明らかにします.
講義で辿る道筋
今日の到達点: 独立した計算の組合せには Applicative を, 前の結果に応じて次の計算を選ぶ連鎖には Monad を使い分ける. do がその連鎖の読みやすい表記であり, IO でも同じ仕組みが働くことを説明できる.
板書と本文では, 次の順に進みます.
- Applicative: 複数の
Maybeを, すべて成功したときだけ組み合わせる. Exercise CH10-1 - Monadと
do記法: 前の結果に依存する次の計算をつなぐ. Exercise CH10-2 - Maybe / Either モナドとリストモナド: 失敗の連鎖と, for 文としての非決定性. Exercise CH10-3
- IO モナドと標準入出力: 純粋コアと IO の殻. Exercise CH10-4
必要になったときに本文へ戻る節: 緩モノイド関手, Applicative 則, 自己関手と \eta / \mu, モナドは自己関手の圏におけるモノイド対象, Kleisli 圏, モナド則の 3 つの定式化, interact の発展演習.
本章の地図を先に渡しておきます (各行は対応する部で実物になります).
| 層 | できるようになること | 足す自然変換 | 法則 | クラス (利用形) |
|---|---|---|---|---|
| Functor f | パターン付き計算 (1 引数) | (なし: 土台) | 関手則 | fmap |
| Applicative (f, \varepsilon, \mu) | パターン付き計算の複数化 (独立な組み合わせ) | \varepsilon : 1 \to f\,1 / \mu : f\,a \times f\,b \to f\,(a \times b) | 結合律・単位律 | pure / <*> = カリー化 |
| モナド (T, \eta, \mu) | パターンと結果に応じた連鎖 | \eta : \mathrm{Id} \Rightarrow T / \mu : T \circ T \Rightarrow T | モナド則 | return / >>= = Kleisli 化 |
クラスの列を 利用形 と書いたのは, どちらのクラスも数学の組そのものではなく, 書きやすい形への変換を 1 枚挟んでいるからです.
Applicative
Functor の復習
第9章 で見たとおり, 関手 f は fmap :: (a -> b) -> f a -> f b を備え, 「f を適用する前の世界で使う関数 a -> b を, 適用した後の世界で使える関数 f a -> f b に変換する」ものでした. 言い換えれば「1 引数関数を関手の世界へ持ち上げる」操作です.
ここで本章の言葉を 1 つ定めておきます. Maybe a の Nothing / Just x, リストの [] / x:xs のような, データ構築子による場合分けの形 を, 本講義では パターン と呼びます (第4章以来パターンマッチで照合してきた, あの形のことです). f a の値は, 素の値 a そのものではなく, 必ずこのパターン付きで返ってきます. パターンは関手やモナドの数学的定義に含まれる部品ではなく, 同じ構造に載る各インスタンスの動きを読むための呼び名 です. この言葉で言い直すと, fmap は「パターンのない世界の計算 a -> b を, パターン付きの世界の計算 f a -> f b に変換する」道具, つまり パターン付き計算 を書くための最初の道具です.
-- 復習: fmap は 1 引数関数を関手に持ち上げる
main :: IO ()
main = do
print (fmap (+ 1) (Just 3)) -- Just 4
print (fmap (* 2) [1, 2, 3 :: Int]) -- [2,4,6]ところが fmap は 1 引数専用 です. 本章では, このパターン付き計算を 順番に 2 回拡張 します.
- パターン付き計算を複数化したい (→ Applicative).
Just 2とJust 3を(+)で足してJust 5にしたい, つまり 2 引数関数版のfmapが欲しい. しかしfmap (+) (Just 2)はJust ((+) 2) :: Maybe (Int -> Int)です. 関数がMaybeの世界の中に落ちたところで,fmapでは手が止まります. - パターンと前の結果に応じて, 次の計算へ移りたい (→ Monad). 「
safeDiv 100 xが成功したら, その商をさらに割る」ように, 次に何を計算するかが前の結果の値で決まる場面です.fmapは固定の関数を中身に作用させるだけで, 「中身を見て次の計算を作る」ことはできません.
どちらの拡張も, やることは同じで, 関手に自然変換を 2 本足して法則を課す だけです.
多引数の fmap が欲しい
まず拡張の 1 つ目, パターン付き計算の複数化 (多引数の持ち上げ) から. 2 引数版から順に liftA2, liftA3, … と呼ぶことにして, 型を掲げておきます.
\texttt{liftA2} : (a \to b \to c) \to f\,a \to f\,b \to f\,c, \qquad \texttt{liftA3} : (a \to b \to c \to d) \to f\,a \to f\,b \to f\,c \to f\,d, \quad \dots
鍵は第5章のカリー化を 逆向きに 使うことです. 2 引数関数 a -> b -> c は, 組からの 1 引数関数 (a, b) -> c と行き来できました (uncurry). 1 引数関数なら fmap で持ち上がります. 3 引数以上も同じで, 組を入れ子に畳み込んでいけば (((a, b), c), (((a, b), c), d), …), 何引数でも 1 引数に直せます. だとすれば, 足りない部品は 2 つだけです.
- 乗法 \mu : f\,a \times f\,b \to f\,(a \times b):
f aとf bを組のf (a, b)にまとめる. これを畳み込めば, n 引数ぶんの材料が 1 つの組にまとまります. - 単位 \varepsilon : 1 \to f\,1: 0 引数 (素の値) を世界へ入れる写像.
この 2 つの部品を, まず Maybe とリストで手作りしてみます.
独立な計算の組み合わせ
部品を手作りします. 最初の部品は乗法 \mu, すなわち「Maybe a と Maybe b を組の Maybe (a, b) にまとめる」関数です. Maybe の意味 (失敗しうる計算) を考えれば, 実装は 1 通りしかありません. 両方そろったときだけ 組にできます.
-- 2 つの独立な計算結果を, 両方そろったときだけ組にする
both :: Maybe a -> Maybe b -> Maybe (a, b)
both (Just x) (Just y) = Just (x, y)
both _ _ = Nothing -- どちらかが欠ければ失敗
main :: IO ()
main = do
print (both (Just 2) (Just 'a')) -- Just (2,'a')
print (both (Just 2) (Nothing :: Maybe Char)) -- Nothingこの both と fmap があれば, 欲しかった 2 引数の持ち上げが組み立てられます. 前節の設計図どおり, uncurry (第5章) で 2 引数関数を組からの 1 引数関数に直し, both でまとめた組に fmap で適用するだけです.
-- (both は上の定義をそのまま使う)
lift2 :: (a -> b -> c) -> Maybe a -> Maybe b -> Maybe c
lift2 g x y = fmap (uncurry g) (both x y)
main :: IO ()
main = do
print (lift2 (+) (Just 2) (Just 3)) -- Just 5
print (lift2 (+) (Just 2) Nothing) -- Nothing冒頭の需要「Just 2 と Just 3 を (+) で足したい」が, これで回収できました. 3 引数以上も設計図どおり, 組を入れ子に畳み込めば届きます.
-- (both は上の定義をそのまま使う)
lift3 :: (a -> b -> c -> d) -> Maybe a -> Maybe b -> Maybe c -> Maybe d
lift3 g x y z = fmap (\((a, b), c) -> g a b c) (both (both x y) z)
main :: IO ()
main = do
print (lift3 (\a b c -> a + b + c) (Just 1) (Just 2) (Just 3)) -- Just 6
print (lift3 (\a b c -> a + b + c) (Just 1) Nothing (Just 3)) -- Nothingリストでも同じ部品が作れます. 「独立な計算の組み合わせ」のリスト版は, 第7章の内包表記による 直積 (すべての組合せ) です.
bothList :: [a] -> [b] -> [(a, b)]
bothList xs ys = [ (x, y) | x <- xs, y <- ys ]
main :: IO ()
main = do
print (bothList [1, 2 :: Int] "ab")
-- [(1,'a'),(1,'b'),(2,'a'),(2,'b')]
print (fmap (uncurry (+)) (bothList [1, 2] [10, 20 :: Int]))
-- [11,21,12,22]both と bothList は型の形も組み立て方も同じで, 違いは「組にする」の中身 (失敗の伝播か, 候補の掛け合わせか) だけです. 残る部品は 0 引数の持ち上げ, すなわち素の値を余計な分岐なしに包む単位 \varepsilon です (Maybe なら Just (), リストなら [()]). この「関手 + 組にする乗法 + 単位」という装備一式には, 数学の名前が付いています.
緩モノイド関手 (lax monoidal functor)
Applicative の数学的実態は, 緩モノイド関手 (lax monoidal functor) です.
土台は第7章の 直積 です. Hask の型たちは, 積 A \times B と 1 点集合 1 (Haskell の () 型) について
(A \times B) \times C \;\cong\; A \times (B \times C), \qquad A \times 1 \;\cong\; A
を満たします. 結合律と単位律が, 等号の代わりに 同型 \cong (組の付け替え) で成り立つ, モノイドによく似た構造です. 圏にこの種の「積と単位」を添えたものを モノイダル圏 (monoidal category) と呼びます. 緩モノイド関手 とは, 関手に自然変換を 2 本追加した組 (f,\ \varepsilon,\ \mu) です.
- データ:
- 法則:
- 結合律: 3 つを \mu でまとめる 2 通り (左 2 つが先か, 右 2 つが先か) が, 組の付け替え \cong を除いて一致する. 組の畳み込みを 左から積んでも右から積んでも結果が変わらない (多引数化が破綻しない) のは, この法則のおかげです.
- 単位律: \varepsilon を \mu で掛けても情報が増えない (\mu\,(\varepsilon, x) は x と, 付け替えを除いて同じ).
この \mu を図にします. 出発点は 対の世界 Hask×Hask (対象 = 型の対 (a, b)) です. そこから 平行な関手が 2 本 走ります. 1 本は成分ごとに f で持ち上げる (a, b) \mapsto (f\,a,\ f\,b), もう 1 本は組んでから持ち上げる (a, b) \mapsto f\,(a, b) です. 乗法 \mu は その 2 本の間の二重矢印 (自然変換) で, 成分は (f\,a,\ f\,b) \to f\,(a, b) です. これは定義に書いた f\,a \times f\,b \to f\,(a \times b) と同じものです (第7章のとおり, 直積型 a \times b の Haskell での書き方が組の型 (a, b) なので, 図の中の型はすべて組の記法で書きます). 単位 \varepsilon も同様に, 一点圏からの「1 を選ぶ」関手と f \circ 1 の間の二重矢印です (図では省略).
図の 2 つの経路を比べてください. 上の経路の行き先は成分ごとに持ち上げた対 (f\,a,\ f\,b), 下の経路の行き先は組んでから持ち上げた f\,(a, b) です. 持ち上げたあとの世界で組み直しができるのは, 上の経路から下の経路へ渡す自然変換 \mu があるからです. \mu は 向き付きの写像 であって等号ではありません. 「緩 (lax)」の一語はここを指しています. 第8章の準同型が h\,(x \bullet y) = h\,x \bullet' h\,y という 等号 で演算を保ったのに対し, 緩モノイド関手は積を 写像で 保ちます. 準同型の等号を, 向き付きの写像に弱めた形です.
| 第8章のモノイド準同型 h : S \to S' | 緩モノイド関手 f | Haskell |
|---|---|---|
| 要素の対応 h | 型の対応 a \mapsto f\,a (関手) | 型構築子 f + fmap |
| 演算を保つ: h\,(x \bullet y) = h\,x \bullet' h\,y (等号) | 積と両立: 乗法 \mu : f\,a \times f\,b \to f\,(a \times b) (向き付き) | liftA2 (,) (次節) |
| 単位元を保つ: h\,e = e' | 単位: \varepsilon : 1 \to f\,1 | Just () / [()] |
| (定義に含まれる) | 結合律・単位律 (\cong を除いて) | Applicative 則 |
この定義を, 手作りの部品で確かめます.
Maybe の乗法 は「両方そろったときだけ組にする」, すなわち手作りした both です. どちらかが Nothing なら全体が Nothing になります (失敗の伝播).
リストの乗法 は第7章の内包表記による 直積 (すべての組合せ), すなわち手作りした bothList です.
型クラス Applicative
Haskell は, 前節の数学の組 (f, \varepsilon, \mu) を そのままの形ではクラスにしていません. 書きやすさのための 利用上の変換 (カリー化) を 1 枚挟んだものが, Applicative (適用可能関手) クラスです.
class Functor f => Applicative f where
pure :: a -> f a
(<*>) :: f (a -> b) -> f a -> f b2 つのメソッドは, パターンの言葉で次のように読めます.
pureは「素の値に, 分岐のない素直なパターンを付けて関手の世界へ持ち上げる」ものです.Maybeならpure 3 = Just 3, リストならpure 3 = [3].fmapが 1 引数,<*>の連鎖が n 引数の持ち上げだとすれば,pureは 0 引数の持ち上げ にあたります. 数学の単位 \varepsilon (たった 1 個の値f ()) との関係はpure a = fmap (\() -> a) ε, 逆に \varepsilon =pure ()で, つまり \varepsilon をfmapで全型に広げた利用形 です.(<*>)(「apply」と読む) は「fの世界の中の関数を,fの世界の中の引数に適用する」ものです. これは乗法 \mu の カリー化した利用形 です. 組にまとめて剥がして適用する代わりに, 1 引数ずつ受け取ってその場で適用します. superclass 制約Functor f =>があるので Applicative は必ず Functor でもあります.
では, この 2 つのメソッドは Maybe とリストで 何をする のか. 標準ライブラリのインスタンス宣言を見るのが早道です.
-- Maybe: 標準ライブラリの定義を場合分けの形に開いたもの (等価)
instance Applicative Maybe where
pure = Just
Just f <*> Just x = Just (f x) -- 両方そろったときだけ適用
_ <*> _ = Nothing -- どちらかが欠ければ失敗
-- リスト: 標準ライブラリの定義そのまま
instance Applicative [] where
pure x = [x]
fs <*> xs = [ f x | f <- fs, x <- xs ] -- すべての組合せ (直積)どちらの実装も, 組の言葉で読めます. pure は Maybe では Just, リストでは 1 要素リストで, どちらも \varepsilon を fmap で広げた「余計なパターンの分岐を持ち込まずに包む」の実装です. <*> は分業で読めます: ax <*> bx = fmap (\(g, x) -> g x) (both ax bx). まず手作りした both (\mu) で組み, fmap で剥がして適用します.
この 2 つのインスタンスは標準ライブラリ (base) で定義済みです. 同じ宣言を自分のファイルに書くと重複インスタンスのエラーになるので, 上のコードは読むための抜粋です (自作の型にインスタンスを書く練習は, 第9章の Box・Tree で経験済みです).
Maybe で試します. <$> は fmap の中置別名 (第9章) であることを思い出すと, 次のイディオムが読めます.
main :: IO ()
main = do
print (Just (+ 1) <*> Just 3) -- Just 4
print (pure (+) <*> Just 2 <*> Just 3) -- Just 5
print ((+) <$> Just 2 <*> Just 3) -- Just 5 (<$> は fmap)
print ((+) <$> Just 2 <*> Nothing) -- Nothing (一方でも欠ければ全体が失敗)(+) <$> Just 2 <*> Just 3 は次のように読みます. まず <$> (= fmap) が (+) を Maybe に持ち上げ Just ((+) 2) を作ります. 次に <*> がその Maybe の中の関数を Just 3 に適用して Just 5 を得ます. これが「n 引数関数を n 個の関手値に一斉に適用する」定石で, どれか一つでも Nothing なら全体が Nothing に短絡します.
リストの <*> も同じ分業で, 「関数のリストと引数のリストの すべての組合せ」(bothList で組んで剥がした形) を作ります.
main :: IO ()
main = do
print ([(+ 1), (* 10)] <*> [1, 2 :: Int]) -- [2,3,10,20]
print ((,) <$> [1, 2] <*> "ab") -- [(1,'a'),(1,'b'),(2,'a'),(2,'b')](,) はペアを作る構築子で, (,) <$> [1,2] <*> "ab" は 2 つのリストの直積 (デカルト積), すなわち乗法 \mu の結果そのものを与えます. リストでは, <*> が「非決定的にすべての候補を試す」というパターンの掛け合わせを表します.
手作りした lift2 の総称版は, 標準ライブラリに liftA2 として揃っています (liftA3 以降も同様). とくに liftA2 (,) は乗法 \mu の実物そのものです.
import Control.Applicative (liftA2)
main :: IO ()
main = do
-- liftA2 (,) が自然変換 f a × f b -> f (a, b) そのもの
print (liftA2 (,) (Just 1) (Just 'a')) -- Just (1,'a')
print (liftA2 (,) (Just 1) (Nothing :: Maybe Char)) -- Nothing
print (liftA2 (,) [1, 2 :: Int] "ab") -- [(1,'a'),(1,'b'),(2,'a'),(2,'b')]<*> とこの積は互いに定義し合え, f <*> x = liftA2 ($) f x, 逆に liftA2 g x y = g <$> x <*> y です. つまり Applicative クラスとは, 数学の組 (f, \varepsilon, \mu) をカリー化して型クラス化した 利用形 です. <*> ⇄ \mu, pure ⇄ \varepsilon の往復は, いまの 2 本の等式でいつでもできます.
Functor との関係も明快で, fmap は Applicative の特別な場合になっています.
\mathrm{fmap}\ f\ x \;=\; \mathrm{pure}\ f \mathbin{<\!\!*\!\!>} x
これで冒頭の需要は回収できました. 多引数の持ち上げは, lift2 / lift3 を型ごとに手書きしなくても, 引数の数だけ <*> を並べる g <$> x <*> y <*> z の形でいつでも手に入ります.
Applicative 則
第8章の代数則・第9章の関手則と同じく, この構造にも守るべき法則があります. Applicative 則 は次の 4 つで, 「緩モノイド関手」の節の 結合律・単位律 (と \mu の自然性) をカリー化の言葉に翻訳したもの です.
\begin{aligned} \text{恒等:} &\quad \mathrm{pure}\ \mathrm{id} \mathbin{<\!\!*\!\!>} v = v \\ \text{準同型:} &\quad \mathrm{pure}\ f \mathbin{<\!\!*\!\!>} \mathrm{pure}\ x = \mathrm{pure}\ (f\ x) \\ \text{交換:} &\quad u \mathbin{<\!\!*\!\!>} \mathrm{pure}\ y = \mathrm{pure}\ (\lambda g.\ g\ y) \mathbin{<\!\!*\!\!>} u \\ \text{合成:} &\quad \mathrm{pure}\ (\circ) \mathbin{<\!\!*\!\!>} u \mathbin{<\!\!*\!\!>} v \mathbin{<\!\!*\!\!>} w = u \mathbin{<\!\!*\!\!>} (v \mathbin{<\!\!*\!\!>} w) \end{aligned}
直感的には, 恒等則 は「pure id を適用しても何も変わらない」, 準同型則 は「素の値どうしの適用は, 先に計算して pure で包んでも同じ」であることを言います. 残る交換則・合成則とあわせて, 「pure は余計なパターンを持ち込まず, <*> の適用は素直に振る舞う」ことを保証しています.
第8章・第9章 の代数則・関手則と同じく, Applicative 則もコンパイラは検査しません. 法則を破るインスタンスを書いてもコンパイルは通ります. 守るのはプログラマの責任で, 第8章 のコラムで触れた QuickCheck で性質として確認できます.
発展: クラスはなぜ \mu でなく <*> か
数学の定義 (\mu と \varepsilon) と Applicative クラス (<*> と pure) が過不足なく往復できることは, パラメトリック多相のおかげで成り立つことが知られています (Applicative を導入した論文 McBride & Paterson 2008, Applicative programming with effects で, このモノイダル形との同値が示されています). クラスがカリー化版 <*> を採るのは, g <$> x <*> y <*> z のように 引数の個数だけ <*> を並べればよい (n 引数へそのまま伸びる) という実用上の便利さのためです.
Exercise CH10-1
<*> で 2 つの Maybe を掛け合わせる
2 つの Maybe Int を受け取り, 両方が Just なら中身の積を Just で返し, 一方でも Nothing なら Nothing を返す関数 mulMaybe :: Maybe Int -> Maybe Int -> Maybe Int を, if やパターンマッチを使わず <$> と <*> だけで実装してください.
-- 実行例
main :: IO ()
main = do
print (mulMaybe (Just 6) (Just 7)) -- Just 42
print (mulMaybe (Just 6) Nothing) -- Nothing
print (mulMaybe Nothing (Just 7)) -- Nothing回答例
mulMaybe :: Maybe Int -> Maybe Int -> Maybe Int
mulMaybe x y = (*) <$> x <*> y
main :: IO ()
main = do
print (mulMaybe (Just 6) (Just 7)) -- Just 42
print (mulMaybe (Just 6) Nothing) -- Nothing
print (mulMaybe Nothing (Just 7)) -- Nothing(*) <$> x で Maybe (Int -> Int) を作り, <*> y でそれを y に適用します. x か y が Nothing なら <*> が自動的に Nothing へ短絡するので, 場合分けを一切書かずに済みます. これが Applicative の「複数の関手値を組み合わせる」力です.
モナド (Monad)
拡張の 1 つ目, パターン付き計算の複数化 (独立な計算の組み合わせ) は Applicative で手に入りました. 本部で扱うのは 2 つ目, 1 つの計算結果を引き継いで次の計算へ移る「結果に応じた」計算の連鎖 です.
fmap :: (a -> b) -> m a -> m b は m a の値 a に何かしらの計算 a -> b を適用することで m b という結果を得ます (ここからはモナドの文脈なので, 関手の型変数を f ではなく Monad の慣習どおり m と書きます).
Haskell では Functor m は Maybe a や [a] などの多相データ型として表されますが, これは値 a に対して何かしらの Functor 固有のパターンと, 値 a の計算結果 を表現します.
x :: Maybe a の a に何かしらの計算 a -> b を適用した fmap f x は Just z (計算成功パターンの結果 z) または Nothing (計算失敗パターン, 結果なし) のいずれかが, パターン + 結果の組み合わせとして返ってきます.
y :: [a] の a に何かしらの計算 a -> b を適用した fmap f y は [] であったり, [z], z:zs などのパターン別の結果が返ってきます.
Functor における「データ構築子 (Just :: a -> Maybe a など) を, 単なる値に何かしらのパターンを付与するもの」,「fmap :: (a -> b) -> m a -> m b を, パターン付きの値に計算を適用しパターン付きで結果を返すもの」とみなすと, Applicative における liftA2 :: (a -> b -> c) -> m a -> m b -> m c などは, 2 引数の場合でも計算を可能にするための拡張でした.
fmap :: (a -> b) -> m a -> m b は, パターンのない世界における計算 a -> b を引数に取り, パターンのある世界での計算 m a -> m b に変換する関数であり, fmap を定めた時点で, パターン別にどのように計算を扱うかは決まっています.
-- Maybe: 標準ライブラリの定義 (読むための抜粋)
instance Functor Maybe where
fmap _ Nothing = Nothing
fmap f (Just x) = Just (f x)このとき「Nothing の場合は次に何をやる」「Just (f x) の値に応じて何をやる」といった パターンや値別の分岐処理 を, 利用側があとから指定することはできません.
これは liftA2 :: (a -> b -> c) -> m a -> m b -> m c でも同様で,
-- Maybe の場合の liftA2 (等価形)
liftA2 :: (a -> b -> c) -> Maybe a -> Maybe b -> Maybe c
liftA2 f (Just x) (Just y) = Just (f x y)
liftA2 _ _ _ = Nothing2 引数関数の挙動も, liftA2 を定めた時点で決まっています.
「safeDiv 100 x が成功したら, その商で さらに次の割り算をする」のように, 次の計算を前の結果の値から 作る ことは, <*> の形にはめられません.
ここで欲しい関数は,
- 前のパターン付き計算結果 (
m a) を受け取って, - その結果に応じてまたパターン付きの結果を返す関数 (
a -> m b) を適用する
ような関数 (>>=) :: m a -> (a -> m b) -> m b です.
中置演算子として使うと mx >>= f (前の計算結果を mx :: m a, つなげる関数を f :: a -> m b と書きます) となり, パターン付きの計算結果 mx に, パターンに応じて次の計算 f をつなげる (bind する) 演算子となります.
このような関数 (>>=) を構成するのに, 足りない部品は何でしょうか?
手元にある適用の道具は fmap だけです. fmap :: (a -> b) -> m a -> m b に f :: a -> m b を渡すと (fmap の型の b に m b を当てはめる形になり), fmap f :: m a -> m (m b) となります. したがって前の計算結果 mx :: m a に適用した fmap f mx の型は m (m b) です. 外側の m は前の計算 mx が持っていたパターン, 内側の m は f が新しく作ったパターンで, fmap はパターンに触らず中身だけを変換するため, f の返すパターンが入れ子のまま残ります.
欲しかったのは mx >>= f :: m b でした. fmap f mx :: m (m b) と見比べると, 足りないのは 二重になったパターンをひと重に潰す関数 だけです. もし join :: m (m b) -> m b があれば,
mx >>= f = join (fmap f mx)
という定義が書けます. 事実, モナドとはまさに, この join :: m (m b) -> m b という自然変換を (素の値を持ち上げる pure とともに) 法則つきで備えた関手にほかなりません.
結果に応じた計算
具体的に safeDiv 100 x の結果が Just 20 か Nothing かによって, その先の計算にどのようにつなげるかを指定した関数を考えてみましょう.
次のプログラムは「前の結果が Just のときだけ, 中身を次の計算へつなげる」関数(bind)を実装しています.
safeDiv :: Int -> Int -> Maybe Int
safeDiv _ 0 = Nothing
safeDiv x y = Just (x `div` y)
-- 前の結果が Just のときだけ, 中身を次の計算へつなげる
bind :: Maybe a -> (a -> Maybe b) -> Maybe b
bind Nothing _ = Nothing -- 次の計算を実施せず終了
bind (Just x) f = f x -- 前の計算結果を次の計算に渡す
calc :: Int -> Maybe Int
calc x = safeDiv 100 x `bind` \r -> safeDiv r 2
main :: IO ()
main = do
print (calc 5) -- Just 10 (100/5 = 20, 20/2 = 10)
print (calc 0) -- Nothing (最初の割り算で失敗 → 連鎖が止まる)safeDiv 100 x `bind` \r -> safeDiv r 2 を追います. safeDiv 100 x が Just 20 なら, bind が中身 20 を \r -> safeDiv r 2 に渡し, safeDiv 20 2 = Just 10 を得ます. もし Nothing なら, bind は 後ろの関数を呼ばずに Nothing を素通し します. これが「パターンと結果に応じた計算」で, bind でつなげる関数を変えることで, 値に応じた様々な計算を実現できます.
実際に, つなげる関数を取り替えたり, 連鎖を伸ばしたりしてみます. 部品を 2 つ足します.
-- 部品 1: 偶数なら半分にし, 奇数なら失敗する
halve :: Int -> Maybe Int
halve n = if even n then Just (n `div` 2) else Nothing
-- 部品 2: 非負ならそのまま通し, 負なら失敗する
assertNonNeg :: Int -> Maybe Int
assertNonNeg n = if n >= 0 then Just n else Nothing
main :: IO ()
main = do
-- 同じ 1 段目でも, つなげる関数しだいで分岐が変わる
print (safeDiv 100 4 `bind` assertNonNeg) -- Just 25
print (safeDiv 100 4 `bind` halve) -- Nothing (25 は奇数)
print (safeDiv 100 5 `bind` halve) -- Just 10 (20 は偶数)1 段目の safeDiv 100 4 はどれも Just 25 を返しますが, その先は assertNonNeg なら通り, halve なら失敗します. 次にどう分岐するかは, つなげる関数 a -> Maybe b が丸ごと持っています. bind はどの関数が来ても, パターンに応じて「中身を渡すか, 止めるか」を判断するだけです.
連鎖は何段でも伸ばせます. しかも後ろの段は, それまでに束縛した すべての結果 を使えます.
-- 3 段の連鎖: 割ってから, 半分にして, 両方の結果を足す
-- (safeDiv, bind, halve は上の定義をそのまま使う)
calc3 :: Int -> Int -> Maybe Int
calc3 x y =
safeDiv x y `bind` \r ->
halve r `bind` \s ->
Just (r + s)
main :: IO ()
main = do
print (calc3 100 5) -- Just 30 (r = 20, s = 10)
print (calc3 100 4) -- Nothing (r = 25 が奇数で halve が失敗)
print (calc3 100 0) -- Nothing (最初の割り算で失敗)最後の段 \s -> Just (r + s) の中から, 1 段目の結果 r も見えていることに注目してください. ラムダが入れ子になっているので, 内側の段ほど使える名前が増えます. その代わり, 連鎖が深くなるほど \r ->, \s ->, … の入れ子が積み重なり, 読みにくくなっていきます.
この bind を備えた構造は, 数学的には モナド (monad) と呼ばれています. モナドは Applicative と同じく 関手 + 自然変換 2 本 からなります.
class Functor f => Applicative f, class Applicative m => Monad m. モナドは Applicative の 拡張 であり, pure は共有されます (のちに出てくる return はその別名です). できることの広がりは Functor ⊂ Applicative ⊂ Monad の一直線です. 数学の側も同じで, このあと取り出すモナドの正体 (T, \eta, \mu) からは, 緩モノイド関手の単位 \varepsilon と乗法 \mu が導けます. 従って, どんな Monad からも <*> は導けます: mf <*> mx = mf >>= \f -> mx >>= \x -> pure (f x) (標準ライブラリの ap). 連鎖が書ければ, 組み合わせは自動で付いてきます.
モナドの正体
第9章 では, 型構築子 m (種 * -> *) が 関手, 型に依らない一様な多相関数 \forall a.\ f\,a \to g\,a が 自然変換 でした. モナドは, この 2 つだけからできています.
圏 Hask 上の関手のうち, 行き先も Hask 自身であるもの を 自己関手 (endofunctor) といいます. Maybe・[]・Either e のように Functor のインスタンスになる型構築子は, すべて Hask 上の自己関手です. モナド とは, 自己関手 T に次の 2 つの自然変換を備えたものです.
- 単位 (unit) \eta : \mathrm{Id} \Rightarrow T: 恒等関手 \mathrm{Id} (第9章の「圏の圏 Cat」の節で定義した「何もしない関手」) から T への自然変換. Haskell では
pure :: a -> m a(素の値a, =\mathrm{Id}\,a をm aへ持ち上げる). - 乗法 (multiplication) \mu : T \circ T \Rightarrow T: 「T を 2 回適用したもの」から T への自然変換. Haskell では入れ子をひと重に潰す
join :: m (m a) -> m a.
この組には法則 (モナド則) が課されます. 緩モノイド関手の結合律・単位律にあたるものです.
- 結合律:
join . fmap join = join . join. 3 重の入れ子m (m (m a))を, 内側から潰しても外側から潰しても同じ. - 単位律:
join . fmap pure = join . pure = id.pureで 1 枚かぶせてからjoinで潰すと, 元に戻る.
第8章のモノイドの結合律・単位律を, \mu を演算, \eta を単位元とみて言い直した形です.
関手 + 単位 + 乗法. この組の形は, 緩モノイド関手 (f, \varepsilon, \mu) とまったく同じです. 変わったのは乗法が相手にする「積」だけで, Applicative では直積 \times (値の対 を掛ける) でしたが, モナドでは 関手合成 \circ (T 自身をもう 1 回掛ける) です.
ここで T \circ T は第9章で定義した関手の合成, すなわち「T を 2 回適用した」m (m a) です.
この組を図にします. 圏 Cat では Hask は 1 つの点に潰れ, 恒等関手 \mathrm{Id}・自己関手 T・T を 2 周した T \circ T がその点の ループ射 でした (第9章の「圏の圏 Cat」の節). 点を開くと (下段), 3 つの箱 (もとの部分圏 \langle A, B, C \rangle, それを T で写した像, もう一度写した T \circ T の像) が どれも同じ Hask の中に 並びます. 箱から箱への矢印 T は, Hask 全体から Hask 全体への T をこの部分圏に 制限 したものです. 行き先は本当はどれも外枠の Hask 全体なので, \mathrm{Id}・T・T \circ T の 3 本は域と余域を共有する平行な関手であり, 単位 \eta : \mathrm{Id} \Rightarrow T と乗法 \mu : T \circ T \Rightarrow T は 関手の矢印どうしを結ぶ二重矢印 として, 開いた図の中に正確に描けます (端点は関手であって像ではありません). 成分は Hask の普通の関数で, \eta_A = \texttt{pure} : A \to T\,A, \mu_A = \texttt{join} : T\,(T\,A) \to T\,A です. 三角形 (f, g, g \circ f) が 三角形のまま 写っていることは, T が合成ごと保つ (関手則) ことの絵になっています.
import Control.Monad (join)
main :: IO ()
main = do
print (join (Just (Just 3))) -- Just 3 (μ: m (m a) -> m a)
print (join (Just Nothing :: Maybe (Maybe Int))) -- Nothing
print (join (Nothing :: Maybe (Maybe Int))) -- Nothing
print (join [[1, 2], [3 :: Int]]) -- [1,2,3] (リストの join は concat)\eta (pure) と \mu (join) さえあれば, 手作りの bind は導けます. 「m a に f :: a -> m b を施す」とは, まず fmap f で m (m b) を作り, join でひと重に潰すことだからです.
\texttt{bind}\ m\ f \;=\; \underbrace{\texttt{join}}_{\mu}\ (\texttt{fmap}\ f\ m), \qquad \texttt{join}\ m \;=\; \texttt{bind}\ m\ \texttt{id}
モナドは自己関手の圏におけるモノイド対象
第8章 のモノイドとの対応. \mu を「2 つを 1 つにまとめる 演算」, \eta を「その 単位元」とみると, モナドは 第8章 の モノイド とそっくりです. モノイドが集合の上の (<>) と mempty だったのに対し, モナドは 自己関手の上の \mu と \eta です. 実際モナドは「自己関手の圏におけるモノイド」と呼ばれ, 第8章 → 第9章 → 本章と続く「モノイドを一般化する」筋のもう一つの到達点になっています. Applicative が第8章の 準同型 (積を写像で保つ) の再演だったのに対し (「緩モノイド関手」の節), モナドは モノイド (演算 = 関手合成) の再演です. 第8章の 2 つの構造が, それぞれ 1 段上の世界で再演されているわけです.
| 第8章 モノイド | モナド (自己関手のモノイド) | Haskell |
|---|---|---|
| 台集合 S | 自己関手 T | 関手 m (Functor インスタンス) |
| 演算 (\diamond) : S \times S \to S | 乗法 \mu : T \circ T \Rightarrow T | join :: m (m a) -> m a |
単位元 e (mempty) |
単位 \eta : \mathrm{Id} \Rightarrow T | return / pure |
| 結合律・単位律 | モナド則 | — |
前節の「自己関手の圏におけるモノイド」という言葉を, 第8章 の note と 第9章 の圏の定義で見た「組 + 法則」の視点で眺め直すと, モノイドから本章のモナドまでが一本の階段になっていることが見えてきます. どの段も「組 + 法則」というレシピは同じで, 変わるのは 台に何を据えるか だけです.
| 段 | 台 (= 前段の「構造を保つ写像」) | 演算 (2 項) | 単位 (0 項) | 章 |
|---|---|---|---|---|
| モノイド | 値 | <> |
mempty |
第8章 |
| 圏 Hask | 関数 (= 集合の写像) | (.) (型の合う対のみ) |
id |
第9章 |
| 圏 Cat | 関手 (= 圏の準同型) | 関手合成 | 恒等関手 | 第9章・本章 |
| 関手圏 (ここでは自己関手の圏) | 自然変換 (= 関手どうしの変換) | 合成 | 恒等自然変換 | 第9章・本章 |
台はどの段でも「前段の構造を保つ写像」です. 集合の写像 (関数) を台に据えると圏 Hask になり (第9章 の「圏はモノイドの一般化」), 圏の構造を保つ写像 (関手 = 圏の準同型, 第9章) を台に据えると圏 Cat になり, 関手どうしの変換 (自然変換) を台に据えると関手圏になります.
図でいえば ズームアウトの繰り返し です. これまでに出てきた図の系列 (図0〜図4) を 1 枚に並べます. 集合だけの世界 (図0) に演算を載せると代数 (図1), 台を射の集まりに取り替えると圏 Hask (図2). そこから一歩引くと, 図全体が 1 つの点 (圏) に潰れて「点 = 圏・射 = 関手」の図3 になり, もう一歩引くと関手が点に潰れて「点 = 関手・射 = 自然変換」の図4 になります.
モナドは, この階段の最上段に立っています. Hask から Hask への自己関手たちの世界で, 関手合成 T \circ T を「掛け算」, 恒等関手 \mathrm{Id} を「単位」とみなすと, そこに もう一度モノイドのレシピが回ります. その世界の「モノイド」が前節の (T, \eta, \mu), すなわちモナドです. 第8章 の (S, \bullet, e) から始まった「組 + 法則」が, 台を 3 段持ち上げた先で同じ形のまま再演されている. これが「モナドは自己関手の圏におけるモノイド」という言葉の中身です (この意味でのモノイドを, 圏論では モノイド対象 (monoid object) と呼びます).
関手には二つの顔があります. 第9章 で見た「圏の構造を保つ写像 (準同型)」であると同時に, 一段上の圏 Cat では台の要素 (射) になります. ちょうど, モノイド準同型が「モノイドたちを対象とする圏」の射になるのと同じ関係です.
では, モナドの「台」はどれでしょうか. 上の表の各段は, 圏を「射 を台とするモノイドの一般化」とみた列で, 最上段の演算は自然変換どうしの合成です. しかしモナドのレシピは, この列の「次の段」ではなく 最上段の世界の中で 回ります. 第8章 の (S, \diamond, e) と対比すると, 台 S の役は自己関手 T (= 前段 Cat の射) が, 直積 S \times S の役は関手合成 T \circ T が, 演算 \diamond と単位元 e の役は自然変換 \mu・\eta が務めます. 前節の対応表で「台集合 S ↔︎ 自己関手 T」と並べたとおり, モナドの台は自然変換ではなく自己関手 です.
「自己」は飾りではありません. レシピの積 = 関手合成 T \circ T は 域と余域が一致するときだけ 定義できるので, 自己でない関手ではモナドのレシピ自体が回りません. 第9章の最初の関手 length (リストの一点圏から (\mathrm{Int}, +, 0) の一点圏へ) がモナドになれないのはこのためです. まとめると, モナドの前提は 2 層あります: ① 自己関手であること (数学の要求. どんな圏の上でもモナドは定義できるが, 台は必ず自己関手), ② その自己関手が型クラス化可能であること (Haskell の要求. class Functor f => Applicative f => Monad f という階層が, 台が f :: * -> * の型構築子 + パラメトリックな fmap で書けること (第9章の Functor クラスの 3 条件) を型システムで強制しています).
型クラス Monad
Haskell は, いま取り出した構造 (T, \eta, \mu) を Monad クラスとして型クラス化しています. ただし, 利用するのは \mu を変換した >>= (bind)です.
class Applicative m => Monad m where
return :: a -> m a
(>>=) :: m a -> (a -> m b) -> m breturnはpureと同じです (return = pure. 歴史的経緯で別名が残っています). つまり \eta そのもの で, こちらは無変換です.(>>=)(bind) が主役です. ただしこれは乗法 \mu そのものではなく, その Kleisli 化した利用形 \mu \circ T(-) です. Haskell で書けばm >>= f = join (fmap f m), つまり 手作りしたbindの一般形 です. 分業に注意してください: 依存を注入するのはfmap f(中身aを次の計算に渡してm (m b)を作る) で, \mu (join) は入れ子を潰すだけです.
Maybe と[]のインスタンス宣言で, >>= の中身も確かめておきます (標準ライブラリで定義済みなので, 読むための抜粋です).
-- Maybe: 標準ライブラリの定義そのまま (return は既定で pure)
instance Monad Maybe where
Nothing >>= _ = Nothing -- 失敗は素通し
Just x >>= f = f x -- 中身を次の計算へ渡す
-- リスト: 内包表記に開いた等価な形
instance Monad [] where
xs >>= f = [ y | x <- xs, y <- f x ] -- 各要素に f を試して, 全部つなぐMaybe の >>= は, 手作りした bind と 同じ場合分けそのもの です (中置記法になっただけ). リストの >>= は「各要素 x に次の計算 f を試し, 出てきたリストを全部つなぐ」で, concat (map f xs), すなわち join と fmap の組 (\mu \circ T\,f) の形がそのまま見えています. 先ほどの calc は, bind を >>= に置き換えるだけでそのまま動きます.
safeDiv :: Int -> Int -> Maybe Int
safeDiv _ 0 = Nothing
safeDiv x y = Just (x `div` y)
calc :: Int -> Maybe Int
calc x = safeDiv 100 x >>= \r -> safeDiv r 2
main :: IO ()
main = do
print (calc 5) -- Just 10 (100/5 = 20, 20/2 = 10)
print (calc 0) -- Nothing (最初の割り算で失敗 → 連鎖が止まる)なお, 前の結果の値を使わずただ順に繋ぎたいときのために (>>) があり, m >> k = m >>= \_ -> k と定義されます.
これで 2 つのクラスが出揃いました. どちらも, 数学の組の 直接の対応物そのものではなく, 利用上の変換を 1 枚挟んだ利用形 です. 章冒頭の地図を, ここで回収できます.
| 数学の組 | 成分の直接の姿 (Haskell) | クラスの提供形 | 挟まっている変換 |
|---|---|---|---|
| Applicative (f, \varepsilon, \mu) | \varepsilon = Just () など / \mu = liftA2 (,) |
pure / <*> |
カリー化: <*> = \mu のカリー化, pure = \varepsilon の fmap 拡張 |
| モナド (T, \eta, \mu) | \eta = pure / \mu = join |
return / >>= |
Kleisli 化: >>= = \mu \circ T(-) = join と fmap の合成 |
使い分けの指針. 依存がなければ Applicative で書くのが定石です. mf <*> mx はパターンの形が式を書いた時点で決まっているぶん, 読み手にも処理系にも情報が多く, 並列に評価する余地も残ります. >>= を持ち出すのは, 次の計算が前の結果の値に依存するときだけ で構いません.
数学とクラスを行き来するときは, この変換を 1 枚剥がすと対応が見えます.
Kleisli 圏
\eta・\mu による定義と 等価 な, もう一つの見方があります. 返り値が m b 型になる関数 a -> m b を Kleisli 射 (Kleisli arrow) といいます. 通常の関数 a -> b は 第9章 の圏 Hask の射でしたが, Kleisli 射は返り値が m b なので, そのままでは (.) で繋げません. そこで Kleisli 合成 (>=>) (Control.Monad) を使います.
(>=>) :: Monad m => (a -> m b) -> (b -> m c) -> a -> m c
f >=> g = \x -> f x >>= gsafeDiv 100 :: Int -> Maybe Int も \r -> safeDiv r 2 も Kleisli 射なので, >=> で合成できて先ほどの calc と同じものになります.
calc' :: Int -> Maybe Int
calc' = safeDiv 100 >=> \r -> safeDiv r 2 -- calc とまったく同じKleisli 射を 射, >=> を 合成, return を 恒等射 とみると, これらは 第9章 で定義した 圏の公理 (結合律 + 恒等律) をちょうど満たします. こうしてできる圏を Kleisli 圏 といい, モナドとは「圏 Hask の上に Kleisli 圏を載せられる構造」とも言えます. 「>=> が結合的で return が恒等射」という Kleisli 圏の公理は, 後述の モナド則 そのものです.
モナド則
「モナドの正体」の節で組に課した モナド則 は, これまで見た 3 つの見方 で書けます. どれも同じ 1 つの法則の言い換えです.
(1) >>= で書く. もっとも実用的な形です.
\begin{aligned} \text{左単位元:} &\quad \mathrm{return}\ a \mathbin{>\!\!>\!\!=} f = f\ a \\ \text{右単位元:} &\quad m \mathbin{>\!\!>\!\!=} \mathrm{return} = m \\ \text{結合律:} &\quad (m \mathbin{>\!\!>\!\!=} f) \mathbin{>\!\!>\!\!=} g = m \mathbin{>\!\!>\!\!=} (\lambda x.\ f\ x \mathbin{>\!\!>\!\!=} g) \end{aligned}
(2) 単位・乗法 (\eta, \mu) で書く. 「モナドの正体」の節で定義に添えた 結合律・単位律 そのものです (join が \mu, return が \eta).
\begin{aligned} \text{結合律:} &\quad \texttt{join} \circ \texttt{fmap join} = \texttt{join} \circ \texttt{join} \\ \text{単位律:} &\quad \texttt{join} \circ \texttt{fmap return} = \texttt{join} \circ \texttt{return} = \texttt{id} \end{aligned}
(3) Kleisli 合成 >=> で書く. 「Kleisli 圏」節で見た, 圏の公理そのものです.
\mathrm{return} >\!\!=\!\!> f = f, \qquad f >\!\!=\!\!> \mathrm{return} = f, \qquad (f >\!\!=\!\!> g) >\!\!=\!\!> h = f >\!\!=\!\!> (g >\!\!=\!\!> h)
左の 2 つは「return が合成 >=> の 恒等射 (単位元) である」, 右は「>=> が 結合的 である」こと, つまり 第9章 の 圏の公理 を Kleisli 射が満たすということです. Maybe の失敗連鎖も Either のエラー伝播も, この共通の骨格の上に乗っています.
モナド則もコンパイラは検査しません. 法則を破る >>= を書いてもコンパイルは通ります. 標準ライブラリの Maybe / Either / [] / IO はすべて則を満たしますが, 自作のモナドでは自分で保証する必要があります (QuickCheck で return a >>= f == f a などを性質として確かめられます).
do 記法
「結果に応じた計算」の calc3 で見たとおり, >>= とラムダを重ねると, 連鎖が長くなるほど入れ子が深くなって読みにくくなります. Haskell は同じことを do 記法 で書けます. do は >>= / >> への 糖衣構文 (syntactic sugar) にすぎず, コンパイラが機械的に脱糖 (desugar) します.
-- do 記法で書いた calc3 (safeDiv, halve は「結果に応じた計算」の定義)
calc3 :: Int -> Int -> Maybe Int
calc3 x y = do
r <- safeDiv x y -- x <- e : e の結果を x に束縛
s <- halve r -- 前の束縛 r は, ここから先でずっと使える
pure (r + s) -- 純粋な値は pure で包む. ブロック最後の式が全体の値
-- 上は次の >>= 版とまったく同じ (機械的にこう脱糖される)
calc3' :: Int -> Int -> Maybe Int
calc3' x y =
safeDiv x y >>= \r ->
halve r >>= \s ->
pure (r + s)脱糖規則は 3 つだけです.
do { x <- e; rest }→e >>= \x -> do { rest }(結果をxに束縛して続ける)do { e; rest }→e >> do { rest }(結果を捨てて続ける)do { e }→e(最後の式)
肝心なのは, do はモナドでありさえすれば何にでも使える という点です. 第4章 以降 main = do ... と書いてきたのは IO モナドの do であり, ここで見た Maybe の do とまったく同じ仕組みでした. do は IO 専用の構文ではありません (この事実は「入出力」の部で正式に回収します).
Exercise CH10-2
do 記法で連想リストを 2 回引く
キーと値の連想リスト [(String, Int)] を受け取り, キー "x" と "y" の値を lookup (返り値は Maybe) で引いて, 両方あれば和を Just で, 片方でも無ければ Nothing を返す関数 addLookup :: [(String, Int)] -> Maybe Int を, do 記法で実装してください.
-- 実行例
main :: IO ()
main = do
print (addLookup [("x", 3), ("y", 4)]) -- Just 7
print (addLookup [("x", 3)]) -- Nothing
print (addLookup [("y", 4)]) -- Nothing回答例
addLookup :: [(String, Int)] -> Maybe Int
addLookup env = do
x <- lookup "x" env
y <- lookup "y" env
return (x + y)
main :: IO ()
main = do
print (addLookup [("x", 3), ("y", 4)]) -- Just 7
print (addLookup [("x", 3)]) -- Nothing
print (addLookup [("y", 4)]) -- Nothingx <- lookup "x" env は「lookup が Just を返せば中身を x に束縛して続け, Nothing なら全体を Nothing にして止める」ものです. y も同様で, どちらか一方でも見つからなければ return (x + y) に到達しません. 脱糖すれば lookup "x" env >>= \x -> lookup "y" env >>= \y -> return (x + y) で, Maybe モナドの >>= がすべての失敗を短絡させています.
Maybe / Either モナド
ここまでの Maybe モナドは「失敗が一つでもあれば全体が失敗」という短絡を >>= に埋め込んだものでした. 便利ですが, Maybe は「失敗した」ことしか伝えられません. Nothing に理由は載りません.
なぜ失敗したか を運ぶには Either e a を使います. 慣習として Left に エラーを表す専用の列挙型 を, Right に成功値を置きます (エラーを String で表すこともできますが, 型で場合分けできる専用の列挙型のほうが安全で, どんな失敗があり得るかがシグネチャから読めます).
-- なぜ失敗したかを型で表す (String でなく専用の列挙型)
data CalcError
= DivByZero -- 0 で割ろうとした
| Negative -- 負の入力は許さない
deriving (Show, Eq)
safeDivE :: Int -> Int -> Either CalcError Int
safeDivE _ 0 = Left DivByZero
safeDivE x y = Right (x `div` y)
checkPos :: Int -> Either CalcError Int
checkPos n
| n < 0 = Left Negative
| otherwise = Right n
-- 2 つの入力を検査してから割る. どこで失敗しても, 最初のエラーが返る.
calcE :: Int -> Int -> Either CalcError Int
calcE x y = do
x' <- checkPos x
y' <- checkPos y
safeDivE x' y'
main :: IO ()
main = do
print (calcE 100 5) -- Right 20
print (calcE 100 0) -- Left DivByZero
print (calcE (-1) 5) -- Left NegativeEither e の >>= は, Left e が出た時点で後続を素通しして その Left e を返し, Right の間はふつうに値を繋ぎます. Maybe の「失敗の連鎖」に 失敗理由を添えた ものが Either モナドです. 第12章 の式評価器も, この Either モナドで 0 除算エラーを伝播させています.
Exercise CH10-3
専用エラー型での検証チェーン (口座からの連続引き出し)
残高 balance から amount を引く関数 withdraw :: Int -> Int -> Either BankError Int を考えます. amount が負なら Left Negative, 残高不足なら Left NotEnough, それ以外は引いた後の残高を Right で返します. これを使って, 初期残高から 2 回続けて引き出す twice :: Int -> Int -> Int -> Either BankError Int を do 記法で実装してください (1 回目の引き出し後の残高から 2 回目を引きます).
data BankError = NotEnough | Negative deriving (Show, Eq)
-- 実行例
main :: IO ()
main = do
print (twice 100 30 50) -- Right 20
print (twice 100 30 80) -- Left NotEnough
print (twice 100 (-1) 0) -- Left Negative回答例
data BankError = NotEnough | Negative deriving (Show, Eq)
withdraw :: Int -> Int -> Either BankError Int
withdraw balance amount
| amount < 0 = Left Negative
| amount > balance = Left NotEnough
| otherwise = Right (balance - amount)
twice :: Int -> Int -> Int -> Either BankError Int
twice balance a b = do
b1 <- withdraw balance a
withdraw b1 b
main :: IO ()
main = do
print (twice 100 30 50) -- Right 20 (100-30=70, 70-50=20)
print (twice 100 30 80) -- Left NotEnough (2 回目で 70<80)
print (twice 100 (-1) 0) -- Left Negative (1 回目で負の額)b1 <- withdraw balance a が 1 回目の引き出し後の残高を b1 に束縛し, withdraw b1 b がそこから 2 回目を引きます. どちらかで Left が出れば, 続く引き出しは実行されずにその理由がそのまま返ります. Maybe では「失敗した」しか言えませんが, BankError により「残高不足なのか負の額なのか」を型で区別できます.
リストモナド
Maybe / Either のパターンが「失敗の伝播」だったのに対し, リストのパターンは「候補の分岐 (非決定性)」です. 型クラス Monad の節で見たとおり, リストの >>= は「各要素 x に次の計算 f を試し, 出てきたリストを全部つなぐ」でした. この節では, これが手続き型言語の for 文 にあたる道具になることを見ます.
for 文の形をした関数: forM. まず第6章の伏線を回収します. flip map xs $ \x -> ... を「手続き型言語における for 文に近い記法」として紹介しました.
main :: IO ()
main = do
-- 第6章: flip map による for 文風の書き方
print $ flip map [-3 .. 3 :: Int] $ \x ->
if x >= 0 then 1 else 0
-- [0,0,0,1,1,1,1]1 行目が「x に各要素を順に束縛する」というループのヘッダ, 改行した本体が 1 周ぶんの計算, という見た目です. この「各要素を純粋な関数で変換する for」を「各要素をモナドの計算で処理する for」へ広げたものが, 第6章で名前だけ予告した forM / forM_ です (リストに特殊化した型で掲げます).
import Control.Monad (forM, forM_)
-- リストに特殊化した型:
-- forM :: Monad m => [a] -> (a -> m b) -> m [b]
-- forM_ :: Monad m => [a] -> (a -> m b) -> m () (結果を捨てる)
halve :: Int -> Maybe Int
halve n = if even n then Just (n `div` 2) else Nothing
main :: IO ()
main = do
-- for x in [1, 2, 3]: print(x * 10) とほぼ同じ見た目
forM_ [1, 2, 3] $ \x ->
print (x * 10) -- 10, 20, 30 を順に表示
print (forM [2, 4, 6] halve) -- Just [1,2,3] (全部成功したときだけ)
print (forM [2, 3, 6] halve) -- Nothing (3 で失敗すると全体が失敗)flip map の「各要素に a -> b」が「各要素に a -> m b」になり, 返りが m [b] にまとまります. リストを for で回しながら, 各周のパターン (Maybe の失敗や IO の作用) をモナドが処理してくれるわけです.
同じループは do でも簡潔に書けます. fmap f xs = xs >>= (pure . f) なので, さきほどの flip map は「x <- xs と束縛して, 結果を pure で返すだけの連鎖」にほかなりません.
main :: IO ()
main = do
print $ do
x <- [-3 .. 3 :: Int] -- for x in [-3 .. 3]
pure (if x >= 0 then 1 else 0) -- 1 周ぶんの計算
-- [0,0,0,1,1,1,1] (flip map 版と同じ)そして do は, 束縛を並べるだけで 多重ループ へ素直に伸びます.
pairs :: [(Int, Char)]
pairs = do
x <- [1, 2, 3] -- for x in [1,2,3]
c <- "ab" -- for c in "ab"
pure (x, c) -- 1 周ぶんの結果main :: IO ()
main = do
print pairs
-- [(1,'a'),(1,'b'),(2,'a'),(2,'b'),(3,'a'),(3,'b')]結果は全組み合わせ (直積) です. 第7章の内包表記 [ (x, c) | x <- [1, 2, 3], c <- "ab" ] と同じものであり, 内包表記とリストモナドの do は同じ計算の 2 つの書き方です.
bind でつなげると, 依存する 2 重ループが書けます. リストが <*> (固定の直積) と分かれるのはここです. 内側のループの範囲を, 外側の結果に応じて変えられます.
-- 内側の範囲が外側の結果 x に依存する 2 重ループ
trianglePairs :: [(Int, Int)]
trianglePairs = [1 .. 4] >>= \x -> [x .. 4] >>= \y -> pure (x, y)
-- do 版 (同じもの)
trianglePairs' :: [(Int, Int)]
trianglePairs' = do
x <- [1 .. 4]
y <- [x .. 4] -- 前の結果 x が, 次の候補集合を決める
pure (x, y)
main :: IO ()
main = do
print trianglePairs
-- [(1,1),(1,2),(1,3),(1,4),(2,2),(2,3),(2,4),(3,3),(3,4),(4,4)][x .. 4] の中に前の結果 x が現れています. これは「パターンと結果に応じた計算」のリスト版で, 次に試す候補集合そのものを前の結果から作っています. さらに, 途中で候補を捨てたければ Maybe の Nothing にあたる「候補なし」= 空リスト を返します.
-- 三平方の定理を満たす組だけを残す
pythagorean :: [(Int, Int, Int)]
pythagorean = do
x <- [1 .. 20]
y <- [x .. 20]
z <- [y .. 20]
if x * x + y * y == z * z then pure (x, y, z) else []
main :: IO ()
main = do
print pythagorean
-- [(3,4,5),(5,12,13),(6,8,10),(8,15,17),(9,12,15),(12,16,20)]失敗の伝播 (Maybe) も, 候補の分岐と絞り込み (リスト) も, IO の作用も, すべて同じ >>= と do の上に乗っています. 変わるのはパターンの中身だけです.
モナドと圏論の対応 (まとめ)
| Haskell | 記号 | 圏論での役割 |
|---|---|---|
関手 m (Functor) |
T | 圏 Hask 上の自己関手 |
return / pure |
\eta : \mathrm{Id} \Rightarrow T | 単位 (自然変換) |
join :: m (m a) -> m a |
\mu : T \circ T \Rightarrow T | 乗法 (自然変換) |
>>= (bind) |
— | join (fmap f m) |
>=> |
— | Kleisli 圏の射の合成 |
do |
— | >>= / >> の構文糖 |
Maybe・Either・リストで固めたこの「自己関手 + \eta + \mu」という骨格が, 次の部で扱う IO にもそのまま当てはまります.
入出力 (IO)
前の部で固めたモナドの骨格 (return と >>=, そして do) を手に, いよいよ 第1章・第2章・第4章 から先送りしてきた「IO とは何か」を回収します. 結論を先に言えば, IO は Maybe や Either と同じ仕組みで動く, モナドの一つです. 違うのは, 値に付いてくるパターンの中身だけです. Maybe のパターンが「成功か失敗か」の場合分けだったのに対し, IO のパターンは「アクションか, 素の値か」です. IO a の値は「実行すると外の世界に作用し, 最後に値 a を返すアクション」であり, 素の値 a とは型で区別されます.
IO モナド
第1章 で見たとおり, Haskell の関数は純粋です: 同じ入力には必ず同じ結果を返し, 画面表示のような 副作用 を起こしません. では, キーボードからの入力や画面への出力のような「外の世界とのやりとり」は, どう表せばよいのでしょうか.
Haskell の答えは「やりとりの手順そのものを, 値にしてしまう」です. 型 IO a の値は, 「実行されると外の世界に作用し, 最後に型 a の値を返す アクション (action)」を表します. アクションは, いわば実行前の手順書です.
putStrLn "hi" :: IO (): 実行するとhiを表示する手順書. 表示のほかに返す情報はないので, 返り値は「情報なし」を表す値()です.getLine :: IO String: 実行すると入力を 1 行読み, その文字列を返す手順書.
大事なのは, 手順書はただの値だということです. 名前を付けられ, 関数から返せ, そして 定義しただけでは何も起きません.
greetAction :: IO ()
greetAction = putStrLn "こんにちは" -- 定義しただけでは何も表示されない
main :: IO ()
main = do
greetAction -- main に組み込まれてはじめて実行される
greetAction -- 2 回組み込めば 2 回実行されるそして IO はモナドです. return :: a -> IO a は「外の世界に何もせず, ただ値 a を返す手順書」, (>>=) :: IO a -> (a -> IO b) -> IO b は「手順書を実行し, その結果を次の手順書に手渡す」連結です. モナドの部で見た骨格 (自己関手 + \eta + \mu) がそのまま当てはまり, パターンの中身だけが違います.
| モナド | パターンの中身 |
|---|---|
Maybe |
失敗したら以降を打ち切る |
Either e |
失敗の理由 e を運ぶ |
| リスト | すべての候補を試す (非決定性) |
IO |
外の世界への作用を順に実行する |
アクションと参照透過性
「getLine の結果は毎回違う. それなら純粋性が壊れているのではないか?」という疑問が湧きます. ここが IO の設計の要です.
式をその値で置き換えても意味が変わらない性質を 参照透過性 (referential transparency) といいます (第1章 の「同じ入力なら同じ出力」の言い換えです). getLine を実行した結果は毎回違いますが, getLine という式の値は「1 行読む」という手順書であって, いつどこに書いても同じです. 変わるのは実行の結果であって, 値ではありません. 実行を処理系に任せ, プログラムは手順書の組み立てに徹することで, 値の世界の参照透過性が守られています.
この設計の見返りが, 第1章 で予告した「副作用は型で区別する」です.
String -> String: 純粋. 外の世界に触れないことが型から分かります.String -> IO String: 外の世界に触れるかもしれないことが型から分かります.
シグネチャを見るだけで副作用の有無が判別でき, しかも 純粋な世界と IO の世界は一方通行です. 純粋関数は IO アクションの中から自由に使えますが, IO String から String を取り出す純粋な関数はありません (結果を使うには >>= で次のアクションへ渡すしかありません). 副作用は IO の中に閉じ込められ, 型システムがその境界を見張ります.
IO アクションの実行順は >>= (つまり do の行の並び) が決めます. 純粋な式はどこから評価しても結果が変わらないので順序を気にせずに済みましたが, 外の世界への作用は順序そのものが意味を持ちます. モナドの「連鎖」が, ここでは実行順の指定として効いているわけです.
標準入出力
基本の道具は 3 つです. getLine :: IO String (1 行読む), putStrLn :: String -> IO () (1 行書く), そして print. 実は print = putStrLn . show であり, これまで全章の実行例で使ってきた print は「第8章 の show で文字列にして表示する」IO アクションでした. つまり, みなさんは第4章からずっと IO モナドを使っていたのです.
名前を尋ねて挨拶するプログラムを書いてみます. ロジックは純粋関数に, IO は薄い殻に分けるのが Haskell の基本設計です.
-- 純粋コア: ロジックはただの関数
greet :: String -> String
greet name = "こんにちは, " ++ name ++ " さん!"
main :: IO ()
main = do
putStrLn "お名前は?"
name <- getLine -- アクションの実行結果を name に束縛
putStrLn (greet name) -- 純粋関数は IO の中で自由に使えるname <- getLine は, モナドの部の do とまったく同じ「>>= の糖衣」です. Maybe の do では「Just の中身」を取り出しましたが, ここでは「アクションの実行結果」を取り出します.
入力全体を一括で変換するなら interact :: (String -> String) -> IO () が便利です. 標準入力の全文を純粋関数に渡し, その返り値を標準出力に書き出します. 「純粋コア + IO の殻」という設計をそのまま 1 つの関数にしたような道具です.
import Data.Char (toUpper)
main :: IO ()
main = interact (map toUpper) -- 入力をすべて大文字にして出力する行ごとの処理は lines / unlines と組み合わせて interact (unlines . map f . lines) の形にします (第6章 の関数合成が, そのまま設計図になります).
Exercise CH10-4
2 つの入力を安全に足す: 純粋コアと IO の殻
標準入力から 2 行読み, それぞれを整数とみなして和を表示するプログラムを書いてください. ただし,
- 変換と足し算は純粋コア
addInputs :: String -> String -> Maybe Intに分離すること.readMaybe :: String -> Maybe Int(Text.Read) と Applicative の部の<$>/<*>を使うと 1 行で書けます. - どちらかが整数でなければ
数値を入力してくださいと表示すること (maybe(第9章) が使えます).
(入力) 3
(入力) 4
(出力) 7
回答例
import Text.Read (readMaybe)
addInputs :: String -> String -> Maybe Int
addInputs s t = (+) <$> readMaybe s <*> readMaybe t
main :: IO ()
main = do
s <- getLine
t <- getLine
putStrLn (maybe "数値を入力してください" show (addInputs s t))readMaybe が失敗を Maybe で返し, <$> / <*> が「両方成功したときだけ足す」を場合分けなしで実現します (Exercise CH10-1 と同じ形です). IO の殻は「読む・読む・表示する」の 3 行だけで, 判断はすべて純粋コア側にあります. 純粋コアは IO 抜きで ghci からテストできます. これが「純粋コア + IO の殻」設計の利点です.
main の構造と do 記法
最後に, 第2章 以来「おまじない」としてきた main = do ... の種明かしです.
Haskell のプログラムとは, つまるところ main :: IO () という 1 つのアクション (手順書) を組み立てるものです. 処理系はプログラムから main という名前の手順書を探し出し, それを実行します. ほかの関数や定義は, main から直接・間接に組み込まれた分だけ働きます.
そして main = do ... の do は, モナドの部の「do 記法」で見た >>= / >> の糖衣そのものです. 先ほどの挨拶プログラムを脱糖すると, こうなります.
greet :: String -> String
greet name = "こんにちは, " ++ name ++ " さん!"
-- do を >>= / >> に脱糖した形. do 版とまったく同じプログラム.
main :: IO ()
main =
putStrLn "お名前は?" >>
getLine >>= \name ->
putStrLn (greet name)第4章 からずっと書いてきた main = do ... は IO モナドの do であり, モナドの部の Maybe の do と同じ規則で動いていました. これで本章の伏線はすべて回収です. なお, do の中では「アクションの結果の束縛」と「純粋な計算の名前付け」を区別します.
greet :: String -> String
greet name = "こんにちは, " ++ name ++ " さん!"
main :: IO ()
main = do
name <- getLine -- <- : アクションを実行して結果を束縛する
let msg = greet name -- let : 純粋な計算に名前を付ける (実行するものはない)
putStrLn msg本章では, fmap の限界から出発して Applicative → Monad と道具を強め, do の正体を明かし, Maybe・Either で固めた骨格が IO にもそのまま当てはまることを見ました. 第11章 では, この道具立ての上で 可変状態 (第5章で予告した State や ST) を扱います.
Exercise CH10-5
interact で行ごとの変換
標準入力の各行を逆順にして出力するプログラムを書いてください. ただし,
- 変換は純粋コア
revLines :: String -> Stringに分離し,mainはinteract revLinesの 1 行にすること. lines/unlines/map/reverseを関数合成で組み合わせること (第6章).
(入力) abc
(入力) def
(出力) cba
(出力) fed
回答例
revLines :: String -> String
revLines = unlines . map reverse . lines
main :: IO ()
main = interact revLineslines で行のリストに分け, map reverse で各行を逆順にし, unlines で 1 つの文字列に戻します. 純粋コア revLines は普通の関数なので, ghci で revLines "abc\ndef\n" のように IO 抜きで試せます. main は殻の 1 行だけです.