Data.Enumeratorとの戦いの記録

これは Haskell Advent Calendar 2011 3日目の記事です。


Data.Enumerator はいろいろすごいらしいです。
どれぐらいすごいのかは

あたりが分かりやすい気もします。まあ全く理解できませんでしたが。


で、自分はこのライブラリで IO を使うときに例外処理をどう扱えばいいのかよくわからなくなったので、ここにその戦いの記録を残す次第であります。
「誰かこの辺分かりやすくまとめてくれたりとかしませんかね?(チラチラ」というのが今回の本題です。

Data.Enumerator に対する自分の理解

自分にとっての Data.Enumerator は「データを送る側とデータ受け取る側が完全に分離できて、(->)とかIOとかその他モナドを一緒に扱える奴」というイメージです。

Enumerator

データを送る側(Enumerator)は、Continue 型コンストラクタだった場合だけ処理して、そうじゃない場合は何だかよく分かりませんがとりあえず returnI を使っておけばいいようです。

-- Int をどこかに送る
enumLine :: E.Enumerator Int IO b
enumLine (E.Continue k) = ...  -- Continue だった場合にいろいろ処理する
enumLine step = E.returnI step -- Continue 以外はこんな定型文を書く

で、Continue だった場合、ここで得られた k に対して Chunk を渡すと、そのデータをどこかに送ることができます。

enumLine (E.Continue k) = do
  v <- liftIO $ read <$> getLine
  k (E.Chunks [v]) -- v をどこかに送る

ただしこれだと1回しか送れません。
2回目以降どうするかというと、

enumLine (E.Continue k) = do
  v <- liftIO $ read <$> getLine
  k (E.Chunks [v]) -- v をどこかに送る
  v2 <- liftIO $ (+10).read <$> getLine
  k (E.Chunks [v2]) -- これはダメなやり方

こうやって単純に2つ並べて書きたくなるのですが、これだと正しくデータを送れません。
しかも、コンパイルは通ってしまうので、頑張って人間が注意しないといけません。
複雑な上にコンパイルの助けすら借りられない状況があるという悲しい現実。

ということで、>>== という演算子で繋いで、そこで取れる(新しい)k を使います。

enumLine (E.Continue k) = do
  v <- liftIO $ read <$> getLine
  k (E.Chunks [v]) >>== enumLine'
  where
    enumLine' (E.Continue k) = do
      v2 <- liftIO $ (+10).read <$> getLine
      k (E.Chunks [v2]))
    enumLine' step = E.returnI step

こんな感じになるようです。
無限に回したいなら単に、

enumLine (E.Continue k) = do
  v <- liftIO $ read <$> getLine
  k (E.Chunks [v]) >>== enumLine

と書けばいいようです。

Iteratee

データを受け取る側は、headとか使えば、何だかよく分からないけれども、どこからともなくデータを受け取ることができるようです。

printIter :: Show a => E.Iteratee a IO ()
printIter = do
  v <- EL.head -- 何か知らないけどデータが取れる。型は Maybe a
  ...

v の型は Maybe a になり、データが取れたら Just になるようなので、出力して無限ループしておきます。
Nothing だった場合、適当に結果返せばいいようなので、とりあえず return () とかやっておきます。

printIter :: Show a => E.Iteratee a IO ()
printIter = do
  v <- EL.head
  case v of
    (Just x) -> do liftIO $ print x -- データがちゃんと取れたら出力
                   printIter -- してからループ
    Nothing  -> return ()
橋渡し

これで Enumerator と Iteratee は作れました。この2つはお互い全く見えてないので、これらをどこかで繋ぐ処理が必要になります。
で、最後に run とかやれば、最終的な結果(今回の場合は出力のみ)が取れます。

main = E.run_ (enumLine $$ printIter)

Enumerator と Iteratee がお互い全く見えてないというのが、モジュラリティが高くていい感じですね。


まあ自分の理解はこんなものです。超適当です。
ということで、例外処理との戦いです。

例外

例えば次のような処理があったとします。

-- ハンドルからひたすら1行ずつ読んで Int にして k に与える
hReadLines :: IO.Handle -> E.Enumerator Int IO b
hReadLines h (E.Continue k) = do
  str <- liftIO $ read <$> IO.hGetLine h
  k (E.Chunks [str]) >>== hReadLines h
hReadLines _ step = E.returnI step

-- ファイルハンドルのオープンとクローズをトレースしたいので関数にしておく
openFile | trace "openFile" True = IO.openFile "enumTest" IO.ReadMode
closeFile | trace "closeFile" True = IO.hClose

-- ファイルからひたすら1行ずつ k に与える
readLines :: E.Enumerator Int IO b
readLines step@(E.Continue _) = do
  -- ファイルを開いて
  h <- liftIO openFile
  -- ひたすら読んで
  r <- hReadLines h step
  -- ファイルを閉じる
  liftIO $ closeFile h
  return r
readLines step = E.returnI step

-- 合計しつつ出力する
printSumI :: E.Iteratee Int IO Int
printSumI = EL.foldM f 0
  where f sum v = do print sum
                     return $ sum+v

main = E.run_ (readLines $$ printSumI) >>= print

enumTest の中身はこんな感じ。

10
20
30

ごく普通に、ファイルから1行ずつ読んで渡しているだけです。
これを実行すると次のようになります。

openFile
0
10
30
test: enumTest: hGetLine: end of file

ファイルが終了してるかどうか判断してないので、例外が発生してファイルハンドルが閉じられていません。
ということで、ファイルが終了していた場合に終わるようにしてみましょう。

-- ハンドルからひたすら1行ずつ読んで Int にして k に与える
hReadLines :: IO.Handle -> E.Enumerator Int IO b
hReadLines h step@(E.Continue k) = do
  eof <- liftIO $ IO.hIsEOF h
  if eof then E.returnI step
         else do str <- liftIO $ read <$> IO.hGetLine h
                 k (E.Chunks [str]) >>== hReadLines h
hReadLines _ step = E.returnI step

これで実行すると

openFile
0
10
30
closeFile
60

となり、無事ファイルも閉じられ、出力が得られていることが分かります。


まあもちろん、こんな方法は全然ダメで、例えばファイルの中に数字以外の文字列を入れておくと、read 関数で例外が投げられてしまいます。


enumTest2 の中身

10
20
thirty

出力:

openFile
0
10
30
test: Prelude.read: no parse

残念な感じです。
ということで Data.Enumerator の中身を探してみたところ、catchError という関数があるようなので、これを使ってみることにしました。

readLines step@(E.Continue _) = do
  -- ファイルを開いて
  h <- liftIO openFile
  -- ひたすら読んで
  r <- hReadLines h step
       -- 例外が起きても確実にリソースを解放する
       `E.catchError` (\e -> do liftIO $ closeFile h
                                E.throwError e)
  -- ファイルを閉じる
  liftIO $ closeFile h
  return r

例外の行を追加しただけです。


で、これで実行してみたのですが、結果は同じ。throwError で投げられた例外じゃないと catchError できないようです。


ということでまた Data.Enumerator の中を探してみると tryIO なる関数がありました。なんだかこれを使えばいけそうです。

hReadLines h step@(E.Continue k) = do
  eof <- liftIO $ IO.hIsEOF h
  if eof then E.returnI step
         else do str <- E.tryIO $ read <$> IO.hGetLine h
                 k (E.Chunks [str]) >>== hReadLines h

liftIO の代わりに tryIO を使ってみました。
しかしこれも同じです。 catchError で捉えることができず、ファイルは閉じられません。
これは別に Data.Enumerator のせいではなく、単に read が IO 処理じゃないかららしいので、Control.Exception の evaluate で評価してやることにします。

hReadLines h step@(E.Continue k) = do
  eof <- liftIO $ IO.hIsEOF h
  if eof then E.returnI step
         else do str <- E.tryIO $ evaluate . read =<< IO.hGetLine h
                 k (E.Chunks [str]) >>== hReadLines h

これで実行すると、

openFile
0
10
closeFile
test: Prelude.read: no parse

となり、無事ファイルが閉じられます。
何だか 30 という出力がないというのが不思議な感じですが、まあそれは気にしないことにしましょう。


これでどんなエラーが発生してもファイルハンドルを閉じることができているかというと、できていません。
Enumerator 側で例外が起こっても何とかなるようにはなりましたが、Iteratee 側で何らかの問題が発生した場合には、相変わらずファイルハンドルが閉じられません。

printSumI = EL.foldM f 0
  where f sum v = do print sum
                     -- 例外投げてみる
                     throw $ ErrorCall "error"
                     return $ sum+v

このように変えて出力してみると、こうなります。

openFile
0
test: error

残念です。非常に残念な感じです。


foldM の実装では(当然 IO 専用ではないので)lift を使ってるから、もしかしてこれのせいじゃないのか?と思ったので、次のように自前で IO 専用の foldM の実装を書き、tryIO を使うようにしてみました。

foldIO f v = do
  x <- EL.head
  case x of
    Nothing -> return v
    (Just x') -> do s <- E.tryIO $ f v x'
                    foldIO f s

printSumI = foldIO f 0
  where f sum v = do print sum
                     throw $ ErrorCall "error"
                     return $ sum+v
openFile
0
closeFile
test: error

こうすると、例外が発生しても見事 Enumerator 側に伝えることができるようになりました。


ということで、最終的な実装は次のようになりました。

-- ハンドルからひたすら1行ずつ読んで Int にして k に与える
hReadLines :: IO.Handle -> E.Enumerator Int IO b
hReadLines h step@(E.Continue k) = do
  eof <- E.tryIO $ IO.hIsEOF h
  if eof then E.returnI step
         else do str <- E.tryIO $ evaluate . read =<< IO.hGetLine h
                 k (E.Chunks [str]) >>== hReadLines h
hReadLines _ step = E.returnI step

-- ファイルハンドルのオープンとクローズをトレースしたいので関数にしておく
openFile | trace "openFile" True = IO.openFile "enumTest2" IO.ReadMode
closeFile | trace "closeFile" True = IO.hClose

-- ファイルからひたすら1行ずつ k に与える
readLines :: E.Enumerator Int IO b
readLines step@(E.Continue _) = do
  -- ファイルを開いて
  h <- liftIO openFile
  -- ひたすら読んで
  r <- hReadLines h step
       -- 例外が起きても確実にリソースを解放する
       `E.catchError` (\e -> do liftIO $ closeFile h
                                E.throwError e)
  -- ファイルを閉じる
  liftIO $ closeFile h
  return r
readLines step = E.returnI step

foldIO f v = do
  x <- EL.head
  case x of
    Nothing -> return v
    (Just x') -> do s <- E.tryIO $ f v x'
                    foldIO f s

-- 合計しつつ出力する
printSumI :: E.Iteratee Int IO Int
printSumI = foldIO f 0
  where f sum v = do print sum
                     throw $ ErrorCall "error"
                     return $ sum+v

main = E.run_ (readLines $$ printSumI) >>= print


ただ、とりあえずこれでうまくいきましたが、ほんとにこれでいいんでしょうか?
必ず Iteratee 側を変更できるわけではありませんし、Iteratee 側にこんなめんどいことをさせるとかありえないですし、処理が IO 専用になっていて残念な感じですし、IO 以外にも例外投げる処理(readとか)はありますし、これは何か間違ってるような気がします。


そもそも Data.Enumerator って遅延 I/O からの解放とかそういうのもあるっぽいのですが、その辺を実際どう書けばいいのかっていうのを見たことが無いんですね。日本語しか読まないのが一番の原因でしょうけど。
ということで誰かこの辺を教えてください!お願いします!


追記:
無事教えて貰えました!ありがとうございます!

Enumerator 側で記述する Iteratee を runIteratee して実行してまた Iteratee に戻してやるだけで良かったようです。この一ヶ月間は一体何を悩んでいたのか分からなくなるレベルの簡単な答えでした…。