yunomuのブログ

酒とゲームと上から目線

io-streams

そろそろio-streamsで遊んでみよう。いや遊んでたのは実のところ随分前なんですけど。
http://hackage.haskell.org/package/io-streams

io-streamsというのはHaskellのストリーム処理ライブラリの一つで、シンプルなのが売りなのかな。
ストリーム処理には私はよくconduitを使っていますけど、conduitと比べるとなんというか、本当にシンプルで使い勝手がいいです。
まずはHelloWorld的に、猫のようなもの。

import System.IO.Streams

main :: IO ()
main = stdin `connect` stdout

ここで出てくるstdinとstdoutはSystem.IO.Streams.Handleに定義されてるもので、それぞれこんな感じになってる。

stdin :: InputStream ByteString
stdout :: OutputStream ByteString

で、connectはSystem.IO.Streamsに定義されてて、こんな感じ。

connect :: InputStream a -> OputputStream a -> IO ()

標準入力からの入力ストリーム(InputStream)を標準出力への出力ストリーム(OutputStream)に接続(connect)しているというだけ。

System.IO.Streams.HandleにあるhandleToInputStream/handleToOutputStreamを使って書き直すとこんな感じ。

import System.IO (stdin, stdout)
import System.IO.Streams hiding (stdin, stdout)

main :: IO ()
main = do
    is <- handleToInputStream stdin
    os <- handleToOutputStream stdout
    is `connect` os

ついでにHandleで書くとこんな感じ。

import System.IO

main :: IO ()
main = do
    c <- hGetChar stdin
    hPutChar stdout c
    main

HandleToStreamと同じようにStreamToHandle系の関数もあるのでなんとなくストリームとIOハンドルは同じものなんだなぁというのがわかりますね。どうでもいいですけど。

ファイルから読むのはwithFileAsInput/withFileAsOutputというのがあって、withFileと同じような雰囲気で使えます。

% cat data.txt
abc
def
ghc

みたいなファイルがあったとして

import System.IO.Streams

main :: IO ()
main = withFileAsInput "data.txt" $ \is -> is `connect` stdout
    -- > "abc\ndef\nghc\n"

ファイルの中身を吐き出すだけのプログラムができる。中身はおそらくwithFileとhandleToInputStreamを組み合わせただけであろう。

System.IO.Streams.Listにはストリームをリストにしたりリストをストリームにしたりする処理が色々入っていて、おそらくよく使うのはtoListなんじゃないかなぁ。

import System.IO.Streams

main :: IO ()
main = withFileAsInput "data.txt" $ \is -> do
    as <- toList is
    print as
    -- > ["abc\ndef\nghc\n"]

なんかあんまし嬉しくなかった。ストリームの一番ベースとなるデータ構造ByteStringには区切りが無いのでまあこうなるよね。
System.IO.Streams.ByteStringにはlinesという改行ごとにByteStringを区切る関数があって、まあData.List.linesとだいたい同じように動きます。

import Prelude hiding (lines)
import System.IO.Streams

main :: IO ()
main = withFileAsInput "data.txt" $ \is -> do
    ls <- lines is
    as <- toList ls
    print as
    -- > ["abc", "def", "ghc", ""]

最後改行で終わってるからなんかゴミ入ってますけど、これで意図通り。isはただのバイト列のストリームですが、lsはバイト列のリストのストリームになっています。わかりづらいですが。
InputStream/OutputStreamはMonadじゃないけど、linesとかtoListとかがIOなのでdoで連結できるし、printとかとも簡単に連携できて良い感じです。それはそうと今MBAのキーボードに水をこぼしてAのキーがすごくききづらくなりました。
bindで書くとちょっといい感じに。

main = withFileAsInput "data.txt" $ lines >=> toList >=> print
    -- > ["abc", "def", "ghc", ""]

別にconnectしなくてもデータ取り出せるのがなんかお手軽な気がします。ここではtoListがconnect相当をやってるんですけどね。

toList :: InputStream a -> IO [a]

モナドトランスフォーマーとか使わないでいいのが良いと思います。

InputStreamにはread/unRead/peekがある。conduitでいうところのawait/leftoverみたいなものです。conduitにpeek相当ってあったっけ?

{-# LANGUAGE OverloadedStrings #-}
import Prelude hiding (lines, read)
import Data.ByteString.Char8 ()
import System.IO.Streams

main :: IO ()
main = withFileAsInput "data.txt" $ \is -> do
    ls <- lines is
    a1 <- read ls
    print a1 -- > Just "abc"
    unRead "xyz" ls
    a2 <- peek ls
    print a2 -- > Just "xyz"
    as <- toList ls
    print as -- > ["xyz", "def", "ghc", ""]
    a3 <- read ls
    print a3 -- > Nothing

readはストリームを消費するけどpeekは消費しない。unReadは本来は一度読んだデータを読まなかった事にする関数だけど、型が合ってれば別になんでも詰め直せる。で、toListはストリームを全部消費するのでその後に何か読もうとしても読めない。
一方、writeはデータをOutputStreamに書き出すだけです。conduitのyieldとは全然違う。

あと/dev/nullみたいなnullInput/nullOutputみたいなのもあります。nullOutputはまあいいとして、nullInputはunReadと組み合わせてスタックみたいに使えそうですね。いやそんなのリスト使えって感じですね。

データ変換、conduitでいうところのConduit相当のものは、単に

f :: InputStream a -> InputStream b

みたいな関数を作ってやればいいだけみたいなんですが、えっとこれどうやって作るんだ。みたいになりますが、おそらくそういう時のためにGeneratorというのがあります。GeneratorはInputStreamを作るための機能のようですが、こいつはMonadでMonadIOなのでInputStreamの変換にも使いやすそうです。おい、結局モナドトランスフォーマー使ってるじゃないか。

ためしに

data Member = Member ByteString deriving (Show)
f :: InputStream ByteString -> IO (InputStream Member)

みたいな関数fを作ってみるとすると、

{-# LANGUAGE OverloadedStrings #-}
import Control.Monad.IO.Class (liftIO)
import Data.ByteString (ByteString)
import Data.ByteString.Char8 ()
import Prelude hiding (lines, read)
import System.IO.Streams

data Member = Member ByteString deriving (Show)
f :: InputStream ByteString -> IO (InputStream Member)
f = fromGenerator . g

g :: InputStream ByteString -> Generator Member ()
g is = do
    ma <- liftIO $ read is
    case ma of
        Nothing -> return ()
        Just a  -> do
            yield (Member a)
            g is

main :: IO ()
main = withFileAsInput "data.txt" $ \is -> do
    ls <- lines is
    ms <- f ls
    toList ms >>= print
    -- > [Member "abc", Member "def", Member "ghc", Member ""]

gなんて関数が増えちゃいましたけど、だいたいこんな感じ? Generator部分(g関数)はあんましConduitと変わりませんね。一旦Generatorを返す関数を作らないと、データの繰り返し処理ができなくなる。というのもだいたいConduitと同じ。

ということでio-stream結構いいんじゃないでしょうか。
周辺ライブラリの充実度でconduitに見劣りするのもの、インタフェースが簡素で他との連携もやりやすいので、頑張ればなんとかなるんじゃないでしょうか。Networkとかattoparsecなんかは最初から入ってるから、頑張って作ろう。

とここまで書いたところでチュートリアルの存在に気づきました。Tutorialってモジュールがある……。
System.IO.Streams.Tutorial

今回作ったソースはここです。
exercises/streams at master · yunomu/exercises · GitHub