なんか考えてることとか

変な人が主にプログラミング関連で考えていることをまとめる。

HaskellのIOアクションが副作用を持たず、参照透過であることをようやく納得できた話

今回は、HaskellのIOアクションについて、ようやく納得できたので書いていく。

TL;DR

HaskellのIOアクションは、外部的要因の一切を排除しており、評価してもIOアクションは実行されない。
故に、副作用を持たず、参照透過である。

はじめに

Haskellは純粋関数型プログラミング言語である。そしてHaskellにおける関数は、何度同じ引数に適用しても計算以外のことは行わず同じ結果となる「数学的な意味での関数」である。このような関数は「副作用を持たない」、かつ「参照透過」である・・・というのは、Haskellをある程度書いた人にとってはもはや常識の範疇である。

ここで、Cのprintf()のような、標準入出力について考えてみる。
たとえばprintf()は、計算以外にも、標準出力という外部に影響を与えている。また、戻り値の型はint型であり、出力文字数を返すのだが、外部的要因により、出力文字数以外にも、負の値を返すこともある。
つまりこの関数は、計算以外にも、外部に影響を与える「副作用を持つ」し、同じ引数に適用しても、同じ結果となるとは限らない「参照透過でない」関数である。

したがって、純粋関数型プログラミング言語であるHaskellでは、関数は副作用がなく参照透過である「純粋関数」でなくてはならないので、通常、Cのprintf()のような「不純関数」を定義することはできない

しかし、現実問題として入出力すらもできないプログラムに価値があるとはとても思えないので、こういった外部に影響されるものも扱えるようにはしたい。そんな一見矛盾したような問題に対して、HaskellIOアクションという概念を導入している。

私とIOアクション

私がIOアクションという概念について初めて出会ったのは、「すごいHaskellたのしく学ぼう!」という本である。

この本では、IOアクションについて以下のように説明している。

I/O アクションとは、実行されると副作用(入力を読んだり画面やファイルに何かを書き出したり)を含む動作をして結果を返すような何かです。


(中略)


では I/O アクションはいつ実行されるのでしょうか? さて、ここでmainが関係してきます。 I/O アクションは、僕らがそれにmainという名前をつけてプログラムを起動すると実行されるのです。

私は当初これを見たとき、頭の中が「?」だらけでいっぱいになった。そして、結局わからなかったので、このままこの本を読み進めていったのであった*1

次に私は、モナドによって「状態付き計算」のような通常副作用が必要となるような計算も抽象化できるということを知り「IOもこうやってモナドによって純粋性を保ちながらうまく計算してるんだスゲー」と思ってしまったのである*2

ふと湧いた疑問

しかし、しばらくして、私はある「違和感」に気がつく。

「仮にモナドを使って計算したとしても、計算の途中で時間や入出力によって結果が変わってしまったら結局それは純粋性が失われることになるのでは?」

と。

そうして私はIOアクションについて、今度はネットで調べた。そして、以下の記事に辿り着いた。
www.infoq.com

以下、私がIOアクションの性質を理解するうえでヒントになった文章を抜き出す。

nextInput()の例では、nextInput()の値は時間に依存します。


(中略)


時間依存を取り除くため、nextInput()を使うのをやめ、doInputという関数で置き換えます。式として、doInput()の値は5でも17でも"Hello, world"でもないです。代わりに、doInput()の値はアクションです。


(中略)


プログラムで式doInput()が現れるどんなところでも、式はつねに同じ値です。その値はキーボードからの入力を取得するアクションです。

さらに、私がIOアクションについてMastodonで投稿したところ、誤りがあり、ご指摘をいただいた。
pawoo.net

その次に見た記事はこれだ。
haskell.jp

私が前の記事で抜粋したのと、概ね同じことが書いてある(前の記事はモナドのほうに比重を置いている記事だったので、IOアクションについて理解したいのであれば、この記事のほうがより良いだろう)。

IOアクションの持つ性質

そこで、私はようやく、IOアクションの持つ性質について理解したのである。

つまり、IOアクションそのものは式の評価で実行されることはないため、時間や、入出力などの外部には左右されない。何度評価してもそれはIOアクションという値でしかない。

したがって、IOアクションは副作用を持たないし、参照透過なのである。

さらに、IOアクションのその性質から、HaskellではIOアクションを含む関数も純粋関数であると言えるのである。

あとは、main関数を定義し、プログラム実行時、HaskellランタイムにIOアクションを実行させることで、時間や入出力などを扱うことができるというわけである。

結局IOモナドって何だったのよ

ときとして、モナドに関して「モナドIOのためのものだよ」といった説明がされることがあるが、先述した通り、仮にモナドで計算したとしても、計算の途中で実行してしまったら意味がない。それを解決するためには、IOアクションという別の概念が必要だ。

故に、私はIOアクションとモナドは切り離して考えたほうが良いと考えている。

では、IOアクションにモナドは要らないか?と言うと、そういうわけでもなく、IOアクションを複数扱ううえでは、モナドはもはや必要不可欠だ*3。たとえば、以下のようなプログラムについて考えてみる。

main :: IO String
main = getLine

これはgetLineによって標準入力するだけのプログラムだ。ここから、標準出力のほうに持っていきたいとなったとき、どうするか?ここで、モナドの登場だ。

モナドを使えば、getLineで標準入力した文字列をそのままputStrLnで標準出力するプログラムもこの通り楽々と実装できてしまう。

main :: IO ()
main = getLine >>= putStrLn

これはdo記法と呼ばれる糖衣構文を使って、あたかも命令型プログラミング言語のように書くこともできる。

main :: IO ()
main = do
    line <- getLine
    purStrLn line

終わりに

Haskellは純粋関数型プログラミング言語である。それは、Haskellでは副作用がなく、参照透過性のある「純粋関数」以外は通常定義できないからである。

しかし、それだと入出力すらできないので、実用的なプログラムを作ることができない。
その問題に対してHaskellは、時間や入出力といった外部的要因を一切排除したIOアクションを導入し、コード以外の部分でHaskellランタイムに実行させることによって、純粋関数型プログラミング言語としての純粋性を守ったうえで時間や入出力を扱うことに成功したのである。

蛇足

注意
今から書くことは、本当に必要なとき以外は絶対に使わないようにしてください。
Haskellでコードを書くうえで覚える必要もありません。

HaskellのIOアクションは、純粋性を守っていると書いたが、これを壊すための関数がある。それがunsafePerformIOである。

たとえば、入力した数値を+1するプログラムを書こうとすると、unsafePerformIOがあればこういうこともできてしまうだろう。

import System.IO.Unsafe (unsafePerformIO)

unsafeIncrement :: (Read a, Num a) => a
unsafeIncrement = x + 1
    where x = unsafePerformIO readLn :: (Read a, Num a) => a

main :: IO ()
main = print unsafeIncrement

この例では、unsafeIncrementという関数が見事に参照透過でない関数となってしまっており、結果として純粋性を破っていることがわかる。Haskellではこのような書き方をせずとも、それでいて安全に、該当プログラムを書くことが可能だ。

increment :: Num a => a -> a
increment = (+1)

main :: IO ()
main = do
    n <- readLn :: (Read a, Num a) => IO a
    print $ increment n

必要になるのかはわからないが、本当に必要なとき以外は絶対に使うべきではないだろう。

*1:ちなみに誤解しないでいただきたいのが、この本はとても良い本であったということだ。Haskellを初めて触る人にはお勧めできる一冊だ

*2:今思えば、この解釈は矛盾しているし、先ほどの引用の最後だけでも納得しておけば良かったのだが

*3:まぁ場合によってはApplicativeで十分解決できる場合もあるが