人気ブログランキング | 話題のタグを見る

IOモナドのすべて

Haskell を習い始めた頃、IO関係のプログラムを書こうとして、IOモナドにひどい目にあわされた。do 記法で普通にプログラムを書いていたつもりなのにコンパイルエラーになってしまって、エラーメッセージを読んでもなんのことかさっぱりわからなかった。完全にお手上げだった。

IOモナドで Haskell 嫌いになった人は多いと思うので、ここで、IOモナドをエラーを出さずに使うための要点をまとめてみたい。IOモナドを料理するためのコツのようなものだ。

1. >>= 演算子

まず IO モナドとは何かということについて詮索しないことを勧める。IOモナドとは何かということを理解するのは滅茶苦茶難しい。いろいろなアナロジーがあるがそれを読んでも一層混乱する。

しかし、IOモナドとはなにかということを一旦置いておいて、IOモナドでプログラムするにはどうすればよいかと考えると、これは非常に簡単だ。IOモナドのプログラムとは IO a 型のデータと f :: a -> IO b 型の関数を >>= 演算子で繋いだものだからだ。

IO a 型のデータと f :: a -> IO b 型のデータを次のように >>= で結合するとどういうことが起きるだろうか。

IO a >>= (f :: a -> IO b)

上の式ではまず、 IO a の a が取り出される。それは f の引数として与えられるから、f a が計算され、最終的に IO b がこの式の値となる。図式で書くと次のようになる。

IO a --> a ---> f a ---> IO b

IO モナドのプログラムはこれで全てだ。たったこれだけ。do 記法はどこへ行った、副作用の隔離はどうするんだということもあるかもしれないが、それも含めてたったこれだけなのだ。逆に言えばこれだけを抑えておけば、do 記法で被害にあったあの不可思議なエラーをすべて回避できる。

抽象的な話ではわかりにくいので具体的な例を上げてみる。たとえば getLine 関数の型を見ると次のようになる。getLine が実行されると端末から入力された文字列が IO コンストラクタでラッピングされ、 IO String 型のデータが戻ってくる。

Prelude> :t getLine
getLine :: IO String

また、putStrLn の型は次のようになる。

Prelude> :t putStrLn
putStrLn :: String -> IO ()

putStrLn は String 型の引数をとり IO () 型のデータを返す関数だ。したがって getLine と putStrLn は次のように >>= 演算子で結合することができるはずだ。

getLine >>= putStrLn

上の式でどういうことが起きるのか考えてみよう。まず getLine が実行されると端末から入力した "hello" のような文字列が IO "hello" になって戻ってくる。

これは >>= 演算子によって putStrLn 関数に結合されているから、IO "hello" から "hello" が取り出されて putStrLn 関数に渡される。つまり putStrLn "hello" が実行されることになる。

putStrLn 関数は引数の文字列を端末に表示して、IO () を返すので、上のプログラムは端末から文字列を取得し、それを端末に表示するというプログラムになることが分かる。

実際に ghci でやってみると次のようになる。

Prelude> getLine >>= putStrLn
hello, world
hello, world

もっとも、一般的な Haskell のプログラムでは、IO モナドのプログラミングは do 記法を使って行われる。しかし、do 記法のプログラムは >>= を使ったプログラムのシンタックスシュガーでしかない。ちなみに上のプログラムを do 記法で記述すると次のようになる。

Prelude> do {cs <- getLine; putStrLn cs}
hello, world
hello, world

>>= 記法のプログラムでは getLine の出力は >>= 演算子で putStrLn 関数に直結できたが、do 記法の場合は <- 記法を使って一旦変数 cs にバインドしてから cs を putStrLn の引数として渡しているが、やっていることは同じだ。

2. return 関数

上のプログラムでは getLine はうまい具合に IO a 型の値を返してくれたが、自前のデータを IO a 型にラッピングするにはどうしたらいいだろうか。

普通に考えつくのは IO コンストラクタを利用して IO "hello" のようにすることだが、これは使えない。 IO コンストラクタを直接使って IO a 型のデータを作ることは禁止されているからだ。

そのかわり、return 関数を利用すると、簡単に IO a 型のデータを作ることができる。 return "hello" の戻値は IO "hello" になるからだ。

したがって、getLine >>= putStrLn の代わりに return "hello" >>= putStrLn とすると、端末に hello と出力される。

Prelude> return "hello" >>= putStrLn
hello

このプログラムを do 記法で記述して実行したものが次の例だ。

Prelude> do {cs <- return "hello"; putStrLn cs}
hello

この場合も return "hello" と putStrLn は直結されず、一旦データを cs にバインドする形になっている。このプログラムの書き方は、手続き型のプログラムとよく似ているが、"hello" の頭に return が付いているのが異なっている。

冒頭に述べた do 記法の不可思議なエラーの場合も、エラーを出したプログラムに return が抜けていたという場合が多い。手続き型のアナロジーではなぜ return が必要なのかが納得できないからだ。しかし、do 記法はあくまでも >>= を使った直結型のプログラムのシンタックスシュガーであることを知っていれば、return の意味をはっきりと分かって使うことができる。

このように return 関数が生のデータを IO a 型にラッピングする関数だということがわかってくれば、次のように次々に >>= を介してデータをパイプラインに流し込むプログラムが書けるようになる。

Prelude> return "hello" >>= \x -> return (x ++ ", world") >>= putStrLn
hello, world

do 記法で書くと次のようになる。

Prelude> do {cs <- return "hello"; st <- return (cs ++ ", world"); putStrLn st}
hello, world

<- によるバインドの直前に return 関数が必要なのも今なら良く分かる。return を除けば見慣れた手続き型のプログラムだ。

3. >> 演算子

>>= は左項の IO a 型のデータを取り出して、右項の f :: a -> IO b 型の関数に渡す働きをしていたが、putStrLn "hello" の場合のように戻値が IO () になって、>>= の左項から右項への値の受け渡しがない場合がある。この場合は >>= 演算子を使うとエラーになるので、>> 演算子を使う。次のプログラムがそのような例だ。

Prelude> putStrLn "foo" >> putStrLn "bar"
foo
bar

しかし、do 記法で記述するときは >>= を使うか、>> を使うかは余り気にしないで済む。(むしろ、>>= を do 記法に翻訳する場合は name <- getLine のように、<- 記法との組み合わせになる。)

Prelude> do {putStrLn "foo"; putStrLn "bar"}
foo
bar

実は、>> 演算子は >>= の左項からのデータをワイルドカード _ で受けて読み捨てるのと同じ働きをしている。したがって、上のプログラムは次のような書き方でも動作する。

Prelude> putStrLn "foo" >>= \_ -> putStrLn "bar"
foo
bar

したがって、>> 演算子は、左項からのデータを意識的に利用しない場合にも使える。次のプログラムは端末の入力からのデータを使わない。

Prelude> getLine >> putStrLn "It's a nice day"
hello
It's a nice day

4. 変数とラムダ記法

手続き型のプログラムでは、

str1 = "hello, "
str2 = "world"
print str1 + str2

のように変数を利用した情報の加工が普通に見られる。今までに説明した IO a と f :: a -> IO b を >>= 演算子で結合するという方法では変数はどのように表現したら良いのだろうか。この場合はラムダ記法を利用する。

変数を使うやり方は do 記法のほうが自然に記述できるので、ます、do 記法でプログラムを作ってみる。

Prelude> do {str1 <- return "hello, "; str2 <- return "world"; putStrLn (str1 ++ str2)}
hello, world

>>= を使うやり方は次のように変数をラムダ記法で表記する。

Prelude> return "hello, " >>= \str1 -> (return "world" >>= \str2 -> (putStrLn (str1 ++ str2)))
hello, world

>>= を使うやり方でもプログラムを作れることは分かったが、do 記法のほうがはるかに分かりやすい。

結局のところ、IOモナドに関係するプログラムを記述するのは do 記法のほうが便利だ。

しかし、今まで書いてきたのは、do 記法で記述する時どうすれば >>= 演算子を使うプログラムとの対応をつけることができるかということを説明するのが目的だ。do 記法で記述したプログラムが意味不明のエラーを吐くときに、そのプログラムを >>= 演算子を使うプログラムに直せるかどうかを考えることで、謎のエラーの原因を発見することができるからだ。

これから後の記事はIOモナドのプログラムを do 記法で記述した時のコツについて述べる。

5. let 構文

IOモナドのプログラムの本質は IO a 型と f :: a -> IO b 型の関数を >>= 演算子で結びつけることだった。したがって return a >>= f >>= g >>= h のように関数が連鎖的に >>= で結合される場合も関数 f, g, h はすべて a -> IO b 型の関数でなくてはならない。その型以外の関数は >>= で連結できないからだ。

これは do 記法の場合も同じで、次のプログラムの各行の関数はすべて a -> IO b 型の関数だ。

Prelude> :set +m
Prelude> do
Prelude| name <- getLine
Prelude| greeting <- getLine
Prelude| putStrLn (greeting ++ ", " ++ name)
Prelude|
Dolly
Hello
Hello, Dolly

一見、普通の手続き型のプログラムに見えるが、getLine も putStrLn も a -> IO b 型の IO モナド型の関数だ。getLine は引数は取らないが、IO () 型の値を返す。

しかし、これは少し制限がきつすぎる。そこで IO モナドでは let キーワードの後のブロックでは、一般の関数を定義することができるようになっている。つまり、そのブロック内だけは、Haskell の純粋関数を扱うことができるようになっている。

Prelude> do
Prelude| name <- getLine
Prelude| let
Prelude|   greeting = "Hello, "
Prelude| putStrLn (greeting ++ name)
Prelude|
Dolly
Hello, Dolly

6. IOモナド外で定義した関数を使う

IOモナドのプログラムでは IO a >>= (f :: a -> IO b) のように、>>= 演算子で IO a 型の値のコンテナから a を取り出して f :: a -> IO b 型の関数に引数として渡すということをする。この時 a に f を適用した f a の値は IO b なので、これを更に >>= で g :: b -> IO c という関数に渡すことができる。

すなわち、IO モナドのプログラムでは、IO a の値を入り口にして f :: a -> IO b 型の関数を次のように次々に >>= 演算子で継いで行くことができる。

IO a >>= (f :: a -> IO b) >>= (g :: b -> IO c) >>= ...

この時に f や g 等の関数は必ず 「引数が1個で、戻値が IO a 型」 の関数でなければならない。どんな関数でも使えるわけではないのだ。そこで、IO モナド外で定義された関数を使う時は、IO a 型の値を返すように工夫する必要がある。と言っても、最終的な値を retrun 関数に渡すだけだ。これは関数の合成を使って次のように記述することができる。

Prelude> return [[1,2],[3,4]] >>= return . concat >>= print
[1,2,3,4]

また、ラムダ記法を使ってつぎのようにすることもできる。

Prelude> return [[1,2],[3,4]] >>= \x -> (return $ concat x) >>= print
[1,2,3,4]

do 記法では次のようになる。

Prelude> do { x <- return [[1,2],[3,4]]; y <- return $ concat x; print y}
[1,2,3,4]

do 記法であってもセミコロンの間に置くことができるのは a -> IO b 型の関数だけだ。これが、手続き型のプログラムの類推で do 記法のプログラムを書いた時の意味不明のエラーが発生する原因だ。

do 記法で書かれていても、IO モナドのプログラムはあくまで >>= 演算子で a -> IO b のIOモナド型の関数をつないでいるのと本質的な差はない。つまり、IO モナド型以外の型の関数は使えないのだ。

7. IO モナドで再帰関数を使う。

Haskell ではループを記述するのに再帰関数を使う。例えば階乗の定義は次のようになる。

Prelude> let
Prelude| fact 0 = 1
Prelude| fact n = n * fact (n-1)
Prelude|
Prelude> fact 5
120

このような再帰関数が IO モナドの中でも使えないと不便だ。しかし、IO モナドの中で再帰関数を使うには少々工夫がいる。次のプログラムは上の階乗のプログラムを IO モナドで記述している。

Prelude> let
Prelude| factio 0 = return 1
Prelude| factio n = do rest <- factio (n-1); return (n * rest)

このプログラムと上の fact 関数との一番の違いは fact n = n * fact (n-1) と直接的な再帰的定義を行わず、do rest <- fact (n-1); return (n * rest) のように do 記法を使って、一旦 fact (n-1) の値を rest にバインドしてから、その値を利用して n * rest を return 関数で返しているところだ。

IO モナドの関数の戻値は必ず IO b 型になっているので、b を取り出すためには rest <- factio (n-1) のように <- 記法で IO b の中の裸の b をとりださなければならない。また、戻値も return 関数で IO b 型にラッピングする必要があるからだ。

factio は次のように使うことができる。

Prelude> do
Prelude| val <- factio 5
Prelude| print val
Prelude|
120

このような利用法なら let キーワードを使って fact を定義して使ったほうが楽だが、次のような場合は上で述べた工夫が入出力を処理するプログラムに役立てる事ができる。次のプログラムは、端末から文字列を入力していって、空行を入力した時、今まで入力した文字列をリストにして返すプログラムだ。

Prelude> let
Prelude| getLines = do
Prelude|   line <- getLine
Prelude|   if line == ""
Prelude|     then return []
Prelude|     else do {rest <- getLines; return (line : rest)}
Prelude|
Prelude> getLines
hello
world

["hello","world"]

getLines の戻値の IO a 型から do 記法を利用してコンテナの値を取り出すという工夫は factio のものと全く同じ発想だ。

以上が IO モナドをエラーメッセージに悩まされずに記述するための工夫の全てだ。Haskell 初心者の段階でも今まで述べてきたことを利用すれば、IO関連のプログラムを楽々と書けるようになるだろう。そうすれば、Haskell なんてアルゴリズムの研究以外には使えないなどとぼやく必要がなくなると思う。

この記事では、副作用の隔離などの理論的な話は一切しなかった。IOモナドでプログラムを書けさえすれば、自然にIO関係の副作用は隔離されてしまうので、それについてことさら言及する必要はないと思ったからだ。IOモナドのプログラミングはちょっと癖があるが、その本質さえ掴んでしまえばあとはそう悩むこともなく活用できる。理論的なことよりまず使えるようになることが大切だ。

IOモナドの壁を超えさえすれば、その先には今までのプログラミングでは見たこともない Haskell プログラミングの世界が見えてくるだろう。
by tnomura9 | 2013-10-03 19:49 | Haskell | Comments(0)
<< IO モナドのちょっと数学的な... モナド >>