むつかしいことを考えずにHaskellでCSVをパースする

まともに使い物になるプログラムを作ろうとすると、プログラムの外から何らかのデータを取り込んでパースし、内部で使えるデータに落としこむ処理というのはほとんどの場合で必要になる処理だろう。

しかし、Haskellではこのパース処理を記述するのが意外なほど難しい、と自分は思う。というのも、パース処理というのは「外界から」のデータを「一つずつ」読み込み、しかもそのあらゆる過程で「失敗するかもしれない」処理だからだと思う。もうこの時点で複雑なモナドが必要な気がしてくる。

ということで、Haskellにはこういった面倒なパース処理をうまくやるためにParsecなどのライブラリがあるので、普通は素直にこれを使うのがよい。

しかし、初学者にとってはParsecの学習コストもバカにはならない。特に、パースしようとする対象がごく単純なCSVフォーマットだったりする場合はわざわざParsecの使い方を習得するのもおっくうになるというものである。

そこで、Parsecを使わずにざっくりとCSVフォーマットをパースする処理を書いてみた。

フォーマット

Wikipediaから取ってきた以下のデータをパースすることを考える。

## https://en.wikipedia.org/wiki/Comma-separated_values
## Year,Make,Model,Length
1997,Ford,E350,2.34
2000,Mercury,Cougar,2.38

#から始まる行はコメントとして無視する。

内部データ型

上記データを受けるデータ型を定義する。

data Car = Car {
  cYear :: Int,
  cMake :: String,
  cModel :: String,
  cLength :: Double
} deriving (Eq,Ord,Show)

Parsecなしのパーサ

とりあえず1行分のデータをパースする処理は以下のように書ける。

type ErrorMessage = String

readField :: Read a => String -> String -> Either ErrorMessage a
readField field_name field = case reads field of
  ((val, _): _) -> Right val
  _ -> Left ("Cannot parse " ++ field_name)

-- Parsec無し
parseLine :: String -> Either ErrorMessage Car
parseLine line = 
  case splitWhen (== ',') $ takeWhile (/= '#') line of
    [year, make, model, len] -> Car <$> readField "year" year
                                    <*> return make
                                    <*> return model
                                    <*> readField "length" len
    _ -> Left "Invalid number of fields"

単純に、#以降の文字列を捨て、文字列をカンマで分割し、分割した要素をreadでデータ型変換し、Carをコンストラクトしている。データコンストラクタをアプリカティブスタイルで呼ぶやり方はパース処理を書く時に重宝すると思う。

splitWhen関数はData.List.Splitモジュールで定義されており、splitパッケージで提供される。words関数がbaseパッケージなのにコレが別パッケージなのはなんともおかしいと思う。

もちろん、カラム内でクォートが使われた場合などには対応できないが、自分が扱うデータはこの程度で十分なことが多かったりする。

ちょっとだけParsecを使ってみる

上記プログラムは十分シンプルだと思うが、分割したカラム文字列一つ一つにいちいち名前をつけているあたりがちょっとダサい。そこでこの点についてのみ、Parsecを使ってみる。

import Text.Parsec (parse, anyToken)

-- フィールドに分割してからParsec
parseLine' :: String -> Either ErrorMessage Car
parseLine' = either (Left . show) Right . parse carParser "CSV source" . splitWhen (== ',') . takeWhile (/= '#')
  where carParser :: Parsec [String] () Car
        carParser = Car <$> next "year" <*> anyToken <*> anyToken <*> next "length"
        next :: Read a => String -> Parsec [String] () a
        next name = either fail return . readField name =<< anyToken

Parsecはテキストデータだけでなく、任意のリスト(今回は[String])をパースすることができる。また、anyToken関数は無条件に次のトークンを取り出す。これを使うと読んだデータにいちいち名前をつけることなく、anyTokenで「次のデータ」を読み出すことができる。

なんとなく、ParsecはanyTokenから入ると理解しやすいと思う。