モナド

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

モナドの役割

モナド (monad) は、入出力などの副作用のある処理を Haskell でうまく実現するために導入された概念です。 遅延評価型の純粋関数型言語である Haskell において、モナドという概念は次の 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 がモナドのインスタンス、演算 (-)* がモナド版の持ち上げ演算です。 なお、f* というのは数学上の表記であり、実際の Haskell プログラムではこれを (>>= f) と書くことになります。

ちなみに、演算 (-)*クライスリスター (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)

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

$ <b>runghc Sample.hs</b>
<b>hello</b>
HELLO
<b>hello, Haskell</b>
HELLO, HASKELL

$