Haskell の IO モナドと参照透過性の秘密


2017年 08月 31日

Haskell は純粋であるだとか、参照透過性を満たしているだとかよく話題になる。参照透過性を満たしているということは、同じ関数に同じ引数を渡せば、いつ、誰が簡約しても結果が変わらないということを意味している。いつ簡約しても結果が変わらないということは、並列に簡約しても良いわけだし、逆順に簡約してもいいわけだし、コンパイル時に簡約したっていいわけだ。じゃあ、以下のコードが、常に HELLOWORLD を出力し、 WORLDHELLO となることはない理由は何なのか? putStr "WORLD"putStr "HELLO" よりも先に簡約したって良いのでは?

do
  putStr "HELLO"
  putStr "WORLD"

実際のところ、「IO モナドってのは特別なんだ」とかそういう理解であっても、とりあえずモノを作るにはそんなに困らない。だいたいそう動くし、そういうもんだと思っていれば十分である。

でも、どういう仕組みなのかやっぱり気になるよね? そんな人のために、ちょっと大変だけど、このプログラムを手で簡約してみたいと思う。自分で簡約し、ついでに実行もして、実際に HELLOWORLD が出力できれば、ある程度の納得を得られるはずだ。

IO モナドの定義

簡約をするためにはまず IO モナドの定義を知らねばならない。ということで定義を確認しよう。以下の通りである。

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

instance Monad IO where
  return a     = IO $ \w -> (w, a)
  (IO h) >>= f = IO $ \w ->
    let (nw, a) = h w
        (IO g)  = f a
    in g nw

OK。ここで出てくる RealWorld という型は、実際にはもうちょっと特殊な型ではある。だが一旦はそういうものだということにしよう。後で「仮の RealWorld 」を自分で定義する。

do 構文の脱糖

さて、元のコードは以下の通りだ。

do
  putStr "HELLO"
  putStr "WORLD"

do 構文はただの糖衣構文なので、まずは衣を剥がそう。これは簡単だ。

putStr "HELLO" >> putStr "WORLD"

さらに、 (>>) の定義はこうだ。

(>>) :: Monad m => m a -> m b -> m b
a >> b = a >>= \_ -> b

ということで

putStr "HELLO" >>= \_ -> putStr "WORLD"

ひとまずこんなところだろうか。

putStr の展開

putStr の定義も確認しないとこれ以上の簡約は難しそうだ。 putStr はどうなっているのだろう?

putStr :: String -> IO ()
putStr s = IO (\w -> putStr# s w)

ふむ? putStr# なるものが出てきたが、これの型は何だろう? 定義に従えば putStr# :: String -> RealWorld -> (RealWorld, ()) であることがわかる。 putStr# もあとで仮のものを定義するが、いったんこういうものだと思って続けよう。

IO (\w -> putStr# "HELLO" w) >>= \_ -> (IO (\w -> putStr# "WORLD" w))

人手でやるにはちょっとごちゃごちゃしてきたが仕方ない。 IO(>>=) の定義に従って頑張って簡約を進めよう。定義はこうだった。

(IO h) >>= f = IO $ \w ->
  let (nw, a) = h w
      (IO g)  = f a
  in g nw

パターンマッチングに従い、 h(\w -> putStr# "HELLO" w) に、 f\_ -> (IO (\w -> putStr# "WORLD" w)) に束縛される。

IO $ \w ->
  let (nw, a) = (\w' -> putStr# "HELLO" w') w
      (IO g)  = (\_ -> (IO (\w -> putStr# "WORLD" w))) a
  in g nw

(\w' -> putStr# "HELLO" w') wputStr# "HELLO" w だ。

IO $ \w ->
  let (nw, a) = putStr# "HELLO" w
      (IO g)  = (\_ -> (IO (\w -> putStr# "WORLD" w))) a
  in g nw

(\_ -> (IO (\w -> putStr# "WORLD" w))) a は、単に引数 a を無視するだけだ。結果としては IO (\w -> putStr# "WORLD" w) となる。

IO $ \w ->
  let (nw, a) = putStr# "HELLO" w
      (IO g)  = IO (\w -> putStr# "WORLD" w)
  in g nw

g をパターンマッチングで取り出そう。 (\w -> putStr# "WORLD" w) である。

IO $ \w ->
  let (nw, a) = putStr# "HELLO" w
  in (\w -> putStr# "WORLD" w) nw

nw を適用する。

IO $ \w ->
  let (nw, a) = putStr# "HELLO" w
  in putStr# "WORLD" nw

a はもう使わない。 nwfst (putStr# "HELLO" w) だ。

IO (\w -> putStr# "WORLD" (fst (putStr# "HELLO" w)))

簡約完了だ。完了である。本当に。

だまされた気がする? だがこれが事実である。この簡約結果は簡約順序を変えようが変わったりしない。そしてこれこそが IO アクションの正体なのだ。

Haskell は純粋だと言ったな。あれは嘘ではない。だが引数が同じだとは言ってない。 IO モナドは「引数を隠して」いたのだ。「現実世界 (RealWorld)」という、巨大な引数を。そして引数が違えば、違う結果を返しても参照透過性を破ることはない。いいね?

更なる簡約の意味

さて、 Haskell プログラムの簡約そのものは完了した。

main :: IO ()
main = IO (\w -> putStr# "WORLD" (fst (putStr# "HELLO" w)))

我々は今、これ以上の簡約を行うことはできない。何故なら引数 RealWorld が与えられない限り、関数の結果もまた決まらないからだ。違う引数であれば、違う結果を返しても良い。そうだね?

はて、じゃあ引数 RealWorld が決まれば、もっと簡約できるよね?

そう、それが Haskell ランタイムの仕事である。Haskell ランタイムの仕事とは、「現実世界」を IO アクションに与えて簡約することなのだ。

runIO :: IO a -> RealWorld -> (RealWorld, a)
runIO (IO f) w = f w

モナドのアクセサ関数を runXxx という名前にしてこれを適用することを「モナドの実行」と言うことが多いが、まさに IO こそその最たるものである。やるべきことは以下の通りだ。

runIO main runtimeWorld

これの簡約こそが、「Haskell プログラムの実行」である。

RealWorld と putStr# のエミュレート

せっかくなので実行もエミュレートしよう。本当なら「現実世界」は、CPU、メモリ、ディスク、ネットワーク、ディスプレイ、キーボード、マウス、etc、etc…様々なものをまとめた「何か」なわけだが、今回はとりあえず putStr だけなので、コンソール出力状態だけエミュレートしてみよう。

data RealWorld = RealWorld { _consoleOutput :: String }

この RealWorld に合わせて、 putStr# も定義してみよう。こんな感じだろうか。

putStr# :: String -> RealWorld -> (RealWorld, ())
putStr# s w = (w { _consoleOutput = _consoleOutput w ++ s }, ())

「実行時の現実世界」もエミュレートする必要がある。とりあえずこのプログラムの実行時には、コンソール出力は空だということにしよう。

runtimeWorld :: RealWorld
runtimeWorld = RealWorld { _consoleOutput = "" }

さあ、実行してみよう。

実行

実行のための材料は出揃っている。 main はこうだ。

main :: IO ()
main = IO (\w -> putStr# "WORLD" (fst (putStr# "HELLO" w)))

こいつを実行しよう。Haskell プログラムの実行とは以下の簡約だった。

runIO main runtimeWorld

mainruntimeWorld を置き換えよう。

runIO (IO (\w -> putStr# "WORLD" (fst (putStr# "HELLO" w)))) (RealWorld { _consoleOutput = "" })

runIO も既に紹介した。

(\w -> putStr# "WORLD" (fst (putStr# "HELLO" w))) (RealWorld { _consoleOutput = "" })

無名関数に RealWorld を適用する。

putStr# "WORLD" (fst (putStr# "HELLO" (RealWorld { _consoleOutput = "" })))

putStr# を展開する。

putStr# "WORLD" (fst (RealWorld { _consoleOutput = "" ++ "HELLO" }, ()))

fst でタプルの第一要素だけを取り出す。

putStr# "WORLD" (RealWorld { _consoleOutput = "" ++ "HELLO" })

もう一度 putStr# を展開。

(RealWorld { _consoleOutput = "" ++ "HELLO" ++ "WORLD" }, ())

(++) も簡約しよう。

(RealWorld { _consoleOutput = "HELLOWORLD" }, ())

実行完了!

見ての通り、(仮の)コンソールに HELLOWORLD が出力された。別に WORLDHELLO になったりはしない。何故なら、 WORLD を出力しようとする putStr# は、「 HELLO が出力された後の」 RealWorld を受け取り、その後ろに続けるよう実装されているからだ。 IO モナドが特別だとかそういうことではない。ただ putStr# がそう定義されているだけなのだ。

まとめ

どうだろう、実際に簡約してみて少しは納得できただろうか。 IO モナドとは、結局のところただの State モナドであり、その状態が「現実世界」という、ちょっと変わった何かなだけだ。

確かに簡約過程で副作用はなかったが、それは「現実世界」という巨大な概念を「状態」として取り込んだからだ。ただし、この「状態」がちょっと特殊なのは確かで、コンソールに何かを出力すればそれは我々に見えるし、ファイルに書き込んだりしたらそれも見える。だからこれを「プログラムの外」と見なし「副作用だ」と主張するのであれば、それは確かに副作用である。

実のところ、他のモナドでも、その機能として何かを「隠して」いる。例えば State なら「状態」を隠しているし、 Maybe なら「中断能力」を隠している。そして、それぞれのモナドに隠されている何らかの働きを「モナド副作用(monadic side-effects)」と呼ぶ。 State モナドが状態を変化させるのは「モナド副作用」だし、 Maybe モナドが計算を中断するのも「モナド副作用」である。 mapM_sequence_ が結果を捨てても有用なのはなぜか? それはモナドが隠した「モナド副作用」があるからなのだ。

IO は「現実世界」を状態としてモナドの裏に隠した。そしてその隠されている状態に対する働きをモナド副作用という。「現実世界」に何か働きかけるということは、一般的に副作用と呼ばれることそのものだ。つまり IO の「モナド副作用」は、要するに普通に言うところの「副作用」そのものなのだ。

いや、歴史的には恐らく順序が逆だろう。つまりこう言うべきだ。一般に言われる「副作用」が「モナド副作用」に相当するように設計されたモナド、それが IO モナドなのだ、と。