あどけない話

インターネットに関する技術的な話など

Haskellと副作用

よく、Haskellには副作用がないと言われるが、それは間違いだ。確かに、Haskell には状態の変化(あるいは再代入)という副作用はない。しかし、入出力という副作用はある。この記事では、Haskell の副作用に対して、命令型プログラマーにすっきりと理解できる説明を試みたいと思う。

間違った方向への第一歩

Haskell の副作用に関する典型的な説明は、こんな感じだ。

Haskell にはあらゆるレベルで副作用がない。そのため、遅延評価が可能になる。遅延評価では、コードが記述順に実行/評価されるとは限らないので、入出力と相性が悪い。そこで、IO モナドが導入されている。IO モナドのおかげで、入出力に関するコードは記述順に実行され、外界に作用できる。

この説明を聞いて理解しろという方が無理である。説明が苦しい最大の理由は、Haskell にはあらゆるレベルで副作用がないと、間違った一歩を踏み出したことだ。

正しい方向への第一歩

最初の一歩を正しい方向へ踏み出そう。

そう、Haskell には入出力という意味での副作用がある。その型は IO である。

ある関数の型に IO が含まれていれば、その関数には入出力という副作用がある。逆に、IO という型がまったくなければ、その関数は純粋である。

Haskell では、IO から純粋な関数を呼べるが、その逆はできない。一旦、汚れてしまったら、純粋に戻るすべはない。この強い制約のおかげで、入出力という副作用のある関数と純粋な関数を完全に分離できる。ここが他の言語と違うところだ。

純粋な部分は遅延評価される。だから、記述順に実行/評価されるとは限らない。一方、入出力という副作用の持つ部分は、記述順に実行される。これは他の言語と同じであり、不思議でも何でもない。

こう説明されれば、残る疑問は明確になる。状態の変化なしで、プログラムが書けるのかと。

状態のないプログラミング

問題の多くは、発想を変えることで解決できる。2、3 の例が、それをはっきりさせるだろう。以下、変数iを変化させる for 文について考えてみよう。

数え上げ

以下の Perl のコードは、数え上げのための for 文を用いている。

for ($i=0; $i<10; $i++) { print "$i\n"; }

これを Haskellで書くなら、単にリストを作ればよい。

mapM_ print [0..9]
モリーの節約

以下の Perl のコードは、ファイルの行を数える。メモリーの節約のため、一行ずつ読み込んでいる。

$i = 0; 
while (<>) { $i++ } 
print "$i\n" 

Haskell では、こんな貧民プログラミングはしない。ファイル全体を読み込んで、行を数える。

print . length . lines =<< getContents

モリーをたくさん消費するのではないかと心配になるかもしれないが、少しのメモリーを使うだけで実行できる(これについては後述する)。

繰り返し

以下のPerlコードは、階乗を計算する。

for ($i = 1; $i <= $n; $i++) { 
  $ret = $ret * $i; 
} 

このような繰り返しが本質なら、Haskellでは再帰を使う。

fact 1 = 1 
fact n = n * fact (n-1)

さらに状態について

そう言われても、複数の関数の間で共有する状態がなければプログラミングはできないと思うかもしれない。Haskell では、そういう状態は、関数の引数として渡して行く。

関数が深く連なると、引数を渡して行くのは辛くなるかもしれない。そういうときは、State という型を使う。State を使うと、見た目は状態を渡していないのに、裏でこっそり状態を渡してくれる。念のためにいうが、State は状態をエミュレートしているだけであって、実際に状態を持っている訳ではない。

長い間 Haskell でプログラミングしていると、あれほど欲しかった状態が、ほとんど欲しくなくなるから不思議だ。僕が、どうしても State を使いたくなったのは、状態マシンを実装したときだけだ。問題の本質が状態の変化なので、State を使うのが自然だったという訳だ。

モナド

上記の説明にモナドという言葉はいっさい出てこないことに注意して欲しい。Haskell を学習するときの最大の障壁はモナドだと言われるが、はじめの内はモナドという言葉を聞くべきではない。モナドを理解していなくても Haskell のコードは書けるので安心を。

それよりも IO がファーストクラスであることを理解する方が、よほど大切である。

ファーストクラスとしての IO

Haskell では、入出力をするコードの生成と実行が分かれている。そのため、生成した IO のコードを配列の中に入れたり、引数に渡したりできる。この意味で、Haskell の IO は、ファーストクラスであると言われる。

配列の中に入れられたり、引数に渡されたときには、IO は実行されない。IO が実行されるのは、main からつらなる IO の文脈で評価されたときである。例を挙げよう。

as :: [IO ()]
as = [putStr "World!", putStr "Hello, "]

main :: IO ()
main = do
    as !! 1
    as !! 0

上記のコードは、as が定義されたときに "World! Hello, " と表示されるように思えるかもしれない。しかし、そのときは何も起きず、main が評価されるときに "Hello, World!" と表示される。

多くのプログラマーがそうだろうが、私も IO がファーストクラスだと何が嬉しいのか、まったく分からなかった。しかし、Haskell のコードを書き続けていて、こんなに便利なものはないと思えるようになった。

その一例は、遅延評価である。IO の生成は、純粋な作業なので、遅延できる。よって、遅延評価を使い、あるコードの仕事と終了条件を独立したモジュールに分割可能になる。詳しくは、遅延評価とIOを読んで頂きたい。

また、引数に IO を取るように設計することで、関数を抽象化していくこともできる。この辺りの説明は難しいので、実際たくさん Haskell のコードを書くことで、体験して欲しい。

遅延IO

最後に注意点として、前述の getContents は遅延 IO と呼ばれる場合があることを指摘しておこう。ここでいう遅延とは、小さなバッファを用いて必要に応じてデータが読み込まれることを指す。UNIX のパイプのような機能だ。上記の IO の遅延評価とは異なるので注意して頂きたい。