HaskellのIOは他の言語でいうところの関数オブジェクトとよく似てるよ、という話

by Yuji Yamamoto on January 28, 2017

Tagged as: Haskell, Monad, Java.

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は命令「書」である、と言えます。
これはIO(またはIO返す関数)自体は「命令を実行する関数」ではなく「命令を表すオブジェクト」であるという意味でもあります。
この記事でいう「オブジェクト」とは「演算の対象になるもの」程度の意味です。第一級オブジェクトと言い換えてもよいでしょう。

つまり、HaskellのIOは「演算の対象になるもの」として変数に格納したり、関数の実行結果として返したり、関数の引数として渡したりできるのです。
この性質を利用すると、他の言語でよくやる、コールバックのような機能を実現することもできます。Haskellのコードになってしまいますが、一応実例をこの記事のサンプルコード用リポジトリーに置きました。ご興味のあるかたはご覧ください。

さて、「変数に格納したり、関数の実行結果として返したり、関数の引数として渡したりできる」ことに加えてコールバックのような使い方もできる、このようにHaskellのIOをとらえると、なんだか他の言語の関数オブジェクトのようにも思えてきませんか?
そう、HaskellのIOは、今時の言語ならだいたい実装している、関数オブジェクト(第一級オブジェクトな関数)とそっくりなのです!RubyでいうところのProcMethodオブジェクトであったり、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 StringStringというのは一体なんのStringでしょう?
これは、readFileが返すIO(指定された名前のファイルを読み込む関数オブジェクト)を実行した結果 — すなわちこのケースで言えば読み込んだファイルの中身 — です。

IOはこのように、型引数として「実行した結果の型」を明示することで使用します。
IO Stringであれば恐らく「ファイルやソケットなどから読んで文字列を返す関数オブジェクト」でしょうし、IO Boolであれば例えば「ファイルの有無を調べて、ファイルがあればTrue, なければFalseを返す関数オブジェクト」かもしれません。
Javaに詳しい方はCallable<String>などと読み替えていただくとピンと来るでしょう。
Callableも引数を受け取らない関数オブジェクトであり、型引数として「実行した結果の型」を受けとるようになっています。

他の言語の関数オブジェクトとしてのHaskellのIO

さて、上記をより具体的にイメージしやすくするために、HaskellのIOっぽいものをJavaで表現してみましょう。
ここまでに述べた通り、HaskellのIOは、他の言語で例えるなら「引数をとらない関数オブジェクトっぽいもの」なので「関数オブジェクトをラップしたクラス」として表現することとします。実際のHaskellのIOはプリミティブなものなのでなにかをラップしているなんてことはないのですが、実際に動くサンプルを作るために必要なので、ご了承ください。

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<>(() -> {
      T1 result = this.internalAction.call();
      IO<T2> nextIo = makeNextIo.apply(result);
      return nextIo.internalAction.call();
    });
  }

}

詳細に立ち入る前に、最も重要な点を述べます。
このHaskellのIOっぽいものを表現したクラスIOは、「関数オブジェクト」と似ていながら、「関数オブジェクト」として実行するAPI2を利用者に提供していないのです。
それを表すために今回は、ラップした関数オブジェクト(internalAction)をprivateにして、internalActionを呼び出すメソッド(call)を直接呼べないようにしました。
HaskellではIOは「実行するもの」ではなくあくまでも「組み合わせる」ものであり、後に説明します「組み合わせる」ための関数(上記のクラスのメソッド)を用いてのみ操作するのです。

IOを組み合わせる関数

単純に繋げる: plusメソッド

最初に単純なplusメソッドについて説明しましょう 3

  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を返す」、ただそれだけです4

結果を再利用できるようにしつつ繋げる: thenメソッド

続けて、もうちょっと複雑なthenメソッドを紹介しましょう 5

  public <T2> IO<T2> then(Function<T1, IO<T2>> makeNextIo) {
    return new IO<>(() -> {
      T1 result = this.internalAction.call();
      IO<T2> nextIo = makeNextIo.apply(result);
      return nextIo.internalAction.call();
    });
  }

まずはplusメソッドと同じ部分を列挙します。

  1. plusメソッドと同様、return new IO<>()している通り、新しくIOを作って返す。
    なのでIOに渡す関数オブジェクトの中でIOを組み合わせることとなる。
  2. 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は他のプログラミング言語の関数オブジェクトと異なり、(Callablecallメソッドのような)「直接実行する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でHappy Hacking!!


  1. 実際にドキュメントを読むとreadFileの型はFilePath -> IO Stringとなっていますが、FilePathは単なるStringの別名(Rubyで言えばalias)です。

  2. JavaのCallableやRubyのProcで言えば文字通りcallメソッド、JavaScriptやPythonの関数オブジェクトで言えば関数呼び出し演算子 ()がそれに該当します。

  3. このplusメソッドはHaskellでいうところの>>関数です。

  4. 実はこのplusメソッド、C#によく似た振る舞いをする演算子があります。名前の通り足し算に使っている+演算子です(plusメソッドという名前もそこからとりました)。
    C#では関数オブジェクト(デリゲート)同士を+で繋ぐことで、両辺の関数オブジェクトを「続けて実行する」新しい関数オブジェクトを作ることができます。詳細はMSDNのこちらのページなどをご覧ください。

  5. thenメソッドはHaskellでいうところのあの>>=関数です。今回はMonad自体の話はしませんが、これは前回Monadのメソッドとして作成したthenメソッドをIOが実装したバージョンです。IOMonadインターフェースを実装したクラス(Haskellの用語で言えば「Monad型クラスのインスタンス」)なのです。


I'm a Haskeller Supported By Haskell-jp.