HaskellのIOは他の言語でいうところの関数オブジェクトとよく似てるよ、という話
by Yuji Yamamoto on January 28, 2017
HaskellのIOは奇妙です。
「純粋な関数だけでプログラムが書けるの?」と多くの方が疑問に思うように、実際のところ、副作用のある操作(入出力。ファイルの読み書きやネットワーク通信など)をしなければ、役に立つプログラムは一切書けません。
Haskellではそのものズバリ「IO」という機能を使ってそれを実現します。
しかし一方でHaskellは「純粋」なので副作用が一切ない、とも言われます(例えばWikipediaのこの辺の説明)。
その辺のややこしい問題について、かの「プログラミングHaskell」の訳者、山本和彦さんの記事では、下記のように解説されていました(原文のままコピペしています)。
Haskell の IO では、評価と実行を分離して考える。例として、以下の関数を考えよう。
putStr :: String -> IO ()
putStr は、たとえば putStr “Hello World” のように、String を引数に渡して評価すると、IO () という型の何かを返す。
このように IO a という型、念のため言うが -> などを含まない単体の IO a という型を持つものを Hakell ではアクションと呼ぶ。僕には、カタカナを使って説明した気になっている人は説明が下手だという信念があるので、日本語に直そう。単体の IO a という型を持つものとは、命令書である。
つまり、こういうことだ。putStr “Hello World” は、「“Hello World” を標準出力に出力しろ」という命令書を作る。これが実行されてはじめて、“Hello World” が出力される。
今回はここでいう「命令書」という概念を、他の言語のオブジェクトで例えて説明することで、もっと具体的にとらえ直すということに挑戦したいと思います。
あ、今回もJavaを使いますが、前回以上にJava固有の仕様への依存度を下げますので、他のオブジェクト指向な言語に読み替えるのはもっと簡単なんじゃないかと思います。
はじめにまとめ
- Haskellの
IO
は他の言語における「引数をとらない関数オブジェクト」と似てる。- ただし、Haskellの
IO
にはプログラマー自らが直接IO
を実行するAPIがない。 - プログラマーは、
IO
を「組み合わせる」ことでプログラムを組み立てる。
- ただし、Haskellの
- Haskellでは関数が入出力を行うかどうかが、型を見るだけで知ることができる。
- なんだか難しいことを書いたけど、結局のところ実用上、Haskellは入出力処理も普通にできると考えた方が分かりやすいからあんまり気にしないでね!
すごく詳しい人への追記: なぜ評価戦略の話をしないのか
あくまでHaskellを知っている人向けです。早く次の節を読みましょう。
2020年4月11日、はてなブックマークでついたコメントに長年イライラしていたので超今更ですが釈明します。もっと早く書くべきでしたね。
一言で言うと「この記事は遅延評価も知らなければHaskellについてもあまり詳しくない人に向けて書かれた記事だから」です。
確かにHaskellのIO
はこちらの、もっと詳しくてたとえ話をしない素晴らしい記事にも書かれているとおり、Haskellの評価戦略が持つ「データ依存関係によってのみ制御」されるという制約の中で「命令」を順番に実行するために生み出された仕組みです。
しかし、前述の記事は基本的にある程度Haskellを知っている人に向けて作られている一方、この記事は「純粋関数型言語なんかでどうやって入出力するの?」という素朴な疑問を持ったプログラマーへ、そうした方々にとって比較的身近でかつ実態に近い、「関数オブジェクト」というプログラミング用語で例えて説明することが目的なので、守備範囲外なのです。
加えて、IdrisやPureScriptなど、普通のプログラミング言語と同様に積極評価を採用した「純粋関数型言語」においても、結局HaskellのIO
とそっくりな仕組みを採用しています。
つまり、評価戦略がどうであれ、IO
によって副作用のある処理とそうでない処理を分ける仕組みは十分導入される動機となりうるので、評価戦略の話は必ずしも本質的ではないのです。
(「純粋でない関数にアノテーションを加える」といったような仕組みとどちらがよいかはまた別の話ですが)
命令「書」
さて、引用した通り、HaskellのIOは命令「書」である、と言えます。
これはIO
(またはIO
返す関数)自体は「命令を実行する関数」ではなく「命令を表すオブジェクト」であるという意味でもあります。
この記事でいう「オブジェクト」とは「演算の対象になるもの」程度の意味です。第一級オブジェクトと言い換えてもよいでしょう。
つまり、HaskellのIO
は「演算の対象になるもの」として変数に格納したり、関数の実行結果として返したり、関数の引数として渡したりできるのです。
この性質を利用すると、他の言語でよくやる、コールバックのような機能を実現することもできます。Haskellのコードになってしまいますが、一応実例をこの記事のサンプルコード用リポジトリーに置きました。ご興味のあるかたはご覧ください。
さて、「変数に格納したり、関数の実行結果として返したり、関数の引数として渡したりできる」ことに加えてコールバックのような使い方もできる、このようにHaskellのIO
をとらえると、なんだか他の言語の関数オブジェクトのようにも思えてきませんか?
そう、HaskellのIO
は、今時の言語ならだいたい実装している、関数オブジェクト(第一級オブジェクトな関数)とそっくりなのです!RubyでいうところのProc
やMethod
オブジェクトであったり、C#では「デリゲート」とも呼ばれたりする、アレです!
Haskellの関数オブジェクトとほかの言語の関数オブジェクト
しかし、Haskellはいわゆる「関数型言語」に属している通り、IO
とは別に「関数オブジェクト」と呼べるものが存在しています。
そう、「関数型」の値です。これとIO
との違いはいったいなんなのでしょうか。
一番の違いは、やはりHaskellの関数が「純粋」であるところに由来します。
Haskellの関数型の値は、例えば「Int -> String
(Int
型の値を受け取ってString
型の値を返す関数)」という型宣言で表されるのですが、原則として、その型に書いてあること以上のことはできません。「Int -> String
」な関数は文字通り「Int
型の値を受け取ってString
型の値を返す」以上のことができないのです(デバッグ目的で使われる例外はあります)。
それに対してIO
は、これまた文字通り入出力処理など、他のプログラミング言語(の関数オブジェクト)なら自由にできることの多くができます。
加えて、他の言語の「関数オブジェクト」やHaskellの「関数型の値」どちらとも大きく異なる点があります。
引数をとることができないのです。引数をとるのは「関数型」の役目、ということになっています。
例えば、ファイル名を受け取ってそのファイルの中身を読んで返す関数、readFile
は、以下のような型となっています1。
readFile :: String -> IO String
これは、「String
(文字列型)を引数として受け取ってIO String
という型を返す関数」という意味です。「String
(文字列型)を引数として受けとる」部分は普通の関数の役目となっているのがわかるでしょうか?
IO String
という部分に受けとる引数についての情報はなく、引数の情報はあくまでも普通の関数を表す記号 ->
の左辺に現れるのです。
ところで、引数のString
はファイル名だとして、IO String
のString
というのは一体なんのString
でしょう?
これは、readFile
が返すIO
(指定された名前のファイルを読み込む関数オブジェクト)を実行した結果
— すなわちこのケースで言えば読み込んだファイルの中身 — です。
IO
はこのように、型引数として「実行した結果の型」を明示することで使用します。
IO String
であれば恐らく「ファイルやソケットなどから読んで文字列を返す関数オブジェクト」でしょうし、IO Bool
であれば例えば「ファイルの有無を調べて、ファイルがあればTrue
, なければFalse
を返す関数オブジェクト」かもしれません。
Javaに詳しい方はCallable<String>
などと読み替えていただくとピンと来るでしょう。
Callable
も引数を受け取らない関数オブジェクトであり、型引数として「実行した結果の型」を受けとるようになっています。
他の言語の関数オブジェクトとしてのHaskellのIO
さて、上記をより具体的にイメージしやすくするために、HaskellのIO
っぽいものをJavaで表現してみましょう。
ここまでに述べた通り、HaskellのIO
は、他の言語で例えるなら「引数をとらない関数オブジェクトっぽいもの」なので「関数オブジェクトをラップしたクラス」として表現することとします。実際のHaskellのIO
はプリミティブなもの2なのでなにかをラップしているなんてことはないのですが、実際に動くサンプルを作るために必要なので、ご了承ください。
public class IO<T1> {
private final Callable<T1> internalAction;
IO(Callable<T1> internalAction){
this.internalAction = internalAction;
}
public <T2> IO<T2> plus(IO<T2> nextIo) {
return new IO<>(() -> {
this.internalAction.call();
return nextIo.internalAction.call();
});
}
public <T2> IO<T2> then(Function<T1, IO<T2>> makeNextIo) {
return new IO<>(() -> {
= this.internalAction.call();
T1 result <T2> nextIo = makeNextIo.apply(result);
IOreturn nextIo.internalAction.call();
});
}
}
詳細に立ち入る前に、最も重要な点を述べます。
このHaskellのIO
っぽいものを表現したクラスIO
は、「関数オブジェクト」と似ていながら、「関数オブジェクト」として実行するAPI3を利用者に提供していないのです。
それを表すために今回は、ラップした関数オブジェクト(internalAction
)をprivate
にして、internalAction
を呼び出すメソッド(call
)を直接呼べないようにしました。
HaskellではIO
は「実行するもの」ではなくあくまでも「組み合わせる」ものであり、後に説明します「組み合わせる」ための関数(上記のクラスのメソッド)を用いてのみ操作するのです。
IO
を組み合わせる関数
単純に繋げる: plus
メソッド
最初に単純なplus
メソッドについて説明しましょう 4。
public <T2> IO<T2> plus(IO<T2> nextIo) {
return new IO<>(() -> {
this.internalAction.call();
return nextIo.internalAction.call();
});
}
plus
メソッドはreturn new IO<>()
している通り、新しくIO
を作って返します。IO
は関数オブジェクトをラップしたものなので、新しく別の関数オブジェクトを作って渡す必要があります。
その、新しく作った関数オブジェクトでは何をしているのでしょう?
最初にplus
メソッドを呼んだIO
オブジェクト自身を呼び出し(this.internalAction.call()
)、次に引数として渡されたIO
オブジェクトを呼び出す(nextIo.internalAction.call()
)、ただそれだけです。
まとめると、「plus
メソッドを呼び出したIO
と、引数として与えられたIO
を続けて呼び出す新しいIO
を返す」、ただそれだけです5。
結果を再利用できるようにしつつ繋げる: then
メソッド
続けて、もうちょっと複雑なthen
メソッドを紹介しましょう 6。
public <T2> IO<T2> then(Function<T1, IO<T2>> makeNextIo) {
return new IO<>(() -> {
= this.internalAction.call();
T1 result <T2> nextIo = makeNextIo.apply(result);
IOreturn nextIo.internalAction.call();
});
}
まずはplus
メソッドと同じ部分を列挙します。
plus
メソッドと同様、return new IO<>()
している通り、新しくIO
を作って返す。
なのでIO
に渡す関数オブジェクトの中でIO
を組み合わせることとなる。plus
メソッドと同様、最初にthen
メソッドを呼んだIO
オブジェクト自身を呼び出し(this.internalAction.call()
)、次にもう1つのIO
オブジェクトを呼び出す(nextIo.internalAction.call()
)。
大きく異なる点は、plus
メソッドがIO<T2> nextIo
を直接受け取っていたのに対して、then
メソッドがFunction<T1, IO<T2>> makeNextIo
、すなわち「nextIo
を作る関数」を受けとることに起因します。
makeNextIo
は、plus
メソッドでは無視していた「呼び出しもとのIO
の実行結果(this.internalAction.call()
が返す値)」を受けとる(makeNextIo.apply(result)
)ことで初めてnextIo
を返します。
そしてmakeNextIo
が返したIO
を実行してその結果を返す — というのがthen
メソッドが作るIO
の処理の流れです。
まとめると、「then
メソッドを呼び出したIO
の結果を使って引数として与えられたIO
を作り、それらを続けて呼び出す新しいIO
を作る」、ということとなります。
then
メソッドは、例えばファイル名をユーザーの入力から受け取って、そのファイルを読み込むといったIO
アクションを作りたいときに使用されます。
「『ファイル名をユーザーから受けとるIO
』の結果を用いた『ファイルを読み込むIO
』」を作る場合は、plus
メソッドの機能では限界があり、then
メソッドのような機能が必要となるのです。
何が嬉しいの?
さて、ここまでHaskellのIO
についてJavaのクラスで例えてきました。
その結果、HaskellではIO
は「実行するもの」ではなくあくまでも「組み合わせる」ものであり、plus
メソッドやthen
メソッドのような、組み合わせるためのAPIを用いてのみ操作する、ということがわかりました。
一体なぜ、そのような変わった仕様になっているのでしょうか。
実用的な観点のみに触れるならば、それはIO
に対して何らかの処理を加えた結果は必ずIO
になるので、結果として全ての入出力処理にIO
という型がつけられる、という点でしょう。
plus
メソッドのシグネチャがIO<T2> plus(IO<T2> nextIo)
、then
メソッドのシグネチャがIO<T2> then(Function<T1, IO<T2>> makeNextIo)
である通り、IO
を組み合わせるメソッドはいずれもIO<T2>
の値を返すようになっています。
そして、前の節で触れたように、IO
は他のプログラミング言語の関数オブジェクトと異なり、(Callable
のcall
メソッドのような)「直接実行するAPI」を提供していません。
更に加えて、Haskellでは標準で提供される、あらゆる入出力処理がこのIO
型の値(あるいはIO
型の値を返す関数)となっています。
したがってHaskellにおいて、ある関数が入出力処理をしうるかどうかは、関数がIO
型の値を返すかどうかだけを見ればわかる、あるいは、関数の型を見るだけで入出力処理をするかどうかがわかるということとなります(主にデバッグ目的に作られた例外はあります)。
例えば、本記事の最初の方で「Haskellの関数型の値は、その型に書いてあること以上のことはできない」と述べたとおり、「Int -> String
(Int
型の値を受け取ってString
型の値を返す関数)」はString
を返すことしかできません。しかし「Int -> IO String
」と戻り値の型にIO
を付けることによって、「入出力など、副作用を伴う処理」をしうるようになり、そのことを関数のユーザーに明示できるようになるのです。
このことは「入出力処理とそれ以外の処理を、意識して分割させやすくする」という大きなメリットをもたらします。
一般に、入出力を伴うコードはテストしにくいコードです。特定のディレクトリーにおかれた特定のパーミッションのファイルがなければ動かないコードかもしれませんし、データベースに接続しなければ動かないコードかもしれません。更には、一度実行したらファイルを削除しなければならないかもしれませんし、データベースを特定の状態にロールバックしなければならないかもしれません。
そうしたコードとそうでないコードを区別させることは、自然とコード全体のテストしやすさを高めることにも繋がりますし、例え入出力を伴うコードが適切に分割されていなかったとしても、そのことを型宣言から容易に測り知ることができます。
静的型付け言語のメリットとして、型宣言を書くことがそのまま常時up-to-dateなドキュメントになる、ということが挙げられますが、Haskellはそうした特徴をもう一歩踏み込んで活用しているのです。「関数が入出力処理を行う」ということを型宣言に含めることで、関数の役割を常に明確にすることができるようになっているのです。
(省略)純粋な関数とIOしかない世界
続いて、「HaskellではIO
は『実行するもの』ではなくあくまでも『組み合わせる』もの」という認識をより実感していただくために、先ほどJavaで作ったIO
を実際に使ってみましょう。Haskellの(純粋な)関数をJavaのFunction
で、IO
を先ほど作ったJavaのIO
に例えて、Haskellのプログラム(Main モジュール)の世界観を再現してみます。
この節では最終的な目標として、普通のJavaで書けば下記のようなコードになるプログラムを、「純粋な関数とIOしかない世界」観で翻訳することを目指します。
public class IOSampleInOrdinaryJava {
public static void main(String args[]) throws Exception {
// 挨拶のあと、名前を尋ねて、
System.out.println("Nice to meet you!");
System.out.println("May I have your name? ");
// 標準入力から名前を取得して、
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
String name = reader.readLine();
// 名前を誉める
System.out.println("Your name is " + name + "?");
System.out.println("Nice name!");
}
}
… と、いうつもりだったのですが、長く複雑になってしまった結果、この記事で本当に言いたいことがぼやけてしまいそうので、省略することにしました。あしからず。
上記を含め、こちらに別途リポジトリーを作りました。
実際に動くJavaアプリケーションとなっておりますので、Haskellの世界観を強引にJavaに翻訳したらどうなるか、動作を確認しながらわかるようになっております。
それで結局Haskellは副作用がある式を書けるの?書けないの?
ここまで、プログラマーにとってHaskellのIO
はあくまでも「組み合わせる」ものであり、直接実行するものではない、ということを説明してきました。
プログラマーがHaskellを書いてできる、IO
を使用した演算(IO
に対する関数の使用)は原則すべて、「組み合わせたIO
を返す」だけの演算であり、最終的にそのIO
を実行するのは、Haskellの処理系(あるいは処理系が吐き出した実行ファイル)の仕事なのです。
しかしながら、こんな疑問を持つ人がいるかもしれません。 — 「それって、突き詰めれば他の言語でも同じなんじゃないか?」、と。
確かに、もとをただせばプログラムとそれを構成する関数は本質的に「命令書」であり、プログラマーはあくまでもその「命令書」を「組み合わせる」ことしかできません。
極端な話、実行するのは結局のところプログラマーでも処理系でもなく、コンピューターなのです。
それに、実際にHaskellをある程度書くとすぐにわかることですが、IO
を使ったコードは見かけ上も使い勝手も普通の手続き型言語で書くような「命令書」とあまり変わりません。
今はそうでもありませんが個人的には、IO
ばかり使ったコードを書いていると、Haskellではなく他の言語で書いているような気さえしました。
大きな違いは、扱う「命令書」が常にファーストクラスオブジェクト(IO
型の値)であることや、副作用を行う式が常に型で示されるお陰で、そうでないコードから分離させやすい、という2点です。
しかしいずれにしても、IO
を使ったコードの書き心地や読み心地は命令型言語のそれそのものなのです。
そのため、「Haskellは副作用がある式を書けるの?」という質問に対しては、私は「背景に(理解しなくてもいい)難しい理屈はあるけども、書けると考えた方が圧倒的に理解しやすい」と回答します。
これまでに書いたことをひっくり返すように聞こえるかもしれません。
しかし、「純粋関数型言語だから副作用のある式は書けない(あるいは書いてはいけない)」などと考えて理解に苦労したり、Haskellを矮小化して捉えたりしてしまうよりは、よっぽど楽でしょう。副作用を普通に扱えることも、Haskellの非常に大事な一面なのです。
そのことを踏まえて、最後にもう一度まとめをのせておきます。
- Haskellの
IO
は他の言語における「引数をとらない関数オブジェクト」と似てる。- ただし、Haskellの
IO
にはプログラマー自らが直接IO
を実行するAPIがない。 - プログラマーは、
IO
を「組み合わせる」ことでプログラムを組み立てる。
- ただし、Haskellの
- Haskellでは関数が入出力を行うかどうかが、型を見るだけで知ることができる。
- なんだか難しいことを書いたけど、結局のところ実用上、Haskellは入出力処理も普通にできると考えた方が分かりやすいからあんまり気にしないでね!
それでは、HaskellのIOでHappy Hacking!!
実際にドキュメントを読むと
readFile
の型はFilePath -> IO String
となっていますが、FilePath
は単なるString
の別名(Rubyで言えばalias)です。↩︎「IO モナドと副作用」の「おまけ」でも取り上げられているとおり、GHCでは
IO
は完全にプリミティブなものではありません。ただ、今回Javaでたとえられる実装ともあまり似てないものです。↩︎Javaの
Callable
やRubyのProc
で言えば文字通りcall
メソッド、JavaScriptやPythonの関数オブジェクトで言えば関数呼び出し演算子()
がそれに該当します。↩︎この
plus
メソッドはHaskellでいうところの>>
関数です。↩︎実はこの
plus
メソッド、C#によく似た振る舞いをする演算子があります。名前の通り足し算に使っている+
演算子です(plus
メソッドという名前もそこからとりました)。
C#では関数オブジェクト(デリゲート)同士を+
で繋ぐことで、両辺の関数オブジェクトを「続けて実行する」新しい関数オブジェクトを作ることができます。詳細はMSDNのこちらのページなどをご覧ください。↩︎then
メソッドはHaskellでいうところのあの>>=
関数です。今回はMonad自体の話はしませんが、これは前回Monad
のメソッドとして作成したthen
メソッドをIO
が実装したバージョンです。IO
はMonad
インターフェースを実装したクラス(Haskellの用語で言えば「Monad
型クラスのインスタンス」)なのです。↩︎