Haskell 入門


IO モナドとの付き合い方

IOモナドの不可解な振る舞い

Haskell でとにかく評判が悪いのが IO モナドだ。Haskell では副作用のある入出力プログラムは IO モナドで記述しなければならない。具体的には getLine, putStrLn などの入出力プログラムは do 記法の中に記述しなければならない。ところが、do 記法の中に普通の手続き型のプログラムに似たものが書けるが、ときに、わけのわからないエラーが出て、その原因が全く分からないという事態が起きてしまう。

例えば、次のプログラムの do 記法の中身は手続き型のプログラムのものとよく似ている。

ファイル名: example1.hs
main = do
  cs <- getLine
  putStrLn cs

これは次のようにきちんと動く。

Prelude> :e example1.hs
Prelude> :l example1.hs
[1 of 1] Compiling Main ( example1.hs, interpreted )
Ok, modules loaded: Main.
*Main> main
hello, world
hello, world

ところが、手続き型のプログラムとのアナロジーで次のようなプログラムを作ると、エラーが出てしまう。しかし、何でエラーになるのか見当もつかない。

ファイル名: example2.hs

main = do
  name <- getLine
  greeting <- "hello, " ++ name
  putStrLn greeting

実は、次のようなプログラムにするのが正解で、これはコンパイルできて実行できる。

ファイル名: example3.hs

main = do
  name <- getLine
  greeting <- return ("hello, " ++ name)
  putStrLn greeting

実行例

Prelude> :l example3.hs
[1 of 1] Compiling Main ( example3.hs, interpreted )
Ok, modules loaded: Main.
*Main> main
world
hello, world

要は "hello, " ++ name の値を return 関数の引数にするだけだ。しかし、何で、return 関数の引数にしないといけないのかの理由が全くわからない。

上の例で見てきたように、IO モナドをプログラムを手続き型のプログラムからの類推で組もうとすると、どうしても意味不明のエラーに突き当たって、IO モナドは難しい、Haskell は使えないという結論になりがちだ。

しかし、これは、IO モナドのプログラムを、手続き型のプログラムの類推で組もうとするから起こる困難で、一旦手続き型のプログラムのことは忘れて、本来のIO モナドのプログラム規則に従ってプログラムを作成してみると、IO モナドのプログラムの仕組みが非常に単純で分かりやすいことが実感できる。

IO モナドのプログラミングの仕組み

用語の定義

IO モナドについての解説は多いが、なかなかわかりにくい。その原因の一つは、IO モナド関連の用語の定義が明確でないということだ。とくにアクションという重要な用語の意味が解説ごとに異なっているような気がする。そこで、最初に用語を次のように定義する。

IO モナド値
IO 型の値のことだ。形式は普通のデータ型と変わりない。IO "hello" や IO 3.14 や IO [1,2,3] などがそうだ。
IO モナド型関数( Kleisli 射 )
引数が一つで戻値が IO モナド値の関数のこと。bar :: a -> IO a 型の関数 bar のことだ。
return 関数
引数を一つとり、モナド値にラッピングして返す関数。 return "foo" の戻値は IO "foo" になる。
>>= 演算子( bind 演算子 )
左項のモナド値からコンテナの値をとりだして、右項のモナド型関数の引数として渡す演算子。たとえば return "hello" >>= putStrLn を実行すると、return "hello" の値は IO "hello" だ。このデータを >>= に注ぎ込んでやると IO "hello" からコンテナの "hello" が取り出され、putStrLn に渡され、putStrLn "hello" が実行される。

Prelude> return "hello" >>= putStrLn hello

以下の説明は上の用語の定義に基づいておこなう。IO モナドの解説記事だが、モナドとは何かということには一切触れない。モナドの概念を知らなくても IO モナドのプログラムは完璧に書けるからだ。

do 記法を用いない IO モナドのプログラム

do 記法を用いないで IO モナドのプログラムをするためには、3つのルールを知っていればよい。たった3つのルールで IO モナドのプログラミングが完全にできるのをみても、IO モナドの仕組みが単純なものであることが分かる。

>>= 演算子

do 記法を用いない IO モナドのプログラムを理解するためには、IO モナドのプログラムのイメージとして、>>= (IO モナド型関数) というパイプラインの中を IO a という IO 型のデータが次々に流れていくところを想像するといい。

パイプラインのパイプを、>>= ではなく、>>= (IO モナド型関数) としたのは理由がある。>>= と IO モナド型関数の組み合わせに、左側から IO a 型のデータが注ぎ込むと右側から IO b 型のデータが出てくるからだ。

たとえば次のプログラムは実際に実行できる Haskell のプログラムだが、上のイメージに沿っている。

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

詳しくデータの流れを見てみよう。最初に return "hello " で IO モナド型データが作られる。次にそれは >>= \x -> return (x ++ "world") というパイプラインのユニットに注ぎ込まれ IO "hello world" という IO モナド型のデータになる。さらに IO "hello world" は >>= putStrLn というパイプラインユニットに注ぎ込まれ、端末に hello world が表示される。

このように IO モナドのプログラムとは >>= (IO モナド型関数)というパイプラインユニットの中を IO a という IO モナド値が色々な形に処理されながら流れていくところとイメージできる。これは Linux のストリームがパイプラインを流れていくのと同じようなイメージだ。 このパイプラインの様子を別の方向から眺めてみよう。先程のプログラムを再掲する。

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

これを見ると、>>= の間、あるいは、>>= の右側は必ず IO モナド型関数であることが分かる。これは >>= (IO モナド型関数) というパイプラインユニットから考えると当然のことだ。

したがって、このような >>= の連鎖の間に現れるのは IO モナド型の関数以外にはない。そこで IO モナドのプログラムのルール1はつぎのようになる。

ルール1: IOモナドのプログラムは、IOモナド型の値を >>= 演算子でIOモナド型の関数に与えるという操作の連鎖である。

IO モナドのプログラムは、上のルール1で完全に規定できる。たとえば、次のようなプログラムは、IOモナドのプログラムだし、実際に稼働する。

return "hello, world" >>= putStrLn

>>演算子

IO モナドのプログラムは、IO モナド型の関数を >>= 演算子でつなげたものだ。しかし、>>= 演算子でつなぐとエラーが出る場合がある。次の例は putStrLn "hello" と putStrLn "world" を >>= 演算子でつないでいるが、ghci で実行するとエラーになる。

Prelude> putStrLn "hello" >>= putStrLn "world"

これは、putStrLn 関数の戻値が IO () なので、左辺から右辺への値の受け渡しが無いからだ。このような場合は >>= 演算子の代わりに、>> 演算子を使うとうまくいく。

Prelude> putStrLn "hello" >> putStrLn "world"
hello
world

したがって、IO モナドのルールその2は次のようになる。

ルール2: >>= 演算子の左辺からの戻値が無いときは >> 演算子を使う。

また、>> 演算子の定義は

x >> y = x >>= const y

であるから、>>= 演算子の左辺からの戻値を無視する場合にも >> 演算子を使うことができる。次の例では、getLine で入力された文字列は無視される。

Prelude> getLine >> putStrLn "hello"
world
hello

このように、>> 演算子は、演算子の左辺からの戻値がない場合、あるいは、左辺からの戻値を意識的に無視する場合に使われる、>>= 演算子のバリエーションだ。

変数の使いかた

IO モナドのプログラミングを行う上でのルールの最後は、IO モナドにどのようにして変数を導入するかだ。関数型プログラミング言語といっても、変数を全く使えないのは不便だ。IO モナドでも変数を使えるようにするにはどうすればいいのだろうか。

例えば次の do 記法のプログラムでは、変数 cs に getLine の戻値を代入できる。そうして、その変数の値をputStrLn で利用することができる。こういう処理ができないと書けないプログラムは多い。

main = do cs <- getLine putStrLn cs

IO モナド型関数を >>= 演算子でつないでいくというプログラムに、この変数への代入を導入するにはどうすればいいのだろうか。それは、>>= 演算子の右辺の関数を、ラムダ記法で記述すればいいのだ。この考え方を使うと、上のプログラムは次のように書くことができる。

getLine >>= (\cs -> putStrLn cs)

2個以上の変数を利用するときは、関数のカッコを入れ子にすることで対応できる。次の実行例では、as に代入した文字列と bs に代入した文字列をつなげて表示できる。

Prelude> getLine >>= (\as -> (getLine >>= (\bs -> putStrLn (as ++ bs))))
hello,
world
hello, world

ちょっと Scheme 風のプログラムになってしまったが、確かに変数 as と bs に文字列を代入して利用することができることが分かる。

したがって、IO モナドをプログラムするときの3番目のルールは次のようになる。

ルール3: 変数を使いたいときは >>= 演算子の右辺の式をラムダ記法で記述する。

まとめ

もういちど、今までに述べたIOモナドのルールをまとめて見てみよう。

ルール1: IOモナドのプログラムは、IOモナド型の関数を >>= 演算子で結合した、IOモナド型の関数の連鎖である。IOモナド型の関数とは f : a -> IO b のように引数が1個で戻り値が IO b 型の関数である。

ルール2: >>= 演算子の左辺からの戻値が無いときは >> 演算子を使う。

ルール3: 変数を使いたいときは >>= 演算子の右辺の式をラムダ記法で記述する。

最も基本的なルールはルール1で、ルール2とルール3はルール1のバリエーションでしかない。何れにしてもこの3つの簡単なルールに従うだけでエラーのない IO モナドプログラムが簡単に作れるようになる。

もちろん、長いプログラムを書くのは do 記法でないと見通しが悪いが、3つのルールで IO モナドのプログラムを作り慣れたあとは、do 記法で現れたエラーにどういう意味があるのか、どう対策すればエラーを無くすことができるかが簡単にわかるようになる。

IO モナドは決して Haskell の鬼門などではない。本来の方法でプログラムすれば、非常に簡潔なルールで純粋関数から隔離された、頑強な入出力のプログラムが書けることが分かる。

do 記法のプログラム

do 記法のプログラムは上に述べた IO モナドプログラムのシンタックスシュガーだ。>> 演算子や >>= 演算子は省略され、IO モナド型関数を上から順に並べたものになる。しかし、上で述べたように各行の関数が IO モナド型の関数でない場合はエラーになる。冒頭で述べた意味不明のコンパイルエラーが起きる場合、記述した関数が IO モナド型の関数かどうかを検討する必要がある。

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

do ブロックの各行の関数が IO モナド型関数でないといけないことが分かれば、コンパイルエラーが起きたときもバグの位置の特定ができる。結局のところ IO モナドを完全に活用するのに、世界の状態も、圏論も、副作用の論議も何もいらなかった。