IO モナドと副作用

純粋関数型プログラミングで副作用を扱う方法

Posted by Mizunashi Mana on April 05, 2020

Haskell は他のプログラミング言語には見られない特徴を多く持っている。その中の1つが純粋性だ。Haskell は純粋関数型プログラミング言語であることを、売りの1つにしている。しかし、純粋性は多くの場合表現力の縮小を招く。ところが Haskell は、IOモナドの導入により、通常のプログラミング言語と変わらぬ表現力を持てるようになっている。これは、とても驚くべきことだ。しかし、同時にこれは Haskell 入門者にとって、大きな混乱を招いているようだ。

今回は、そもそも純粋性とはなんなのか、なぜ他の言語は純粋性を担保できないのか、そして Haskell はどうやって IO モナドにより純粋性を担保しつつ他の言語と変わらない表現力を持てるようにしているのかについて、触れていきたいと思う。

Link to
here
純粋性とは何か

Haskell は純粋関数型プログラミング言語 (purely functional programming language) を売りにしている。関数型 (functional) の部分は他に任せるとして、ここでは純粋 (purely) の部分に着目しよう。純粋とはなんだろうか? どういう条件を満たせば、プログラミング言語は純粋と言えるんだろうか? Haskell の公式サイト ではこう述べられている:

Every function in Haskell is a function in the mathematical sense (i.e.,pure).

Haskell の全ての関数は、数学の意味での関数 (つまり「純粋」) です。

haskell.org Features: Purely functional より

ふむ、どうやら全ての関数が、数学的な意味での関数であれば、そのプログラミング言語は純粋と言えるようだ。ところで、数学的な意味での関数とはなんだろうか? 関数が純粋とはどういうことを指すんだろうか? これは噛み砕くと、

  1. 関数はどんな時も、同じ引数を与えられたら同じ結果を返す

    つまり、関数は毎回まっさらな状態で始まり、前にどんなことがあったのか、今巷でどういうことが起きてるのかを全く考慮に入れず、ただ受け取った引数から得られる情報だけを使って結果を計算する。

  2. 関数は、副作用を持たない

    つまり、関数は計算の結果を返す以外の役割を持たない。例えば、関数を1回呼び出すのと、関数を1回呼び出してその結果を捨てまたもう1回呼び出すので結果が変わることはない。

という2つの性質にまとめられる 1。具体的には、関数 f が、

  • 1回目の f 1 では 2 を返し、2回目の f 1 では 3 を返す

という動作をするなら、これは 1 の条件にも 2 の条件にも違反することになる。では、関数 printString

  • 受け取った文字列をターミナルに出力し、() を結果として返す

ことを考えよう。関数 printString は常に () を返すので 1 の条件にはマッチする。しかし、この関数は、与えられた文字列をターミナルに出力するので、1回呼び出すか2回呼び出すかは重要な違いになる。よって、2 番目の条件を満たさないため、純粋ではない。逆に、関数 getNowYear が、

  • 完全に副作用を持たず、何回呼び出しても他には何の影響もないが、今の年数を返す関数で、年が変わるごとに結果が変わる

となると 1 の条件に違反するため、やはり純粋ではない。関数が純粋になるためには、何の面白味もないかもしれないが、12 の条件を守らないといけない。決して夕日が沈むと突然結果を出さなくなったり、関数を呼ぶ度に近所の犬が吠えたり静かになったりしてはいけない。

さて、このような定義なら、多くのプログラミング言語の関数が純粋性を持たないのは納得できるだろう。同じ引数でも呼ぶタイミングによって結果が変わる関数、関数を呼ぶと全く予期しなかったスイッチが作動し、別の関数の結果が突然変わるようなプログラムを思いつく人は少なくないはずだ。ただ、その人たちは同時にこうも思うだろう。

そのような関数はプログラミングでは必要不可欠だ。Haskell はその必要不可欠な関数を、純粋性のためだけに書けないようにしてるのだろうか?

その疑問は至極妥当で、当然のものだ。そして安心して欲しい。その質問に対する答えは NO だ。Haskell は純粋性を保ちながら、そのような必要不可欠な関数を表現する方法を持っている。

Link to
here
動作を扱う関数

Haskell には、ターミナルに出力する動作を扱う関数や、ターミナルへの入力を受け取る動作を扱う関数が存在する。これは、どうも先ほどまでの純粋性の定義とは相容れないように見える。Haskell の純粋関数型プログラミング言語という性質は、そのような関数を除いては純粋という意味なんだろうか? それなら他のプログラミング言語でも事情は変わらない。ところが、Haskell はそのような関数まで純粋なのだ。そのカラクリについて、見ていこう。

Haskell でターミナルに文字列を (改行付きで) 出力する動作を扱う関数として、putStrLn という関数がある。これを題材として扱っていこう。この関数はどういう型を持っているのだろう? もし、

putStrLn :: String -> ()

という型になっていたら、この場合は文字列を受け取り () 型の値を返す関数になる。() 型は次のように定義される 2:

data () = ()

つまり、() というたった一つの値を持つ型になる。なので、putStrLnputStrLn :: String -> () という型を持っていた場合は、常に () という値を返す関数となり、純粋性の 1 番目の条件を満たす。しかし、putStrLn は呼び出し回数に応じて文字列をターミナルに出力していくので、副作用を持ち、2 番目の条件には到底当てはまらない。では、2 番目の条件に当てはまるようにするにはどうすればいいだろう? そのような選択肢は到底ないように見える。

実際には、HaskellputStrLn

putStrLn :: String -> IO ()

という型を持つ。つまり、返る値は IO () という不思議な型を持つ。こうすると純粋に文字列をターミナルに出力できるんだろうか? 答えは NO だ。HaskellputStrLn 関数は、

  • 受け取った文字列をターミナルに出力する

関数ではない。Haskell は、到底純粋性を持たないような操作をする関数を、そもそも関数の見方を変えて提供することで純粋性を保っている。この関数 putStrLn

  • 受け取った文字列から、「ターミナルにその文字列を出力する動作」を返す

関数だ。そして、「ターミナルにその文字列を出力する動作」は IO () という型を持つ。動作 (action) とは、文字通り「何をするか」 3 を表す。IO a は、

  • 動作、つまり「何をするか」を表す値を持つ
  • その動作をした結果、得られる値の型が a であることを表す

型だ。抽象的すぎてあまりピンとこないかもしれない。もし、その動作が結果を返す以外に何もしないなら、それは純粋な操作であるから、次のように書ける:

data PureAction a = PureAction (() -> a)

つまり、引数が何もない純粋関数だ。例えば、整数を2つ受け取って、その和を計算する動作を返す関数は次のように書けるだろう:

addAction :: Int -> Int -> PureAction Int
addAction x y = PureAction (\_ -> x + y)

putStrLnaddAction と同じように、値そのものではなくその値を計算する動作それ自体を返す。ただ、putStrLn が返す IO の動作は、PureAction の動作よりもっと一般的なものだ。つまり、純粋な動作ではないかもしれないということだ。もしかしたらそれは、今の時刻で結果を変えるかもしれないし、結果は常に変わらなくても何回呼び出すかでターミナルに表示する文字を変えるかもしれない。つまり、完全に純粋な関数では表せないかもしれない。でも、動作自体は不変的だ。putStrLn "str"

  • ターミナルに "str" を出力する動作

を表す。これが、10時にはこういう動作を返してきたのが、12時には

  • ターミナルに "str" を出力し、お昼の鐘を鳴らす動作

を返すようになるということはないし、この動作を返す以外に

  • 勝手にターミナルに文字列を出力する

ということもない。例えば、GHCi で以下のようなプログラムの出力を見てみよう:

>>> let _ = putStrLn "str" in ()
()

これは

  1. putStrLn "str" を計算し、
  2. 結果を捨て
  3. () を返す

というプログラムだ。このプログラムを評価しても、() だけしか目にしないはずで、何回実行しても同じ結果が得られるはずだ。つまり、putStrLn は余計なことを何もしていないと言えるだろう。そう説明すると、ちょっと Haskell をかじった人は

この説明は間違っている。この式は putStrLn "str" を全く評価していないので、実際に putStrLn "str" が余計なことを何もしていないかは分からない

と言うだろう。その通りだ。この説明は間違っている。それを確認してみよう:

>>> let _ = error "something happened!" in ()
()

もし、さっきの putStrLn "str" がちゃんと計算されていたなら、今回は something happened! というエラーが見れるはずだ。ところが、全く何の問題もなく式の実行は終わり、() が出力されてしまった。Haskell は遅延評価により、最終結果に本当に必要な部分しか計算してくれないので、putStrLn "str" の部分は計算されず無視されてしまっていただけのようだ。では、ちゃんと修正してみよう。修正は、seq という魔法の関数を使うことで可能だ。seq :: a -> b -> b は一番最初に渡された引数を (必要かどうかに関わらず、強制的に) 計算し、その後2番目の引数を返す関数だ。この関数を使うと、次のように修正が可能だ:

>>> putStrLn "str" `seq` ()
()
>>> error "something happened!" `seq` ()
*** Exception: something happened!

今度は大丈夫だろう。putStrLn "str" の部分をエラーに変えると、ちゃんとエラーが出力されている。putStrLn "str" は計算されているようだ。そう、putStrLn "str" が実行されて実際に行われるのは、その定義通り

  • 「ターミナルに "str" を出力する動作」を返す

ということだけで、他には何もしない。常に同じ動作を返すし、副作用を起こしたりもしない。これは純粋関数の定義に当てはまっている。putStrLn は純粋な関数なのだ。そして、その動作には、動作の結果の型によって型が決まっていて、それが IO 型ということになる。

では、実際に putStrLn はどういう定義になるんだろう? その定義は純粋な枠組みで定義できるんだろうか?

と疑問を持つ人はいるかもしれない。その疑問はとても良いところをついている。そう、putStrLn は、Haskell では定義できない。もしくは、定義するならば「文字列をターミナルに出力する動作」を表す値の作成方法を、何らかの仕組みで提供する必要がある。もし、

  • 「文字列 s をターミナルに出力する動作」を PutStrLn s :: IO () と書ける

なら、その時は、putStrLn を次のように定義できる:

putStrLn :: String -> IO ()
putStrLn s = PutStrLn s

ただ、今度は PutStrLnHaskell で定義するにはどうすればいいのだろう? という話になり、この話は延々と続くことになるだろう。現実世界の純粋なエミュレータを Haskell 内部で実装すれば収束するかもしれない。しかし、私たちは、現実世界をコストなく扱いたいわけであり、純粋かどうかは重要なことではない。そして、別に putStrLnHaskell 内で純粋に定義したいのではなく、「文字列 s をターミナルに出力する動作」を扱いたいだけなのだ。なので、Haskell は純粋な部分だけは目に見える範囲で提供し、非純粋な部分は隠蔽し、純粋に扱うことだけをできるようにしている。

では、実際にこの動作を実行したい時はどうすればいいんだろう? putStrLn "str" が純粋に、「"str" をターミナルに出力する動作」を返してきて、それを純粋に扱うことしかできないとなると、実際にターミナルに出力することはできないのではないだろうか? それもその通りだ。では、Haskell ではその問題をどう解決するか。実は Haskellmain プログラムは、IO 型の値で定義するようになっている。つまり、

main :: IO ()
main = putStrLn "str"

というように、main を何らかの IO 動作で定義する。そして、実際にこのプログラムからコンパイルされた実行ファイルは、定義された動作をそのまま行うようになっている。こうすることで、Haskell は純粋性を保ちながら、非純粋な動作を扱えるようになっている。

Link to
here
IO モナド

HaskellputStrLn が純粋な理由は分かってもらえただろうか? さて中には、

主張は分かったが、純粋に扱うだけに制限するということは、普通のプログラミング言語より非純粋な動作を上手く扱えないんじゃないか

と疑問に思う人もいるだろう。これも当然の疑問だ。普通のプログラミング言語は、表現力豊かで、様々な制御構文を持ち、それぞれの構文が純粋性に拘らないため、とてもユニークな非純粋なプログラムを書くことができる。ただ、安心して欲しい。Haskell も、それに負けない表現力で、非純粋な動作を作成することができる。さて、Haskell は、普通のプログラミング言語の機構の基盤は

  • 2つの動作を上手く結合できること

ではないかと考えた。そして、このうまく結合する機構を、IO 動作の上で実現する方法を考えた。結果、Haskell では2つの特殊な操作が組み込まれている:

  • 純粋な計算を、IO 動作に変換する: pure :: a -> IO a
  • 2つの IO 動作を繋げる: (>>=) :: IO a -> (a -> IO b) -> IO b

(>>=) については少し説明が必要だろう。2つの IO 動作を繋げるというのは、(>>=) :: IO a -> IO b -> IO b となった方が自然そうである。しかし、普通のプログラミング言語は、

  • 前の動作の結果により、次に行う動作を変える (if 文や while 文など)

ということが可能だ。そして、前の動作の結果は変数束縛などにより自由に参照できる。Haskell は、IO 動作を純粋性により実際に実行することはできない。その代わり、上の動作の制御機構を、繋げる操作に組み入れることで代用しようとしたのだ。つまり、

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

の操作は、

  1. 最初に受け取った IO 動作を実行し、
  2. その結果から、次に行う IO 動作を純粋に生成し、
  3. 生成された動作を行う

という動作全体を表す IO 動作を生成する。この結果から次に行う IO 動作を生成する部分をうまく定義すれば、(>>=) によって様々な制御構文を模倣できるのではないかと考えたのだ。この仕組みはとても上手くいった。Haskell では、非純粋な動作をif 文や while 文で任意に実行することを、次のような純粋に動作を切り替える関数で代用する:

ifIO :: Bool -> IO a -> IO a -> IO a
ifIO b act1 act2 = case b of
  True  -> act1
  False -> act2

whileIO :: (a -> Bool) -> a -> (a -> IO a) -> IO ()
whileIO isEnd x0 act = go x0 where
  go x = ifIO (isEnd x)
    (pure ())
    (
        act x >>= \nx ->
        go nx
    )

これらの関数を使えば、

main :: IO ()
main =
  getLine >>= \loopCmd ->
  ifIO (loopCmd /= "loop")
    (putStrLn "No loop")
    (whileIO (\(b, _) -> b) (False, 0) (\(_, i) ->
        putStrLn ("loop " ++ show i) >>= \_ ->
        getLine >>= \loopEndCmd ->
        ifIO (loopEndCmd == "end")
          (pure (True, i))
          (pure (False, i + 1))
    ))

のようなプログラムが書ける。このプログラムは、

  1. 最初にターミナルへの入力を待ち、loop と打たれれば、ループに入る。それ以外の場合は "No loop" と出力し、プログラムを終了する。
  2. 今のループの回数を出力し、ターミナルへの入力を待つ。
  3. ターミナルに end と打たれれば、プログラムを終了する。それ以外の場合、ループカウントを1増加させて、2 に戻る。

ということを行う。このように、純粋な範囲内で繋げる操作を工夫することで、普通のプログラミング言語の機構を IO 動作内に組み込めるようになっている。ただ、このプログラムは大変見にくい。なので、Haskell はさらに、この繋げる操作を元に、次のような DSL を提供している:

main :: IO ()
main = do
  loopCmd <- getLine
  ifIO (loopCmd /= "loop")
    (putStrLn "No loop")
    (whileIO (\(b, _) -> b) (False, 0) (\(_, i) -> do
        putStrLn ("loop " ++ show i)
        loopEndCmd <- getLine
        ifIO (loopEndCmd == "end")
          (pure (True, i))
          (pure (False, i + 1))
    ))

少しは見やすくなっただろうか? この操作は、そこまで特別な操作をしてるわけではない。インデントを解析して、

main :: IO ()
main = do
  x1 <- e1
  x2 <- e2
  e3

というのを、

main :: IO ()
main =
  e1 >>= \x1 -> do
  x2 <- e2
  e3

に変形して、さらに

main :: IO ()
main =
  e1 >>= \x1 ->
  e2 >>= \x2 -> do
  e3

と変形して、

main :: IO ()
main =
  e1 >>= \x1 ->
  e2 >>= \x2 ->
  e3

と変形する、というように最初から11行変形して、do がなくなるまで変形を行うだけだ。つまり一行一行の動作を (>>=) で繋げていくのだ。なお、一番最後以外は x <- e という形になっているのが基本で、もしそのような形になっていない e は、_ <- e と変換される 4 。なので、

main :: IO ()
main = do
  putStrLn "str1"
  putStrLn "str2"

は、

main :: IO ()
main = do
  _ <- putStrLn "str1"
  putStrLn "str2"

と変換された後、先ほどの変換によって、

main :: IO ()
main =
  putStrLn "str1" >>= \_ ->
  putStrLn "str2"

となる。このようにして、Haskell は他のプログラミング言語の非純粋な動作を、純粋な枠組みでも同じように扱えるようになっている 5Haskell は、この仕組みをモナディックIOと名付け、IO 型を IO モナドと呼んでいる。モナドとは何か、どういう便利な側面があるのかについては、他の記事に譲る。

Link to
here
動作を第一級で取り扱う

どうやら、HaskellIO 動作の仕組みが、純粋な枠組みでも他のプログラミング言語とそう劣るものではないということが分かってもらえただろうか? ところで、先ほどの ifIOwhileIO は、IO 動作を何事もなく引数にとって返したりしていた。ifIO の定義をもう一度よく見てみよう:

ifIO :: Bool -> IO a -> IO a -> IO a
ifIO b act1 act2 = case b of
  True  -> act1
  False -> act2

このプログラムは、条件を表す引数と、IO 動作を2個受け取り、条件によって2つの動作のうちのどちらかを返していた。これは考えてみれば、とても不思議で強力なことだと思わないだろうか? 普通のプログラミング言語の if 文は、条件から書かれたプログラムのどちらかを実行する。一方、ifIO は実行を制御しているわけではない。単に、普通の関数と同じように、2つの動作を受け取って、そのうちの片方を関数の返り値として返すだけだ。ifIO を呼び出したプログラマは、返ってきた動作をゴミ箱に捨ててもいいし、(>>=) で繋げて「2回続けて同じ動作をする」1つの動作にしてもいい。もちろんその動作も main に組み入れるかはプログラマ次第だ。なんなら、main 以外にライブラリの一部としてグローバルに定義してもいい。ライブラリを使うユーザは、やっぱりそれを使うも使わないも自由だ。main に組み入れない限り、その動作は単なるデータであり、実行もされない。

IO 動作がデータであることは、プログラムをより豊かにする。さっきの ifIO は、条件によって片方の動作を返していた。IO動作はもっと多彩に制御できる。例えば、条件によって動作の順番を変えたかったら次のように書けばいい:

chooseOrderIO :: Bool -> IO a -> IO a -> IO a
chooseOrderIO b act1 act2 = case b of
  True  -> do
    act1
    act2
  False -> do
    act2
    act1

chooseOrderIO は条件によって、受け取った動作を実行する順番を変え、その順序で結合した動作を返す。順番が同じで結果だけ選ぶといったこともできる:

ifResultIO :: Bool -> IO a -> IO a -> IO a
ifResultIO b act1 act2 = do
  x1 <- act1
  x2 <- act2
  case b of
    True  -> x1
    False -> x2

ifIO は条件によって動作そのものを選んでいたが、ifResultIO はどの条件でも act1act2 の順に動作をすること自体は変えない。代わりに、その動作の結果をどっちにするかだけを変える。このように、HaskellIO 動作を、多彩に、しかも純粋にコーディネートすることができる。これは、他の多くのプログラミング言語にはなく、しかも強力な機能だ。そう、HaskellIO 動作は、それが単なるデータであるがゆえに、通常のプログラミングの範囲で自由に加工できるのだ。

これを、動作が第一級であるという。第一級とは、つまり他のデータと全く同じように扱えるということだ。

Link to
here
まとめ

この記事では、

  1. 純粋とは何か
  2. Haskell は、どうして純粋なのか
  3. Haskell は、純粋な中で、他の言語の機構をどうやって実現しているのか
  4. Haskell が、その中で獲得した強力な機能とは何か

について紹介した。どうだろう? HaskellIO モナドについて、少しでも理解の補助になっただろうか?

HaskellIO モナドとは、動作そのものを値に持つ型だった。そして、その値は、特別な繋げる操作により他の言語と同じように加工でき、しかも通常のプログラミングの範囲で加工が可能になっている。しかも、加工自体は純粋にでき、動作の生成も純粋にできる。これが、Haskell が純粋であると言われる所以だった。

この Haskell の根幹をなす機能が、どういう点で魅力的なのか分かってもらえたら、この記事を書いた甲斐があるというものだ。もし、あなたもこの機能の魅力に取り憑かれたらなら、ぜひ IO 動作をふんだんに加工してプログラミングをしていって欲しい。では、楽しい Haskell ライフを。

Link to
here
おまけ: IOモナドの実装

ところで、もしかしたら、読者の中には、

HaskellIO モナドは、現実世界を状態にする State モナドだ

という主張を、見たことがある人がいるかもしれない。最後におまけとしてこの話に触れておこうと思う。気になる人は、この後も呼んでみると、IO モナドの理解の助けになるかもしれない (または、むしろ混乱するかもしれない。もし、混乱したなら、とりあえずこの話は忘れることをお勧めする。ここに書いてある話を理解しなくても、IO モナドの利用に関して全く支障はない。そういう話もあるぐらいの事柄だ。なので、安心してまずは Haskell プログラミングを楽しんでほしい。いつか楽しみ飽きたら戻ってきてもいいかもしれない)

まず、この話は、

  • 非純粋な世界の話だということ
  • 単なる State モナドではないということ

を押さえておいて欲しい。さて、Haskell の代表的な処理系 GHC は、標準の範囲では純粋関数型プログラミングを提供するが、全体としては非純粋な計算も許容している。そして、その計算を IO モナドの内部に使っている。GHC では IO モナドは、通常の言語内の一部として定義されている:

newtype IO a = IO (State# RealWorld -> (# State# RealWorld, a #))

この型は、詳細は省くが、本質的には、

  • State# RealWorld 型の値を受け取り、State# RealWorld 型の値と a 型の値のタプルを返す関数

newtype になっている。State モナドについて知ってる人は、これは State (State# RealWorld) a と同じだと思うだろう。しかし、IO a の値は、他に Haskell の型では表現できない契約を持つ。それは、

  • State# RealWorld の型の値は、必ず1回だけ使用される

という契約だ。なので、

IO $ \s# -> (# s#, \() -> s# #)

6 s#2箇所で使ってるため IO (() -> State# RealWorld) の値になれないし、

IO $ \s# -> (# s#, IO $ \_ -> (# s#, () #) #)

は一番外側の IOs#2 箇所で使っているため契約違反で、内側の IO は受け取った引数を一度も使っていないのでやはり契約違反ということになる。この定義を使って、例えば Haskell で可変参照を扱う IORef のフレームワークは、次のように定義されている 7 :

data IORef a = IORef (MutVar# RealWorld a)

newIORef :: a -> IO (IORef a)
newIORef init = IO $ \s1# -> case newMutVar# init s1# of
  (# s2#, var #) -> (s2#, IORef var)

readIORef :: IORef a -> IO a
readIORef (IORef var) = IO $ \s1# -> readMutVar# var# s1#

writeIORef :: IORef a -> a -> IO ()
writeIORef (IORef var#) val = IO $ \s1# -> case writeMutVar# var# val s1# of
  s2# -> (# s2#, () #)

この定義は、ちゃんと IO 型の制約を守っている。なおここで出てくる、# が付く関数やデータ型は、GHC の中で特別扱いされ、プリミティブな関数やデータ型になっている。それぞれ、以下の型の関数として扱える:

newMutVar# :: a -> State# s -> (# State# s, MutVar# s a #)

readMutVar# :: MutVar# s a -> State# s -> (# State# s, a #)

writeMutVar# :: MutVar# s a -> a -> State# s -> State# s

注意して欲しいのは、これらの関数は純粋ではないということだ。これは、GHCi 上で次のように確認できる:

>>> :set -XMagicHash -XUnboxedTuples
>>> :module GHC.Prim GHC.Types
>>> :{
IO $ \r0# ->
  let (# r1#, var# #) = newMutVar# False r0# in
  let (# r2#, b1 #) = readMutVar# var# r1#
      r3# = writeMutVar# var# True r2#
  in
  let (# _, b2 #) = readMutVar# var# r1# in
  (# r3#, b1 == b2 #)
:}
False

b1b2 は両方とも readMutVar# var# r1# から得た値になる。ところが、これらを比較してみると False になる 8 。もし、readMutVar# が純粋なら、b1b2 の結果は同じになるため、上の評価結果は True になるはずだ。しかし、残念ながら readMutVar# は純粋ではないので、b1b2 は異なる値になってしまう。なお、この式は、IO 型で定義しているが、実際には

  • 2 回目の readMutVar# の呼び出しで r1#2 回使用しているし、
  • 返ってきた State# RealWorld の値を捨てている

ので契約違反であることに注意だ。GHCi 上で、うまく評価結果を確認するために、IO を使っている。

さて、純粋性を守れないなら、GHC は一体全体何のためにこのような定義をしているんだろう? 関数が純粋でなくてもいいなら、単に

newtype IO a = IO (() -> a)

と定義しても問題ないのではないだろうか? この方が自然に動作を表しているように思える。ところが、このような定義は、ある問題を招くのだ。Haskell は純粋関数型プログラミング言語という売りの他に、遅延評価という他の言語にはあまり見られない評価機構を採用している。もちろん、GHC も遅延評価が基本だ。正確には、Haskell の評価順序は、

The order of evaluation of expressions in Haskell is constrained only by data dependencies; an implementation has a great deal of freedom in choosing this order.

Haskell の式の評価順序は、データ依存によってのみ縛られます。これは、実装がこの順序の選択において、大きな自由度を持つことを意味します。

Haskell Language Report - Chapter 7 Basic Input/Output

とあるように、データ依存関係によってのみ制御できる。ところが、IO動作は違う。例えば、

main :: IO ()
main = do
  putStrLn "str1"
  putStrLn "str2"

という式において、putStrLn "str1"putStrLn "str2" の動作の間には、何らのデータ依存関係も存在しない。しかしながら、main

  • "str1" をターミナルに出力した後、"str2" をターミナルに出力する

という動作を表して欲しいはずだ。つまり、IO動作はデータ依存関係によってのみ順序が決まるわけではなく、その繋げ方によって順序が決まって欲しいのだ。ところが、単純に

newtype IO a = IO (() -> a)

という定義を採用してしまうと、IOの中身は繋げ方の順序を情報として持たないため、動作の実行順序を制御するのに、別途工夫が必要になる。そこで、元の定義の登場だ:

newtype IO a = IO (State# RealWorld -> (# State# RealWorld, a #))

この State# RealWorld は、実際には () 型と同じく単一の値を持つほぼ何の意味も持たない型になる。しかし、この型の値を計算度に付与することで、データ依存を作ることができる。具体的には、この IO に対して次のように pure / (>>=) を定義するのだ:

pure :: a -> IO a
pure x = IO $ \r# -> (# r#, x #)

(>>=) :: IO a -> (a -> IO b) -> IO b
IO f >>= g = IO $ \r0# ->
  let (# r1#, x #) = f r0#
      IO g' = g x
  in g' r1#

特に、(>>=) の定義が重要になる。(>>=) が返してくる IO の中身は、

  1. 受け取った State# RealWorld をまず最初の IO 動作に渡す
  2. その結果を g に渡して、次の IO 動作を生成する
  3. 生成した IO 動作に、最初の IO 動作が返してきた State# RealWorld を渡す

ということを行っている。これにより、

  1. 受け取った State# RealWorld
  2. f の結果の State# RealWorld
  3. g' の結果の State# RealWorld

という順で State# RealWorld のデータ依存関係が出来上がる。つまり、通常の Haskell の評価の枠組みで、評価順序を保証できるようになるのだ。実際に、IORef を例に考えてみよう。

main :: IO Bool
main = do
  x <- newIORef False
  b1 <- readIORef x
  writeIORef True x
  b2 <- readIORef x
  pure $ b1 == b2

というプログラムにおいて、最終的な pure $ b1 == b2 からのデータ依存関係に、一見 writeIORef True x は関与していないように見える。ところが、内部を見てみると、上のプログラムは、

main :: IO Bool
main = IO $ \r0# ->
  let (# r1#, x# #) = newMutVar# False r0#
      (# r2#, b1 #) = readMutVar# x# r1#
      r3# = writeMutVar# True x# r2#
      (# r4#, b2 #) = readMutVar# x# r3#
  in (# r4#, b1 == b2 #)

と同じ意味を持ち、全て隠れた State# RealWorld によってデータ依存で紐づいている。もちろん、let 内の定義の順番を変えても何の問題もない。重要なのは r0# から r1# の結果が得られ、r1# から r2# の結果が得られ、というようなデータ依存だけだ。これにより、GHCHaskell の評価順序に特別な規則を設けない 9 10 で、IO を実装しているのだ。

なお、こうなると IO

  • State# RealWorld の型の値は、必ず1回だけ使用される

という契約も意義が見えてくる。もし、この契約が破られると、途中でデータ依存が分岐したり、または途中で途絶えたりすることになる。そうなると、動作がどういう挙動をするかは、Haskell 内では規定されなくなってしまう。実際に、最適化によってどう動作するかが変わってきてしまう例も作れる。IO の契約とは、データ依存が必ず一本の線で繋がり、Haskell の評価の枠できちんと順番が規定されるということを保証しているのだ。

これが、GHC がこのような定義を IO で採用している理由になる。もちろん、アナロジーとして現実世界全体を表す架空の状態を State# RealWorld と見立て、IO動作の実行により新たな現実世界全体の状態が手に入るという見方は可能だ。名前の由来もそこから来ている。ただ、基本的には、GHC において、特別な仕組みを入れずに IO を実装するためのやり方であるということを押さえておいて欲しい。


  1. 定義は、 School of Haskell のチュートリアル から拝借している。↩︎

  2. この定義は、Haskell Language Report 6.1.5 The Unit Datatype で述べられているが、実際には Haskell の構文規則に違反している特別な構文が使われている。なので、実際に Haskell でこのように定義できるわけではなく、擬似的に書くとこうなるという意味になる。↩︎

  3. 動作は、計算 (computation) とも呼ばれる。また、日本の Haskell コミュニティでは、英語そのままで「アクション」とも呼ばれている。↩︎

  4. 厳密には、(>>) という別の関数を使って定義されるんだが、意味的にはそのような変換と思ってもらって構わない。正式な変換方法は、Haskell Language Report 3.14 Do Expressions を参照するといいだろう。↩︎

  5. 多くの言語では、main プログラム以外の、例えばライブラリが勝手にスレッドを1つ立てるなどの挙動をサポートしている。そのような挙動は、Haskell では残念ながらできない。なぜなら、Haskell では main に動作を組み入れない限りその動作は実行されないからだ。この点では、他の言語より表現力は劣っているということもできる。しかし、そのような機能は、多くの場合明示的に模倣できる。↩︎

  6. (# x, y #)(# a, b #) 型の値を表す特別な構文だ。ここでは詳細は述べないので、xy のタプルの特別な表記方法だと思ってもらって構わない。↩︎

  7. 実際には ST モナドとの兼ね合いで、直接こう定義はされていないが、分かりやすさのため簡略化している。↩︎

  8. 実際には、最適化次第で結果が変わることもある。↩︎

  9. さらに、State# RealWorldunlifted なデータ型になっており、サンクを持たない。このため、強制的に正格評価になるようになっており、IO動作が遅延され、最後に一気に評価されるということを防いでいる。これも、通常の GHC の枠組みの中で提供されているのは、とても興味深い。↩︎

  10. 厳密には、残念ながら全てを特別扱いせずに済ませられているわけではない。GHC では、State# RealWorld に関して一部の最適化で特別な処理を施している。↩︎