関数プログラミングのアプローチ (16)

モナドのデメリット

前回までで、入出力を含むプログラムをモナドを用いて記述する方法について検討してきました。モナドを用いることで、プログラムを関数的に保ったまま、入出力を行うことができました。また、モナドを用いることで、プログラムの処理系の実装の自由度を高めることができることもできました。
今回からは、入出力を取り扱う上で、モナド以外のアプローチについて検討することにします。最初に入出力にモナドを用いることのデメリットについて検討します。
まず、効率がデメリットとなることが考えられます。IOモナドは、入出力の一つ一つの処理にそれぞれアクションを生成することになるので、命令的なプログラムに比べて一つ一つの入出力の処理にかかる命令数やメモリ使用量のオーバーヘッドは大きくなります。しかし、これは実際には誤差の範囲に収まる程度の軽いオーバーヘッドであるかもしれませんし、コンパイラががんばることで命令的なプログラムと同等の効率で実行できるようになるかもしれません。ですので、効率の面は本質的な問題ではないと考えることができます。
より大きな問題として、プログラミングスタイルの問題があります。例えば、これまで検討してきた行番号を付加するプログラムは、リスト操作関数を用いて次のように書くとより関数プログラミングのスタイルにフィットします。

output = zipWithL(lambda (num, line): str(num) + " " + line,
                  integers, input)

ただし、zipWithL()は、関数と遅延リストを2つとって、リストの各要素に関数を適用した結果を、遅延リストとして返す関数で、次のように定義することができます。

def zipWithL (fun, aa, bb):
    def f (aa, bb):
        aa = get(aa)
        bb = get(bb)
        if aa and bb:
            a,aa = aa
            b,bb = bb
            return fun(a,b), Thunk(lambda: f(aa, bb))
        else:
            return None
    return Thunk(lambda: f(aa, bb))

このスタイルは、遅延リストを使うことで、入力を1行ずつ読み取って、1行ずつ出力することができるため、必要以上にメモリを消費することなく、効率よく処理を行うことができます。
しかし、これをモナドを用いたプログラムと組み合わせると困ったことになります。次のプログラムを見てください。(以下では、記述の簡便さのために、遅延リストでなく、通常のリストを使います。)

def lineNum (ifile, ofile):
    return \
    freadlines(ifile).bind(lambda (lines): \
    fwritelines(ofile,
                zipWith(lambda (num, line): str(num) + " " + line,
                        integers, lines)))

ただし、メインルーチンとfreadlines(), fwritelines()は次のように定義します。

def actions (prog, ifname, ofname):
    return \
    fopen(ifname, 'r')    .bind(lambda (ifile): \
    fopen(ofname, 'w')    .bind(lambda (ofile): \
    lineNum(ifile, ofile) .bind(lambda: \
    fclose(ofile)         .bind(lambda: \
    fclose(ifile)         .bind(lambda: \
    io_return())))))

def freadlines (ifile):
    def loop (ret):
        def cond (line):
            if line:
                return loop(ret + [line])
            else:
                return io_return(ret)
        return freadline(ifile).bind(cond)
    return loop([])

def fwritelines (ofile, lines):
    def loop (lines):
        if lines:
            line = lines[0]
            lines = lines[1:]
            return fwrite(ofile, line).bind(lambda: \
                   loop(lines))
        else:
            return io_return()
    return loop(lines)

このプログラムは、一見効率よく動くように思えますが、実際には、lineNum()関数の呼び出しの前に、入力ファイルの内容をすべてメモリに読み込んでしまいます。IOモナドがトップレベルでしかアクションを実行することができないという制限のために、zipWith()のような関数の処理中に入力と出力を交互に実行するようなプログラムを書くことができません。
このような制限のために、モナドを用いたプログラムは、場合によって、非常に命令的な記述を強制されるという、逆説的な結果を生み出すことがあるのです。