Skip to content

Instantly share code, notes, and snippets.

@fujiyan fujiyan/kansaifp-01.md
Last active Aug 29, 2015

Embed
What would you like to do?

Attoparsecで簡単パージング


自己紹介

  • Fujiyan(@fujiyan18)
  • Haskell始めて1年半です。
  • 「すごいHaskell読書会 in 大阪 2週目」で勉強中です。
  • 今日の資料、スライドに落とせませんでした…。

今回のポイント

  • Haskellのパーザライブラリである、Attoparsecをご紹介致します。
  • パーザライブラリは、Haskellの面白さが比較的わかりやすく体験できるので、私のような初学者の方にオススメです。
  • 今回は、Attoparsecの組み込みパーザの説明から、独自パーザの定義方法を経て、最後にCSVファイルをパーズする例を取り上げます。
  • BNFの定義からストレートに、パーザが記述できる様子を御覧ください。

Attoparsecとは

  • Haskellのパーザライブラリです。
  • 同様のパーザライブラリで有名なものとしてParsec3があります。
  • Parsec3に比べると、高速なのが特徴です。
  • でも、失敗時のエラーメッセージがわかりにくいです。Parsec3は、失敗した位置等を親切に教えてくれます。

Attoparsecアプリケーションの概要

  • Attoparsecアプリケーションは「パーザ」「パーズ対象テキスト」「パーザを起動する関数」の3つから成り立ちます。
parseOnly anyChar "abcdef"
  • 最初のparseOnlyがパーザを起動する関数、真ん中のanyCharがパーザ、最後の"abcdef"がパーズ対象テキストです。
  • Attoparsecアプリケーションは、目的に応じたパーザを定義することが、主な作業になります。

パーザ

  • Haskellの関数です。
  • 関数なので、状態を意識せず、宣言的に記述可能です。
  • Haskellの関数なので、アプリケーションで定義されたデータ型や関数を容易にパーザに組み込むことができます。
  • パーザは「マッチする文字列のパターン」と「マッチした文字列を元に作成するデータの型」を持ちます。
  • 任意の文字列パターンをそのまま文字列として返したり、数字列というパターンを数値に変換したり等々…。
  • 組み込みの小さなパーザを組み合わせて、複雑なテキストのパーザを定義します。

任意の1文字をCharにパージング

{-# LANGUAGE OverloadedStrings #-}
import Data.Attoparsec.Text


main :: IO ()
main = print $ parseOnly anyChar "abcdef"

結果

Right 'a'
  • parseOnlyは、指定されたパーザにマッチした場合、結果をEitherで返し、残りのテキストを破棄します。

特定の1文字をCharにパージング

{-# LANGUAGE OverloadedStrings #-}
import Data.Attoparsec.Text


main :: IO ()
main = print $ parseOnly (char 'a') "abcdef"

結果

Right 'a'
  • 下記のように、指定した文字とマッチしない場合はエラーになります。
main = print $ parseOnly (char 'a') "Xabcdef"

結果

Left "Failed reading: satisfy"

数字1文字をCharにパージング

{-# LANGUAGE OverloadedStrings #-}
import Data.Attoparsec.Text


main :: IO ()
main = print $ parseOnly digit "123456"

結果

Right '1'

特定のパターンの文字列をStringにパージング

{-# LANGUAGE OverloadedStrings #-}
import Data.Attoparsec.Text


main :: IO ()
main = print $ parseOnly (string "abc") "abcdef"

結果

Right "abc"

大小文字の違いを無視して、特定のパターンの文字列をStringにパージング

{-# LANGUAGE OverloadedStrings #-}
import Data.Attoparsec.Text


main :: IO ()
main = print $ parseOnly (asciiCI "abc") "AbCdef"

結果

Right "AbC"

条件を満たす文字が現れるまでの文字列をStringにパージング

{-# LANGUAGE OverloadedStrings #-}
import Data.Attoparsec.Text


main :: IO ()
main = print $ parseOnly (takeTill (== 'd')) "abcdef"

結果

Right "abc"

数字列を数値型にパージング

{-# LANGUAGE OverloadedStrings #-}
import Data.Attoparsec.Text


main :: IO ()
main = print $ parseOnly decimal "123456"

結果

Right 123456

独自のパーザを定義

{-# LANGUAGE OverloadedStrings #-}
import Data.Attoparsec.Text


charA :: Parser Char
charA = (char 'a')

main :: IO ()
main = print $ parseOnly charA "abcdef"

結果

Right 'a'
  • パーザは、Parser a型を返す関数です。
  • Parserの型引数には、マッチした文字列を元に作成するデータの型を指定します。

コンビネータ

  • コンビネータを用いて、より高度なパーザを定義できます。

指定回数だけ繰り返し

{-# LANGUAGE OverloadedStrings #-}
import Data.Attoparsec.Text


anyChar3 :: Parser String
anyChar3 = count 3 anyChar

main :: IO ()
main = print $ parseOnly anyChar3 "abcdef"

結果

Right "abc"
  • countは、指定された回数だけ、指定されたパーザを連続で適用します。
  • 結果はパーザの戻り値の型のリストです。
  • CharのリストはStringなので、パーザの戻り値はStringです。

1回以上マッチする間

{-# LANGUAGE OverloadedStrings #-}
import Control.Applicative
import Data.Attoparsec.Text


many1A :: Parser String
many1A = many1 $ char 'a'

main :: IO ()
main = print $ parseOnly many1A "aaabcdef"

結果

Right "aaa"
  • many1は、1回以上マッチする間、指定されたパーザを連続で適用します。
  • 下記のように、1度もマッチしない場合はエラーになります。
main = print $ parseOnly many1A "Xaaabcdef"

結果

Left "Failed reading: satisfy"

アプリカティブスタイルによる、複雑なパーザの定義

固定長の数字列を数値型にパージング

{-# LANGUAGE OverloadedStrings #-}
import Control.Applicative
import Data.Attoparsec.Text


digit4 :: Parser Int
digit4 = read <$> count 4 digit

main :: IO ()
main = print $ parseOnly digit4 "201409"

結果

Right 2014
  • YYYYMM形式の日付文字列から、年だけをIntで取得します。
  • decimalでは、201409という風に、連続する全ての数字をパーズしてしまいます。
  • そこで、一旦桁数指定した文字列として取得した後、それをreadで数値に変換します。
  • 演算子<$>を用いて、関数readを(String -> a)から、(Parser String -> Parser a)に持ち上げます。

2つのパーザの結果をタプルに格納

{-# LANGUAGE OverloadedStrings #-}
import Control.Applicative
import Data.Attoparsec.Text


digit4 :: Parser Int
digit4 = read <$> count 4 digit

digit2 :: Parser Int
digit2 = read <$> count 2 digit

digit4And2 :: Parser (Int, Int)
digit4And2 = (,) <$> digit4 <*> digit2

main :: IO ()
main = print $ parseOnly digit4And2 "201409"

結果

Right (2014,9)
  • YYYYMM形式の日付文字列から、年と月をIntのタプルで取得します。
  • 演算子<$>と<*>を用いたアプリカティブスタイルで、データコンストラクタ(,)を(a -> a -> (a, a))から、(Parser a -> Parser a -> Parser (a, a))に持ち上げます。

デリミタを含む書式のパージング

{-# LANGUAGE OverloadedStrings #-}
import Control.Applicative
import Data.Attoparsec.Text


digit4 :: Parser Int
digit4 = read <$> count 4 digit

digit2 :: Parser Int
digit2 = read <$> count 2 digit

digit4And2 :: Parser (Int, Int)
digit4And2 = (,) <$> digit4 <* "/" <*> digit2

main :: IO ()
main = print $ parseOnly digit4And2 "2014/09"

結果

Right (2014,9)
  • YYYY/MM形式の日付文字列から、年と月をIntのタプルで取得します。
  • デリミタとなる"/"は、マッチはさせますが、パーズ対象にはしません。
  • 演算子<*は、右側のパターンのマッチングを行いつつ、その文字列を破棄します。

先頭のパターンが不要な場合

{-# LANGUAGE OverloadedStrings #-}
import Control.Applicative
import Data.Attoparsec.Text


digit4 :: Parser Int
digit4 = read <$> count 4 digit

digit2 :: Parser Int
digit2 = read <$> count 2 digit

digit4And2 :: Parser (Int, Int)
digit4And2 = (,) <$ "@" <*> digit4 <* "/" <*> digit2

main :: IO ()
main = print $ parseOnly digit4And2 "@2014/09"

結果

Right (2014,9)
  • 先頭に@が付いた、YYYY/MM形式の日付文字列から、年と月をIntのタプルで取得します。
  • 演算子<$は、先頭のパターンのマッチングを行いつつ、その文字列を破棄します。

クラスReadのインスタンスでないデータ型にパージング

{-# LANGUAGE OverloadedStrings #-}
import Control.Applicative
import Data.Attoparsec.Text


data Gender = Male | Female
    deriving (Show)


male :: Parser Gender
male = asciiCI "male" *> return Male

main :: IO ()
main = print $ parseOnly male "MaLe"

結果

Right Male
  • データ型がクラスReadのインスタンスで無い場合、文字列から変換する関数readが使えません。
  • 或いは、関数readで想定していない文字列パターンから変換したい場合もあるかも知れません。
  • 演算子*>は、左側のパターンのマッチングを行いつつ、その文字列を破棄します。
  • 演算子*>の右側に、マッチした文字列に対応するデータを指定します。
  • 型Genderの値Maleを返す際には、returnを用いることで、GenderからParser Genderに持ち上げています。

選択

{-# LANGUAGE OverloadedStrings #-}
import Control.Applicative
import Data.Attoparsec.Text


data Gender = Male | Female
    deriving (Show)


male :: Parser Gender
male = asciiCI "male" *> return Male

female :: Parser Gender
female = asciiCI "female" *> return Female

gender :: Parser Gender
gender = male <|> female

main :: IO ()
main = print $ parseOnly gender "FeMaLe"

結果

Right Female
  • 演算子<|>で、両側のパーザのうち、マッチしたほうを結果として返します。

BNFに対応したパーザの定義

{-# LANGUAGE OverloadedStrings #-}
import Control.Applicative
import Data.Attoparsec.Text


{-
    year = 4 * digit ;
    month = 2 * digit ;
    day = 2 * digit ;
    ymd = year , "/" , month, "/" , day ;
-}

year :: Parser Int
year = read <$> count 4 digit

month :: Parser Int
month = read <$> count 2 digit

day :: Parser Int
day = read <$> count 2 digit

ymd :: Parser (Int, Int, Int)
ymd = (,,) <$> year <* "/" <*> month <* "/" <*> day

main :: IO ()
main = print $ parseOnly ymd "2014/09/06"

結果

Right (2014,9,6)
  • EBNFでの定義内容と、パーザ関数が1対1に対応しています。

CSVファイルのパーザを作ろう

CSVレコードをパージング

ExampleModel

module ExampleModel where

import Data.Text


data Gender = Male | Female
    deriving (Show)

data Profile = Profile
    { name :: Text
    , gender :: Gender
    , birthday :: (Int, Int, Int)
    }
    deriving (Show)

Main

{-# LANGUAGE OverloadedStrings #-}
import Control.Applicative
import Data.Attoparsec.Text
import qualified Data.Text as T
import qualified ExampleModel as M


{-
    name = ? characters until "," appears ? ;
    gender = "male" | "female" ;
    year = 4 * digit ;
    month = 2 * digit ;
    day = 2 * digit ;
    birthday = year , "/" , month, "/" , day ;
    profileCsv = name , "," , gender , "," , birthday ;
-}

name :: Parser T.Text
name = takeTill (== ',')

male :: Parser M.Gender
male = asciiCI "male" *> return M.Male

female :: Parser M.Gender
female = asciiCI "female" *> return M.Female

gender :: Parser M.Gender
gender = male <|> female

year :: Parser Int
year = read <$> count 4 digit

month :: Parser Int
month = read <$> count 2 digit

day :: Parser Int
day = read <$> count 2 digit

birthday :: Parser (Int, Int, Int)
birthday = (,,) <$> year <* "/" <*> month <* "/" <*> day

profileCsv :: Parser M.Profile
profileCsv = M.Profile <$> name <* "," <*> gender <* "," <*> birthday

main :: IO ()
main = print $ parseOnly profileCsv "Haskell,MaLe,2014/09/06"

結果

Right (Profile {name = "Haskell", gender = Male, birthday = (2014,9,6)})
  • プロフィールのCSVレコードから、Profile型のデータをパーズします。
  • プロフィールは、名前、誕生日、性別から構成されます。
  • Profile型は、名前をText、誕生日を3つのIntのタプル、性別をデータ型Genderで保持します。
  • プロフィールのCSVレコードを、EBNFで定義します。
  • EBNFの定義に対応して、パーザ関数を定義しいます。

CSVファイルを読み込む

profiles.csv(UTF-8)

Haskell,MaLe,2014/09/06
Scala,FeMaLe,2013/12/31
F#,mALe,2012/01/01

Main

{-# LANGUAGE OverloadedStrings #-}
import Control.Applicative
import Control.Monad.Trans.Resource
import Data.Attoparsec.Text
import Data.Conduit
import Data.Conduit.Binary hiding (mapM_)
import Data.Conduit.Text
import Data.Conduit.Attoparsec

import qualified Data.Text as T
import qualified ExampleModel as M


{-
    name = ? characters until "," appears ? ;
    gender = "male" | "female" ;
    year = 4 * digit ;
    month = 2 * digit ;
    day = 2 * digit ;
    birthday = year , "/" , month, "/" , day ;
    profileCsv = name , "," , gender , "," , birthday ;
    profileCsvFile = { profileCsv ,  endOfLine } , EOF ;
-}

name :: Parser T.Text
name = takeTill (== ',')

male :: Parser M.Gender
male = asciiCI "male" *> return M.Male

female :: Parser M.Gender
female = asciiCI "female" *> return M.Female

gender :: Parser M.Gender
gender = male <|> female

year :: Parser Int
year = read <$> count 4 digit

month :: Parser Int
month = read <$> count 2 digit

day :: Parser Int
day = read <$> count 2 digit

birthday :: Parser (Int, Int, Int)
birthday = (,,) <$> year <* "/" <*> month <* "/" <*> day

profileCsv :: Parser M.Profile
profileCsv = M.Profile <$> name <* "," <*> gender <* "," <*> birthday

profileCsvFile :: Parser [M.Profile]
profileCsvFile = (many $ profileCsv <* endOfLine) <* endOfInput

main :: IO ()
main = do
    result <- runResourceT $ sourceFile "profiles.csv" $= decode utf8 $$ sinkParser profileCsvFile
    mapM_ print result

結果

Profile {name = "Haskell", gender = Male, birthday = (2014,9,6)}
Profile {name = "Scala", gender = Female, birthday = (2013,12,31)}
Profile {name = "F#", gender = Male, birthday = (2012,1,1)}
  • manyは全くマッチしなくても成功します。その場合は空リストを返します。
  • manyを用いる場合は、Control.Applicativeをimportする必要があるので注意。
  • endOfLineは行末文字とマッチします。
  • endOfInputはファイル終端とマッチします。
  • ストリーム入出力ライブラリである、Conduitを用いてファイルを読み込みます。
  • conduitパッケージとconduit-extraパッケージをインストールしてください。
  • profiles.csvという名前のファイルをバイナリとして読み込み、UTF-8としてデコードし、結果のテキストをパーザに渡しています。

参考

Text Parsing tutorial - School of Haskell - FP Complete
Haskell で parser を書くには (初心者編) - Utotch Blog
Applicativeのススメ - あどけない話

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.