モナド

このページでは,Haskell を理解するうえでカギとなる概念である,モナドについて説明します.

モナドの役割

モナド (monad) は,入出力などの副作用のある処理を Haskell でうまく実現するために導入された概念です. 遅延評価型の純粋関数型言語である Haskell において,モナド(とりわけ IO モナド)は次の 2 つを実現する役割をもちます.

モナドの必要性を理解するために,モナドのない世界を想像してみましょう. 仮に,標準入力を 1 行読み込む関数 getLine の型が(IO String ではなく)ただの String だったとします. すると,次のようなプログラムが書けてしまいます.

str1 = getLine
str2 = getLine
str = str1 ++ str2
main = putStrLn str

このプログラムはいろいろと問題があります. 第一に,ソースコード 1 行目と 2 行目の getLine が違う文字列を返す可能性があり,参照透過性がありません. 第二に,ソースコード 1 行目と 2 行目のどちらの getLine が先に実行されるかが不定です. Haskell はこのあたりの問題を IO モナドによってうまく解決しています.

圏論との関係

ところで,モナドは数学の一分野である圏論に由来する概念です. そのため,圏論の言葉を使えばモナドはきれいに説明できます. Haskell の開発者の一人である Philip Wadler のこんな言葉が知られています.

「モナドは単なる自己関手の圏におけるモノイド対象だよ.何か問題でも?」

――James Iry(青木靖 訳)「不完全にしておよそ正しくないプログラミング言語小史」より

ちなみに筆者は「自己関手」とか「モノイド対象」とか言われて頭の中が「???」なので,圏論の勉強してみようかなと思っている今日この頃です.

さて,モナドは圏論に由来する概念ではありますが,Haskell のモナドを使う分には圏論の知識は必要ありません. Haskell におけるモナドは,単なる Haskell の型クラスのひとつ (あるいはそのインスタンスたち)です. そのため,型クラスを利用した Haskell プログラムを理解できる人なら,モナドも理解できると思います.

*     *     *

ここから先は,モナドを自然に理解できるように,まず,モナドと近い概念である「ファンクタ」という型クラスを紹介します. その次に,モナドを紹介します. それから,モナドの例として Maybe モナドを紹介し,モナドのための構文糖である do 記法を説明します.

ファンクタ

モナドを自然に理解できるように,モナドと近い概念である「ファンクタ」という型クラスを紹介します.

ファンクタも圏論で定義される概念のひとつです.しばしば「関手」と訳されます.

リストの要素を操作する関数 map を一般化した fmap という関数があります. ファンクタ (functor) はこの fmap をメンバとする型クラスであり,次のように定義されています.

class Functor t where
  fmap :: (a -> b) -> t a -> t b

Functor クラスは,値を入れるコンテナみたいな構造をもつ型を表します. 別の言い方をすれば,なんらかの型をラッピングした(包み込んだ)型を表します. インスタンスには,Maybe[] (リスト),IO などがあります.

関数 fmap は,ラッピングされた値に対する操作を可能にする関数です. いくつか使用例を見てみましょう.

f x = 2 * x

main = do print $ fmap f (Just 5)   -- 出力: Just 10
          print $ fmap f Nothing    -- 出力: Nothing
          print $ fmap f [1, 2, 3]  -- 出力: [2, 4, 6]
          print $ fmap f []         -- 出力: []

Maybe および [] のインスタンス宣言は,次のようになっています.

instance Functor Maybe where
  -- fmap :: (a -> b) -> Maybe a -> Maybe b
  fmap f Nothing  = Nothing
  fmap f (Just x) = Just (f x)

instance Functor [] where
  -- fmap :: (a -> b) -> [a] -> [b]
  fmap = map

Functor クラスのインスタンスはあくまで Maybe[] であって,Maybe a[a] ではないことに注意します. 一般に,Functor クラスのインスタンスは,具体型を 1 つとる型構成子です.

数学的には,ファンクタは次の 2 つの規則(ファンクタ則)を満たすことが要求されます.

  • 恒等射の保存: fmap id = id
  • 合成の保存: fmap (f . g) = fmap f . fmap g

実際,Maybe[] も両者を満たしています(簡単に証明できます). ただし,これらの性質が満たされているかどうかについて,コンパイラによるチェックは行われません.

関数 f の型が f :: a -> b のとき,fmap f の型は fmap f :: t a -> t b です. この関係を図にすると,次のようになります.

関数 fmap によって,普通の関数 f が,(Maybe[] で)ラッピングされた値に作用する関数 fmap f に変換されている様子がよくわかります. こうした変換のことを,関数の持ち上げ (lifting) ということがあります.

モナドの定義

モナドもファンクタと同じく,なんらかの型をラッピングした型を表す型クラスです. ファンクタで見たさっきの図の矢印の向きをちょこっと変えると,モナドの説明のできあがりです.

この図の m がモナドのインスタンス,演算 $(\cdot)^*$ がモナド版の持ち上げ演算です. なお, $f^*$ というのは数学上の表記であり,実際の Haskell プログラムではこれを (>>= f) と書くことになります.

ちなみに,演算 $(\cdot)^*$ は クライスリスター (Kleisli star) と呼ばれます.

モナドの型クラスは次のように定義され,基本的な演算として >>=return を持ちます.

class Monad m where
  (>>=) :: m a -> (a -> m b) -> m b
  return :: a -> m a

  (>>) :: m a -> m b -> m b
  m >> n = m >>= \_ ->  n

  fail :: String -> m a
  fail = error

演算 >>=バインド (bind) 演算と呼ばれる左結合演算子で,Functor クラスの fmap と同様に関数を持ち上げます. 演算 return は,普通の値をラッピングされた値にする関数です(C/C++ とかの return 文とは関係ありません). その他の演算 >>fail はあまり重要ではありません.

演算 >>=return の関係を図にすると,次のようになります.

演算 >>=return は,例えば次のように使われます.

f x = return (2 * x)

main = do print $ Just 5 >>= f     -- 出力: Just 10
          print $ Nothing >>= f    -- 出力: Nothing
          print $ [1, 2, 3] >>= f  -- 出力: [2, 4, 6]
          print $ [] >>= f         -- 出力: []

ファンクタを理解できていれば,モナドも自然に理解できますね. (少なくとも Maybe モナドみたいな簡単なモナドなら.)

Haskell で定義されている代表的なモナドには,次のようなものがあります.

ご存じのように,この中の IO モナドが,現実世界とのやりとり(入出力処理)に使われるモナドです. 入出力の値を IO モナドというベールで隠蔽してしまうことにより,冒頭で書いた「(参照透過性はそのままで)副作用のある処理を行う」という側面を実現しています.

ファンクタのときと同じく,数学的には,モナドは次の 3 つの規則(モナド則)を満たさなければなりません.

  • 左単位元律: return x >>= f = f x
  • 右単位元律: m >>= return = m
  • 結合律: (m >>= f) >>= g = m >>= (\x -> f x >>= g)

Maybe モナド

定義が簡単な Maybe モナドを例にとって,バインド演算の動きを追ってみます.

Maybe モナドのインスタンス宣言は次のように書かれます.

instance Monad Maybe where
  Nothing >>= f = Nothing
  Just x  >>= f = f x
  return x = Just x
  fail _ = Nothing

Maybe モナドのバインド演算は,失敗しうる計算を連鎖させて,失敗しうる大きな計算を作ります. 次のプログラムはその好例です.

-- x が 2 で割り切れない場合には失敗する
div2 :: Int -> Maybe Int
div2 x = if even x then Just (x `div` 2)
                   else Nothing

-- x が 8 で割り切れない場合には失敗する
div8 :: Int -> Maybe Int
div8 x = return x >>= div2 >>= div2 >>= div2

main = do print $ div8 32  -- 出力: Just 4
          print $ div8 50  -- 出力: Nothing

バインド演算の定義を展開して書くと,関数 div8 は次のように書けます.

div8 :: Int -> Maybe Int
div8 x =
  case return x of
    Nothing -> Nothing
    Just y ->
      case div2 y of
        Nothing -> Nothing
        Just z ->
          case div2 z of
            Nothing -> Nothing
            Just w -> div2 w

これを見て一目瞭然なように,バインド演算は左から右へ順番に式を評価する性質をもちます. IO モナドでも同じ理屈で,「(遅延評価はそのままで)処理を逐次的に実行する」という仕組みが実現されています.

do 記法

モナドのバインド演算の構文糖として,do 式(do 記法)が利用できます. この do 式の存在が,モナドを Haskell において特別な存在にしています.

do { 1 ; ...; n }

do 式の中に書ける式は,次の 3 通りです.

(a) モナド式  (すなわち,Monad m => m a 型の式)
(b) パターン <- モナド式
© let { 宣言1 ; ... ; 宣言n }

式 (b) は,右辺が m a 型であれば,a 型の値を左辺のパターンに束縛します. 式 © は,普通の変数や補助関数を宣言します. 式 (b),© で宣言された名前は,その do 式の中だけで有効です.

do 式の中の最後の式は (a) でなければなりません. この式の値が,do 式全体の値となります.

先の div8 関数は,do 式を用いると次のように書けます.

div8 :: Int -> Maybe Int
div8 x = do y <- div2 x
            z <- div2 y
            div2 z

すこし別の例を見てみましょう. 次のプログラムは,入力された文字列を 1 行ごとに大文字に変換して出力するものです. 空行が入力された時に終了します.

import Data.Char

main :: IO ()
main = do s <- getLine
          let t = map toUpper s
          if null s then return ()
                    else do { putStrLn t ; main }

このプログラムの do 記法を脱糖してみると,次のようになります. (言語仕様で定義されている展開とは少し異なります.)

import Data.Char

main :: IO ()
main =
  getLine >>= (\s ->
    let t = map toUpper s in
    if null s then return ()
              else putStrLn t >> main)

実行例は次のようになります($ はプロンプト,太字は入力した文字列).

$ runghc Sample.hs
hello
HELLO
hello, Haskell
HELLO, HASKELL

$