Haskell は純粋であるだとか、参照透過性を満たしているだとかよく話題になる。参照透過性を満たしているということは、同じ関数に同じ引数を渡せば、いつ、誰が簡約しても結果が変わらないということを意味している。いつ簡約しても結果が変わらないということは、並列に簡約しても良いわけだし、逆順に簡約してもいいわけだし、コンパイル時に簡約したっていいわけだ。じゃあ、以下のコードが、常に HELLOWORLD
を出力し、 WORLDHELLO
となることはない理由は何なのか? putStr "WORLD"
は putStr "HELLO"
よりも先に簡約したって良いのでは?
do
putStr "HELLO"
putStr "WORLD"
実際のところ、「IO モナドってのは特別なんだ」とかそういう理解であっても、とりあえずモノを作るにはそんなに困らない。だいたいそう動くし、そういうもんだと思っていれば十分である。
でも、どういう仕組みなのかやっぱり気になるよね? そんな人のために、ちょっと大変だけど、このプログラムを手で簡約してみたいと思う。自分で簡約し、ついでに実行もして、実際に HELLOWORLD
が出力できれば、ある程度の納得を得られるはずだ。
簡約をするためにはまず 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
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 :: 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') w
は putStr# "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
はもう使わない。 nw
は fst (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 プログラムの実行」である。
せっかくなので実行もエミュレートしよう。本当なら「現実世界」は、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
main
と runtimeWorld
を置き換えよう。
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
モナドなのだ、と。