tips/lazyIO
編集   新規   履歴   最終更新者 lethevert   最終更新時刻 2007-07-03 15:48:47 UTC

問題設定

一方でファイルを読みながら、一方でその内容を加工して別のファイルに出力するという処理は、プログラミングでは典型的な処理パターンの1つである。このような処理を行う上で、一度ファイルの内容を全て読み込んでから、その内容に対して処理を行うというやりかたは、プログラムの構造を簡単にするという点で魅力的であるが、メモリ消費が増大したり、レイテンシが発生したりするという問題がある。

この問題に対して、遅延評価をサポートしている言語では、ファイル読込処理を遅延させて、評価が進むにしたがって少しずつファイル読込を進めることで、メモリやレイテンシの問題を避けることはすぐに思い付くアイデアである。これを、ここでは「遅延IO」と呼ぶことにする。

Cleanでは、一意型と正格性フラグのおかげで、遅延IOを驚くほど簡単に実現することができる。


入力ファイルを全て読み込む場合

最終的に、入力ファイルの情報を全て使う場合は、入力ファイルを最後まで読みきってからファイルを閉じることになる。そのような場合は、次のように簡単に書くことができる。

read_process_write :: !String !String !*World -> !*World
read_process_write infile outfile w
  # (ok,fi,w) = fopen infile FReadText w
  | not ok = trace_n ("cannot open " +++ infile) w
  # (ok,fo,w) = fopen outfile FWriteText w
  | not ok # (_,w) = fclose fi w
           = trace_n ("cannot open " +++ outfile) w
  # (data,fi) = freadlines fi
    data = process data
    fo = fwritelines data fo
    (_,w) = fclose fo w
    (_,w) = fclose fi w
  = w

infileは入力ファイル名、outfileは出力ファイル名、processはファイルの内容に対する処理の本体部分で、リストからリストへの関数として定義される。例えば、次の例は、各行の文字列を反転させる処理を示している。

process = map reverseStr
  where reverseStr l = {c \\ c <- reverse [c \\ c <-: l]}

元のプログラムの意味する所はシンプルである。入力ファイルと出力ファイルを開いて、入力ファイルのデータを改行で区切ってリストとして受け取り、得られたデータをリスト操作で処理して、最後に出力ファイルに書き込んでいる。一見、入力を全てメモリ上に蓄積してから処理が行われそうな気がするが、実際には一行ごとに読み込んで処理して書き出すというように進められる。

どうしてこのようなことが可能なのだろうか?

これは、freadlines関数の型に秘密がある。freadlinesは次のような関数である。

freadlines :: !*File -> (![String],*File)
freadlines f
      # (b,f) = fend f
      | b = ([],f)
      # (l,f) = freadline f
        (ls,f) = freadlines f
      = ([chop l:ls],f)

何の変哲もない、ただファイルから1行ずつ読み込んでそれをリストにつないで返しているだけの関数だが、どうしてこのような動きになるのだろうか?それは、freadlinesの返値のタプルの2つ目の要素の*Fileに正格性フラグが付けられていないためなのだ。

freadlinesが評価されても、遅延タプルが得られるだけで、実際には関数内のfreadlineやfreadlinesの呼び出しは評価されない。これらが評価されるのは、実際に返値のデータが必要になったときまで遅延される。

もし、freadlinesの評価の直後に、返値の*File型のグラフを評価したら、一意型によって結びつけられた全てのfreadlineが一度に評価されて、入力ファイルの全てのデータがメモリ上に読み込まれる。しかし、*File型のグラフを評価せず、[String]型のデータを順次評価していけば、遅延評価の仕組みによって、入力ファイルから必要になるたびに1行ずつ読み込んで処理が行われる。

このような仕組みにより、Cleanでは、効率性を失うことなく、ファイルからの入力をリストに変換して、汎用的なリスト操作によってファイルのデータを処理することが可能である。


入力ファイルを一部しか読み込まない場合

しかし、上のプログラムでは、入力ファイルのデータを全て処理する場合はよいが、最初の数行だけを処理して残りのデータは全く使わないという場合にも、全てのデータに対してfreadlineを評価する必要があるので、効率が非常に悪くなる可能性がある。

そのような場合には、Cleanの標準機能の範囲内では解決することができないが、OptEnvに含まれるOptReferenceモジュールを使うことで解決できる。OptReferenceは、同じオブジェクトを指し示す参照型という概念を導入するモジュールで、通常なら共有できない一意なオブジェクトへの参照をコピーすることで、一意なオブジェクトを安全に共有することを可能にするためのものである。

OptReferenceを使うと、ファイルの最初のn行を標準出力に出力する関数は、次のように書くことができる。

head_read :: !String !Int !*World -> *World
head_read path n w
    # (ok,fi,w) = fopen path FReadText w
    | not ok = trace_n ("cannot open file "+++path) w
    # (fo,w) = stdio w
      rfi = refer fi
      [rfi`,rfi:_] = copy rfi
      data = freadlines_ref rfi`
      data = take n data
      fo = fwritelines data fo
      (_,w) = fclose fo w
      (_,w) = unref fclose rfi w
    = w

refer, copy, unrefが、それぞれ、参照を作る、参照をコピーする、参照を閉じるための関数である。

ここでも、鍵はfreadlins_ref関数にある。この関数は、先の関数とは違い、受け取った参照を返していない。これは、もう1つの参照を経由してファイルを閉じることができるため、参照を返す必要がないためである。freadlines_refは次のように定義される。

freadlines_ref :: *(Ref *File) -> [String]
freadlines_ref rf
    # (l,rf) = access freadline rf
    = [chop l: freadlines_ref rf]

accessは、参照が指し示すオブジェクトに関数を適用するための関数で、ここではfreadlineを*File型オブジェクトに適用している。

この場合も遅延リストの効果により、実際に値が必要とされるまでfreadline関数が呼ばれることはないため、必要な分だけしかデータが読み込まれることない。さらに、参照型により、ファイルを閉じるときには評価が実際に進んだ所までしか評価せずに、その時点の*File型オブジェクトに対してfcloseを適用することができるため、余分なデータを読み込むことなくファイルを閉じることができる。

注意すべき点として、

    # (data,_) = access freadlines rfi`

という形で呼び出してはいけない。このように呼び出すと、freadlinesがまとまった1つの処理として参照に適用されるため、ファイルを閉じる前に全てのデータを読み込んでしまうためである。