Enumerator Package – Yet Another Iteratee Tutorial

preferred

2011-02-10 14:30:18

バレンタインチョコ欲しい! 田中です。

Iterateeの素晴らしいチュートリアルを見つけたので、今回はその翻訳をお届けしようと思います。以前、The Monad Reader Issue 16 のiterateeの記事をベースにした解説記事を書いたのですが、こちらの記事はかなり概念的なところから始まり、結構天下り的にiterateeの定義を受け入れていたのに対して、こちらの記事は、一貫して具体例からの抽象化で話が進み、また易しく書かれているので、比較的理解しやすいと思います。また、実際の実装に即して解説されていますので、読み終えて即実際に使ってみることが出来るでしょう。

このチュートリアルを書かれたMichael Snoymanという方は、現在YesodというHaskellのWebフレームワークを精力的に開発されています。Yesodには実際にiterateeがふんだんに用いられており、それが堅牢でハイパフォーマンスのWebサーバを支えています。このWebフレームワークも大変面白く興味深いものですので、またいずれ紹介したいと思います。

Part 1:Iteratee

イントロダクション

enumeratorはHaskellの新しいパターンです。しかし残念なことに、とても利用を始めにくい状況にあります。

  • 複数のわずかに違ったアプローチの実装がある
  • いくつかの実装では、ネーミングが信じられないほど紛らわしい
  • チュートリアルが実在の実装に即していないので、実際に使い始めるには大変

このチュートリアルが、そのギャップを埋めることを期待しています。私は enumerator パッケージ をベースに解説しようと思います。私はバージョン0.4.0.2を使っていますが、もっと古いバージョンや、多分新しいバージョンにも適用できると思います。このパッケージは iteratee パッケージ よりも新しく、ユーザも少ない(訳注:現在ではenumeratorパッケージの方が被参照数が多くなっています)ですが、私は次のような理由でこれを選びました。

  • パッケージの依存が少ない
  • パッケージが小さく、拡張しやすい
  • ネーミングがベター

しかし、どちらのパッケージも同じ概念に基づいて作られているので、片方を学べばもう片方は容易に理解できるでしょう。

3つのパート

このチュートリアルは3つのパートからなります。enumeratorパッケージには、使うために学ぶべき3つのメインコンセプト、iteratee、enumerator、enumeratee、があります。それぞれの基本的な定義は次のような感じです。

  • iterateeは消費者(consumer)である。データを貰って、何かを行う
  • enumeratorは生産者(producer)である。iterateeにデータを与える
  • enumerateeはパイプである。enumeratorからデータを受け取り、iterateeに渡す

Enumeraotorの何がいいのか

実際にライブラリを見ていく前に、なぜこれを使うべきなのか、モチベーションを見てみましょう。私がenumeratorパッケージを用いた、いくつかの実際の例があります。

  • データベースからデータを読むとき、すべての値を一度にメモリに取ってくる必要はない。代わりに、その値を1つずつ処理する関数に渡したい
  • YAMLファイルを処理するとき、全体の構造を読むのではなく、1つか2つのレコードの値のみを処理したい
  • HTTP経由でファイルをダウンロードして、その結果をファイルに保存したい時、一旦全体をメモリに保存し、それからファイルに書き出すのは嬉しくない。enumeratorはI/Oのインターリーブを簡単に行える

これらの多くの問題は、遅延I/Oを用いれば解決できます。しかし、遅延I/Oは必ずしも万能薬ではありません。遅延I/Oの落とし穴については、Olegの記事を読むと良いでしょう。

直感的な説明

数のリストを合計する関数を書きましょう。スペースリークなどの問題は、今は重要ではないので考えないことにします。

sum1 :: [Int] -> Int
sum1 [] = 0
sum1 (x:xs) = x + sum1 xs

数がリストとして与えられない場合はどうでしょう?代わりにユーザーがコマンドラインから数を一行ずつ入力し、”q”で終端するとします。これは次のように書けます。

getNumber :: IO (Maybe Int)
getNumber = do
x <- getLine
if x == "q"
then return Nothing
else return $ Just $ read x

これを用いると、sumは次のようになります。

sum2 :: IO Int
sum2 = do
maybeNum <- getNumber
case maybeNum of
Nothing -> return 0
Just num -> do
rest <- sum2
return $ num + rest

データソースの変更にともなって、sumの形が完全に変わりました。これは嬉しくないので、なんとか一般化したいです。2つの関数から共通部分を取り出します。両方とも、値を取り出し、終端に至ると結果を返し終了するという形になっています。

Streamデータ型

enumeraorパッケージの最初のデータ型はストリームです。

data Stream a = Chunks [a] | EOF

EOFは、もうデータが無いことを示します。Chunkは単純に複数のデータを保持します。これは効率のためです。これを用いてsum2を書き換えてみましょう。

getNumber2 :: IO (Stream Int)
getNumber2 = do
maybeNum <- getNumber — 元のgetNumber関数を使う
case maybeNum of
Nothing -> return EOF
Just num -> return $ Chunks [num]

sum3 :: IO Int
sum3 = do
stream <- getNumber2
case stream of
EOF -> return 0
Chunks nums -> do
let nums’ = sum nums
rest <- sum3
return $ nums’ + rest

sum2より良くなったわけではないですが、これはStream型の使い方を示しています。現状の問題は、sum3内にgetNumber2、すなわちデータソースをハードコーディングしていることです。

一つの答えは、sum関数の引数としてデータソースを渡すことです。

sum4 :: IO (Stream Int) -> IO Int
sum4 getNum = do
stream <- getNum
case stream of
EOF -> return 0
Chunks nums -> do
let nums’ = sum nums
rest <- sum4 getNum
return $ nums’ + rest

これはうまく動きます。しかしもう少し込み入ったケース、2つのデータソースを合計したいような場合はどうでしょう。例えば、ユーザのコマンドライン入力と、加えてHTTPからのデータも足し合わせたいとなると、これはうまくいきません。ここの問題は”制御”にあります。sum4がgetNumを呼ぶ形になっていますが、これは”pull”データモデルです。enumeratorは制御モデルを”push”データモデルへと反転させます。これは複数のデータソースからデータを得るなどのクールなことを可能にし、データソースのためのリソース確保などを容易にします。

Stepデータ型

sum操作の状態を表す新しいデータ型が必要です。次の三つの状態を許すことにしましょう。

  • もっとたくさんのデータを必要としている
  • すでに結果の計算が完了している
  • エラー状態。これは利便性のためで、厳密には不要(EitherTモナドなどを用いてエラー処理することが可能)だが、この方がシンプル

これはStep型の三つのコンストラクタに対応します。エラーは

Error SomeException

としてモデル化します。HaskellのExtensible Exceptionを利用します。計算済みは、

Yield b (Stream a)

a は iteratee への入力、b は出力の型です。これは、iterateeが結果の値と、”食べ残し”を同時に生成できるようにしています(sumは入力を全部食うのでこういうことは起こらない。そのような例は後で示します)。

もっとデータを必要としているiterateeの状態はどうやって表現すればいいでしょうか。内部状態を表現する型を作って、それにデータを渡すようにしたいと思うかもしれませんが、そうはしません。我々は単に関数を使います(とってもHaskellらしいよね?)。

Continue (Stream a -> Iteratee a m b)

エウレカ!遂に Iteratee 型が出てきました!実際のところ、Iterateeはとても退屈なデータ型で、Step型に対してクールなインスタンス(モナドなど)を定義したりするためだけのものです。

newtype Iteratee a m b = Iteratee (m (Step a m b))

“Iterateeは単にStepをモナドで包んだnewtypeだ” このことは重要です。それを頭に入れて、enumeratorパッケージの定義を見てみましょう。このことを知っているので、Continueコンストラクタは次のように考えることができます。

Continue (Stream a -> m (Step a m b))

関数はいくつかの入力をとり、新しいiterateeの状態を返します。これはとてもシンプルなアプローチです。Step型を用いたsum関数の定義を見てみましょう。

sum5 :: Monad m => Step Int m Int — 入力Int、任意のモナド、出力Int
sum5 =
Continue $ go 0 — よくあるパターン。常にContinueから始める
where
go :: Monad m => Int -> Stream Int -> Iteratee Int m Int
— 新しい値をアキュームレータに加え、新しいContinueを作る
go runningSum (Chunks nums) = do
let runningSum’ = runningSum + sum nums
— 次の行は"醜い"。ヘルパ関数を作るのが良い
— 後で改良する
Iteratee $ return $ Continue $ go runningSum’
— 最終結果を生成する
go runningSum EOF = Iteratee $ return $ Yield runningSum EOF

最初の行は、iterateeを初期状態で初期化しています。他のsumと同様に、どこかで明示的に0から始めるということを記述しなければなりません。実際の計算はgoで行われます。goの最初の引数としてiterateeの状態を渡していることに注意してください。これはiterateeのとても一般的なパターンです。

EOFとChunk、二つの異なったcaseをハンドルしなければいけません。EOFを受け取ったら、Yieldを返さなければなりません(場合によってはErrorも有り得ますが、Continueを返してはいけません)。sumの場合、単にrunningSumを返せば良いです。Chunkを受け取った場合は、単純に足しあわせて、新しいContinueをつくって返します。

関数を綺麗に改良するのを考えましょう。Iteratee . returnという形は良く出てくるので、用意するに値します(実際enumeratorパッケージで定義されています)。

returnI :: Monad m => Step a m b -> Iteratee a m b
returnI = Iteratee . return

これを用いると、

go runningSum EOF = Iteratee $ return $ Yield runningSum EOF

が、次のようになります。

go runningSum EOF = returnI $ Yield runningSum EOF

しかし、もっと便利な関数が用意されています。

yield :: Monad m => b -> Stream a -> Iteratee a m b
yield x chunk = returnI $ Yield x chunk

さっきの行はこうなります。

go runningSum EOF = yield runningSum EOF

同様に、これが

Iteratee $ return $ Continue $ go runningSum’

こうなります。

continue $ go runningSum’

IterateeのMonadインスタンス

これはすべてとてもうまくいっています。我々はiterateeに任意のモナドから数値を与えることができます。複数のソースから入力を取ることすらできます(今はやりませんが、Part 2でやります)。しかし正直なところ、sum5は醜い関数です。もっと簡単に書けないものでしょうか?

実際には、Iteratee型は型クラスにするのを簡単にするために存在しています。これにはMonadも含みます。つまり、IterateeはMonadとして扱うことができます(IterateeをどうやってMonadのインスタンスにしているかの説明は割愛します)。次のコードに、どうやってモナディックに使うかを示します。

sum6 :: Monad m => Iteratee Int m Int
sum6 = do
maybeNum <- head — Preludeのheadではない!
case maybeNum of
Nothing -> return 0
Just i -> do
rest <- sum6
return $ i + rest

head関数はPreludeで定義されているものではなく、Data.Enumeratorモジュールのものです。

head :: Monad m => Iteratee a m (Maybe a)

これは次の入力の次のピースを取り出すと考えてください。後でこの関数をより深く見ていきます。

sum6をsum2と見比べて下さい。とても良く似ています。このように、複雑なiterateeを単純なiterateeとモナドによって作ることができます。

Interleaved I/O

さて、今度は全く違う問題を考えましょう。いくつかの文字列をもらって、それをそれぞれ1行に出力したい。1つのアプローチは遅延I/Oです。

lazyIO :: IO ()
lazyIO = do
s <- lines `fmap` getContents
mapM_ putStrLn s

しかし、これには2つの欠点があります。

  • 一つの入力ソース、stdinに結び付けられている。これは引数としてデータソースを受け取るように変更できる
  • データソースが乏しいリソースだった場合(とても忙しいWebサーバのファイルハンドルなど)、遅延I/Oがファイルハンドルを解放する保証はない

高レベルモナディックiterateeでのアプローチを見てみましょう。

interleaved :: MonadIO m => Iteratee String m ()
interleaved = do
maybeLine <- head
case maybeLine of
Nothing -> return ()
Just line -> do
liftIO $ putStrLn line
interleaved

liftIOはtransformersパッケージの関数で、これはIOを任意のMonadIOアクションへと昇格させます。このiterateeは状態を持たないというのと、結果の値には興味がなく、副作用にのみ興味があることに注意して下さい。

head の実装

最後の例として、head関数を実装しましょう。

head’ :: Monad m => Iteratee a m (Maybe a)
head’ =
continue go
where
go (Chunks []) = continue go
go (Chunks (x:xs)) = yield (Just x) (Chunks xs)
go EOF = yield Nothing EOF

sum6関数のように、これも内部関数”go”をcontinueでラップします。しかし、今回は3つのclauseを持ちます。一つ目は Chunk [] で、これはストリームがまだアクティブだが今は利用出来る値がないということを示しています。iterateeはこの空のチャンクを無視すべきです。

二つ目は、いくつかの値が利用出来る場合。この場合は、最初の値をyieldして、残りの値を食べ残しにします。

最後の場合、EOFを受け取ったときはNothingを返します。

練習問題

  • sum6をliftFoldL’を使って書きなおしなさい
  • consume関数の、高レベルの関数(headなど)を用いた実装と、ローレベルのものだけを用いた実装を作りなさい
  • 1つおきの値を返すconsumeの変形版を、ハイレベル・ローレベル両方書きなさい

まとめ

  • Iterateeは、型クラスのインスタンスを作るためのStep型のシンプルなラッパー
  • モナドを使って、Iterateeはシンプルに書ける
  • iterateeの3つの状態は、Continue(まだ処理中)、Yield(完了)、Error(デュフ)
  • 望ましい振る舞いのiterateeはEOFを受け取ったらContinueを返さない

Part 2:Enumerator

さて、Iterateeを書けるようになりましたが、これだけでは役に立ちません。このパートでは、enumeratorの説明をします。

値を取り出す

前回、幾つかのiterateeを書きましたが、そこからどうやって値を取り出すのかをまだ知りません。Iteratee型が単にStep型をラップしたものだということを思い出しましょう。

newtype Iteratee a m b = Iteratee { runIteratee :: m (Step a m b) }

最初にまずIterateeをunwrapして、中のStepの値を扱えるようにしましょう。Stepには3つのコンストラクタ、Continue、Yield、Errorがありました。Errorの場合はをEitherの結果として返します。Yieldは既に欲しいデータが与えられています。

トリッキーなのはContinueの場合です。もっと値を欲しているiterateeがあるとします。ここではEOFが役に立ちます。iterateeにやっていることを終わらせて、結果をよこすように言うということになります。前章を覚えていたら、望ましい振る舞いのiterateeはEOFを受け取ったらContinueは返さないことが分かるでしょう。なので、

extract :: Monad m => Iteratee a m b -> m (Either SomeException b)
extract (Iteratee mstep) = do
step <- mstep
case step of
Continue k -> do
let Iteratee mstep’ = k EOF
step’ <- mstep’
case step’ of
Continue _ -> error "Misbehaving iteratee"
Yield b _ -> return $ Right b
Error e -> return $ Left e
Yield b _ -> return $ Right b
Error e -> return $ Left e

幸いなことに、これをあなたが再定義する必要はありません。enumeratorパッケージにrunおよびrun_として用意されています。さて、sum6関数を使ってみましょう。

main = run_ sum6 >>= print

これを実行すると結果は0になるでしょう。これは重要なことです。iterateeは単にやってきたデータをどう処理するかだけではなく、現在の処理の状態も表しています。今回の場合、sum6の初期状態である0から何も変更していません。

iterateeをマシンとして考えるアナロジーがあります。データを与えるとiterateeの内部状態が変わりますが、あなたはそれらの変化を外から見ることはできません。データを与え終わったら、あなたはボタンを押して結果を取り出します。あなたが何もデータを与えなければ、初期状態が返ってくるでしょう。

データを与える

我々は1から10の和など、いくつかの数の和を計算したい。iterateeであるsum6に対して、これを食わせるのに幾つかの方法があります。Iterateeをunwrapして、Stepの値を直に扱う必要があります。

sum6の場合、Continueがくることが分かっているので、次の関数は安全です。

sum7 :: Monad m => Iteratee Int m Int
sum7 = Iteratee $ do
Continue k <- runIteratee sum6
runIteratee $ k $ Chunks [1..10]

しかし、一般的にはどんなコンストラクタが来るかは分からないので、きちんとContinue、Yield、Errorを扱う必要があります。Continueの場合の処理は先ほど見たとおり、データを与えれば良いです。YieldとErrorの正しい動作は、なにもしないです。そのまま最後の結果として返せばいいのです(成功のYieldも、失敗のErrorも)。なので、上の関数の”正しい”定義は、次のようになります。

sum8 :: Monad m => Iteratee Int m Int
sum8 = Iteratee $ do
step <- runIteratee sum6
case step of
Continue k -> runIteratee $ k $ Chunks [1..10]
_ -> return step

Enumerator型シノニム

sum7とsum8でやったのは、Iterateeの変換でした。しかし、それはとても制限された方法でした。元のIteratee関数(sum6)をハードコードしているところです。これは単に引数にすることができます。

sum9 :: Monad m => Iteratee Int m Int -> Iteratee Int m Int
sum9 orig = Iteratee $ do
step <- runIteratee orig
case step of
Continue k -> runIteratee $ k $ Chunks [1..10]
_ -> return step

引数のIterateeの値は必ずunwrapしなければならないので、引数をStep型にすればもっとシンプルになります。

sum10 :: Monad m => Step Int m Int -> Iteratee Int m Int
sum10 (Continue k) = k $ Chunks [1..10]
sum10 step = returnI step

この型シグニチャ(Stepをとって、Iterateeを返す)はとても良く出てきます。

type Enumerator a m b = Step a m b -> Iteratee a m b

sum10のシグニチャは次のように書けます。

sum10 :: Monad m => Enumerator Int m Int

EnumeratorとIterateeをつなぐ幾つかのヘルパ関数が必要になります。

applyEnum :: Monad m => Enumerator a m b -> Iteratee a m b -> Iteratee a m b
applyEnum enum iter = Iteratee $ do
step <- runIteratee iter
runIteratee $ enum step

Enumeratorはデータを与えることによって、Iterateeを初期状態から新しい状態に変換します。これを使うのは、次のようなコードになります。

run_ (applyEnum sum10 sum6) >>= print

これは期待したとおり55を返します。しかも今回はenumeratorの利点があります。複数のデータソースを使うことができます。もう一つのEnumeratorがあるとしましょう。

sum11 :: Monad m => Enumerator Int m Int
sum11 (Continue k) = k $ Chunks [11..20]
sum11 step = returnI step

それから、sum6を単純にsum10、sum11両方のenumeratorに適用します。

run_ (applyEnum sum11 $ applyEnum sum10 sum6) >>= print

結果は210にります( (1+20)*10 = 210 )。しかし、心配しないでください。applyEnumをあなたが書く必要はありません。enumeratorは同じ動作をする $$ という演算子を提供しています。これの型シグニチャはすこし恐ろしいことになっていますが、それはapplyEnumの一般化です。そしてこれはapplyEnumと同様に動作します。さらにコードは読み易くすらなります。

run_ (sum11 $$ sum10 $$ sum6) >>= print

$$ は ==<< のシノニムで、これは単純に >>== をflipしたものです。私は $$ のほうが読みやすいと思いますが、異論は認めます。

幾つかの組み込みenumerator

もちろん、数を渡す関数全体を毎回書くのはとても酷いやり方です。リストを引数にして次のように書けます。

sum12 :: Monad m => [Int] -> Enumerator Int m Int
sum12 nums (Continue k) = k $ Chunks nums
sum12 _ step = returnI step

しかし、Intのリストに制限する必要はありません。簡単に一般化できます。

genericSum12 :: Monad m => [a] -> Enumerator a m b
genericSum12 nums (Continue k) = k $ Chunks nums
genericSum12 _ step = returnI step

実際にはこれは enumList という組み込み関数になっています。enumListは整数を更にひとつ取ります。これはChunkに幾つづつ分割するかを指定します。

run_ (enumList 5 [1..30] $$ sum6) >>= print

(465になります。確かめてみて下さい)enumListの一つ目の引数は結果には影響しませんが、パフォーマンスに影響するかもしれません。

Data.Enumerator には他のenumeratorも定義されています。enumEOFはiterateeに単にEOFを渡します。concatEnumsはもう少し面白いです。複数のenumeratorを組み合わせます。例えば、

run_ (concatEnums
[ enumList 1 [1..10]
, enumList 1 [11..20]
, enumList 1 [21..30]
] $$ sum6) >>=

これが465になります。

幾つかのpureでない入力

Enumeratorはpureな値でないものを扱う時がより面白いです。このチュートリアルの最初のパートでは、コマンドラインからユーザに数を入力させるというのをやりました。

getNumber :: IO (Maybe Int)
getNumber = do
x <- getLine
if x == "q"
then return Nothing
else return $ Just $ read x

sum2 :: IO Int
sum2 = do
maybeNum <- getNumber
case maybeNum of
Nothing -> return 0
Just num -> do
rest <- sum2
return $ num + rest

我々はこれをpullモデルと呼びました。sum2はgetNumberから値を”pull”します。getNumberを”pullee”(pullされるもの)ではなく、”pusher”(pushするもの)として書けないでしょうか。

getNumberEnum :: MonadIO m => Enumerator Int m b
getNumberEnum (Continue k) = do
x <- liftIO getLine
if x == "q"
then continue k
else k (Chunks [read x]) >>== getNumberEnum
getNumberEnum step = returnI step

最初に、我々は渡されたコンストラクタをチェックします。そして、Continueの時だけ処理をします。もしContinueだったら、我々は1行の文字列を入力します。もしその行が”q”(入力の終了を示す)だったら、なにもしません。あなたはEOFを渡すべきだと考えるかもしれませんが、もしそれをやると、他のデータをiterateeに食わせることが出来なくなってしまします。代わりに我々は、元のStepの値を単純に返すということをします。

もし行が”q”でなかったら、我々はそれをreadでIntに変換します。ChunkコンストラクタでStream型の値を作り、それをkに渡します(もっときちんとやるなら、xが本当にIntなのかをチェックして必要に応じてErrorを返します。これは読者への練習問題とします)。この式の型シグニチャを見てみます。

k (Chunks [read x]) :: Iteratee Int m b

この行の残り( >>== getNumberEnum )を書かなくても、これは型チェックを通ります。しかし、残りのコマンドラインの入力を読まないことになります。残りの部分が、このEnumeratorをループにしています。

この関数について見るべき最後のところは、型シグニチャです。

getNumberEnum :: MonadIO m => Enumerator Int m b

これはIntを受け取るIterateeにIntを食わせるenumeratorで、出力は任意であるということを表しています。これは同じenumeratorで劇的に異なる動作を可能にします。

intsToStrings :: (Show a, Monad m) => Iteratee a m String
intsToStrings = (unlines . map show) `fmap` consume

そして、これらはどちらもうまく動作します。

run_ (getNumberEnum $$ sum6) >>= print
run_ (getNumberEnum $$ intsToStrings) >>= print

練習問題

  • 入力をStringとして読み込むenumeratorを書きなさい。次のiterateeとともに動作することを確かめなさい

    printStrings :: Iteratee String IO ()
    printStrings = do
    mstring <- head
    case mstring of
    Nothing -> return ()
    Just string -> do
    liftIO $ putStrLn string
    printStrings

  • 上と同様に、単語を読み込む(空白区切り)enumeratorを書きなさい。これも同じiterateeで動作させよ
  • getNumberEnumの正しいエラーハンドリングを実装せよ
  • getNumberEnumをstdinの代わりにファイルから読み込むように変更せよ
  • getNumberEnumを用いて、2つの違うファイルの数を合計せよ

まとめ

  • enumeratorはstep transformerです。iterateeにデータを与え、更新された状態の、新しいiterateeを生成します
  • 複数のenumeratorが一つのiterateeに対してデータを与えることが出来ます。最後にrunもしくはrun_を使って結果を取り出すことができます
  • 我々は $$、>>==、==<< を用いて、enumeratorにiterateeを適用することができます
  • enumeratorを書くときは、我々はContinue状態にのみデータを与えるようにしなければならない。YieldとErrorはすでに最終結果を持っています

Part3: Enumeratee

getNumberEnumの一般化

前章で、次のようなシグニチャを持つgetNumberEnumを作りました。

getNumberEnum :: MonadIO m => Enumerator Int m b

あなたの記憶が確かならば、getNumberEnumはIntのストリームを生成します。実際には、getNumberEnum関数は行をstdinから読んで、それをIntに変換し、iterateeに食わせます。”q”を入力したら行を読むのをやめます。

しかし、この機能はIntじゃなくても有用だと思われます。文字列をもっと別のもの、例えばBoolとして扱いたい場合もあります。getNumberEnumのStringをIntに変換しないバージョンを簡単に作ることができます。

lineEnum :: MonadIO m => Enumerator String m b
lineEnum (Continue k) = do
x <- liftIO getLine
if x == "q"
then continue k
else k (Chunks [x]) >>== lineEnum
lineEnum step = returnI step

クール。これをsumIter関数(前回のsum6をリネームした)につなぎ込みましょう。

lineEnum $$ sumIter

実際にはこれは型チェックを通りません。lineEnumはStringを生成しますが、sumIterはIntを受け取るからです。このため、少し変更する必要があります。

sumIterString :: Monad m => Iteratee String m Int
sumIterString = Iteratee $ do
innerStep <- runIteratee sumIter
return $ go innerStep
where
go :: Monad m => Step Int m Int -> Step String m Int
go (Yield res _) = Yield res EOF
go (Error err) = Error err
go (Continue k) = Continue $ strings -> Iteratee $ do
let ints = fmap read strings :: Stream Int
step <- runIteratee $ k ints
return $ go step

ここでやっていることは、オリジナルのiterateeをラップすることです。一般的に、最初にIterateeと、Stepをくるんでいるモナドをunwrapする必要があります。一度innerStepを取り出せば、それをgo関数に渡し、単純にストリームの値をIntに変換すれば良いのです。

もっと一般的に

任意のiterateeにこの変換を適用できるなら、もっと素晴らしいです。そのためにまず、iterateeと変換関数をくくり出すことにします。

mapIter :: Monad m => (aOut -> aIn) -> Iteratee aIn m b -> Iteratee aOut m b
mapIter f innerIter = Iteratee $ do
innerStep <- runIteratee innerIter
return $ go innerStep
where
go (Yield res _) = Yield res EOF
go (Error err) = Error err
go (Continue k) = Continue $ strings -> Iteratee $ do
let ints = fmap f strings
step <- runIteratee $ k ints
return $ go step

これは次のようにして呼ぶことができます。

run_ (lineEnum $$ mapIter read sumIter) >>= print

ここで注目すべきものはありません。これは単に前のバージョンと同じです。面白いのはこれと同等のenumeratorパッケージの組み込み関数、mapです。これはしかし、mapIterとはシグニチャが大幅に異なります。

map :: Monad m => (ao -> ai) -> Enumeratee ao ai m b

Enumerateeの定義は次のとおり。

type Enumeratee aOut aIn m b = Step aIn m b -> Iteratee aOut m (Step aIn m b)

mapのシグニチャを展開すると次のようになります。

map :: Monad m => (aOut -> aIn) -> Step aIn m b -> Iteratee aOut m (Step aIn m b)

この型シグニチャが複雑なのは何なのでしょうか?これはmap自体には必要はないのですが、他の同様の関数には必要なのです。しかし、今は迷子にならないよう、このmap関数にフォーカスしましょう。最初の引数は前と同じmapping関数です。2つ目の引数がStepになっています。これはそんなに驚くべきことではありません。mapIterではIterateeを引数として取り、内部でunwrapしてStepを取り出していました。

しかし、返り値の型には何が起きているのでしょう?このデータ型の意味を考えてみましょう。これはaOutを食い、Step(もしくは、新しいIterateeとも言える)を返すIterateeです。この種の直感的な感覚に適っています。入力を1つのソースから取り出し、Stepを新しい状態へと変換する中間の人を導入しなければなりません。

しかし、ここが全体で最もトリッキーなところでしょう。我々は実際にはどうやってmap関数を使うのでしょうか?使い方はEnumerateeの型シグニチャが前章で定義したEnumeratorに十分近いことから分かります。

map read $$ sumIter

しかし、この式の型は少し恐ろしいです。

Iteratee String m (Step Int m Int)

IterateeがStepを単にwrapしたものだということを思い出すと、我々がここで得たものは、Stringを受け取り、Intを受け取りIntを生成するIteratee、を生成するIterateeです。この奇妙な型は、iterateeの1つのすごいトリックを可能にします。それは、複数のデータソースからの入力です。例えば、文字列ををこの醜い型のものに突っ込むと、”新しい” Intを食いIntを生成するiterateeが得られます。

(この部分がすべて理解できなくても心配しないで。この話はもう終わりです)

しかし多くの場合、このすべてのパワーを必要としません。今回の場合、我々はEnumerateeにIterateeを差し込んで、新しいIteraeteが欲しいだけです。mapをsumIterにアタッチして、新しい、Stringを食ってIntを返すIterateeを生成したいのです。そのために、次のような関数が必要になります。

unnest :: Monad m => Iteratee String m (Step Int m Int) -> Iteratee String m Int
unnest outer = do — using the Monad instance of Iteratee
inner <- outer — inner :: Step Int m Int
go inner
where
go (Error e) = throwError e
go (Yield x _) = yield x EOF
go (Continue k) = k EOF >>== go

我々はこれを次のようにして使えます。

run_ (lineEnum $$ unnest $ map read $$ sumIter) >>= print

unnest関数はData.EnumearatorにjoinIという名前で用意されています。なので、実際にはこう書きます。

run_ (lineEnum $$ joinI $ map read $$ sumIter) >>= print

Skipping

もっと面白いenumerateeを書いてみましょう。これは値を1つおきにスキップするものです。

skip :: Monad m => Enumeratee a a m b
skip (Continue k) = do
x <- head
_ <- head — 1つ読み飛ばす
case x of
Nothing -> return $ Continue k
Just y -> do
newStep <- lift $ runIteratee $ k $ Chunks [y]
skip newStep
skip step = return step

ここで面白いのは、アプローチがEnumeratorと似ていることです。Stepの値がContinueかをチェックし、そうでなければ単にそれを返します。Continueの場合は、2つの値をheadで取り出し、値がなければもとのContinueの値を返します。Enumeratorと同様に、終端でもEOFを渡しません。なのでもっと他のデータを食わせることが出来ます。データがあれば、それをiterateeに渡しループします。

そして、これがenumerateeのクールなところなのですが、これらをチェインすることができます。

run_ (lineEnum $$ joinI $ skip $$ joinI $ map read $$ sumIter) >>= print

これは、行を読み、1行おきに捨て、StringからIntに変換して、それらを合計します。

Real life examples: http-enumerator パッケージ

私はこのチュートリアルを、http-enumeratorパッケージの仕事として始めました。私は実際に使ってみることが、enumerartorが実生活で利益をもたらすことの良い説明になると思います。HTTPには、3つの異なる、レスポンスボディを返す方法があります。

  • Chunked encoding。Webサーバは後続のチャンクの長さを表す16進文字列を送り、それからその長さのチャンクを送る。最後は長さ0のチャンクで終端される
  • Content length。WebサーバはBodyを送る前にBodyの長さをヘッダとして送る
  • 何も無い。この場合、BodyはEOFで終端される

加えて、ボディはGZIPで圧縮されているかもしれない(し、されていないかもしれない)。これをenumerateeを用いて行います。それぞれ、chunkedEncoding、contentLength、unzip、すべての型シグニチャは、Enumeratee ByteString ByteString m b になります。これらを組み合わせて、次のようにコードが書けます。

let parseBody x =
if ("transfer-encoding", "chunked") `elem` responseHeaders
then joinI $ chunkedEncoding $$ x
else case mlen of
Just len -> joinI $ contentLength len $$ x
Nothing -> x — enumerateeを適用しない
let decompress x =
if ("content-encoding", "gzip") `elem` responseHeaders
then joinI $ ungzip $$ x
else x
run_ $ socketEnumerator $$ parseBody $ decompress $ bodyIteratee

サーバからのデータは、parseBody関数に入力されます。parseBodyでは、chunked encodingのときはchunkedEncodingが適用され、長さが指定されているときは、その長さしか読まないようにcontentLengthが適用されます。それ以外の時は、parseBodyは何も適用しません。

どの場合にも、rawボディがdecompressに渡されます。ボディがGZIPされていれば、それを解凍し、そうでなければdecompressはなにもしません。最後に、ユーザが与えたbodyIteratee関数によってボディはパーズされます。ユーザはどのステップのデータが渡されるのか知る由もありません。

練習問題

  • 16進文字列(”DEADBEEF”のような)を受け取り、Word8を返すenumerateeを定義しなさい。型シグニチャは Enumeratee Char Word8 m bになるはずです
  • 逆のenumerateeを作りなさい。型は Enumeratee Word8 Chara m b になるはずです
  • これらの関数が正しく動作していることを確認するためのquickcheckプロパティを作りなさい

結論

  • enumerateeはenumeratorとiterateeをつなぐパイプです
  • Enumerateeの奇妙な型シグニチャはたくさんの可能性を秘めています。特に、この型シグニチャがEnumeratorと似ていることに注意してください
  • EnumerateeとIterateeを joinI $ enumeratee $$ iteratee としてマージできます
  • enumerateeを作るときに、Iterateeのモナドインスタンスが使えることを忘れないように
  • http-enumeratorで見たように、あなたはいつでも、複数のenumerateeを合成することができます

これで、3つのパートから成るチュートリアルを終わります。もし質問や補足などがあるなら、コメントを残すか、メールを送ってください。

Leave a Reply