トラック
/
Elm
Elm
/
演習
/
パオラの名高いピザ
パオラの名高いピザ

パオラの名高いピザ

学習演習

はじめに

パース

パースとは、テキストを意味のあるデータに変換する処理のことです。Stringパッケージの関数にはこの目的では限界があり、正規表現は扱いにくいため、Elmでパースするときの定石はelm/parserパッケージを使うことです。

プログラミング言語LOLCODE(の一部)のパーサーを書く例を一緒に見ていきましょう。次のコメント付きプログラムを考えます。

HAI 1                 # program version 1
CAN HAS STDIO?        # import stdio
VISIBLE "HAI WORLD!"  # print "HAI WORLD!"
KTHXBYE               # end of program

まず、取りうるコマンドを表すElmの型を書きましょう。

type Command
    = Version Int
    | Import String
    | Print String

こうすると、LOLCODEプログラムはList Commandとしてモデル化できます。まずはHAI 1の行をパースするパーサーから始めましょう。

versionP : Parser Command
versionP =
    Parser.succeed Version
        |. Parser.keyword "HAI"
        |. Parser.spaces
        |= Parser.int

versionPは_パーサーのパイプライン_を使って組み立てられています。(|.)演算子は「文字列をパースするけれど結果は捨てる」、(|=)は「文字列をパースして結果を保持する」という意味です。この例では、Parser.keyword "HAI"が"HAI"を消費しますが、その結果は無視されます。Parser.spacesは' '、'\n'、'\r'の文字をいくつでも消費しますが、これも無視されます。Parser.intは整数をパースし、その整数の値(たとえば1)が一番外側のParser.succeed Versionに渡されて、パーサーは値Version 1を返して成功します。

パイプラインの中のパーサーがどれか1つでも失敗すると、Parser.DeadEndを返し、パーサー全体が失敗します。パーサーはParser.runで実行できます。

Parser.run versionP "HAI 1"
    --> Ok (Version 1)
Parser.run versionP "HAI 2"
    --> Ok (Version 2)
Parser.run versionP "BYE 2"
    --> Err [{ problem = ExpectingKeyword "HAI", col = 1, row = 1 }]
Parser.run versionP "HAI MOM"
    --> Err [{ problem = ExpectingInt, col = 5, row = 1 }]

次に、"CAN HAS STDIO?"の行に取り組みましょう。

importP : Parser Command
importP =
    Parser.succeed Import
        |. Parser.keyword "CAN HAS"
        |. Parser.spaces
        |= packageP
        |. Parser.symbol "?"

importPもパーサーのパイプラインですが、今回は"?"のような記号をパースするのに適したParser.symbolで終わります。保持したい値(インポートするパッケージの名前)は、次のものでパースします。

packageP : Parser String
packageP =
    Parser.chompWhile Char.isAlphaNum
        |> Parser.getChompedString
        |> Parser.map String.toLower

パッケージ名が何になるか分からないので、Parser.keywordは使えません。代わりにParser.chompWhileを使います。これは、条件を満たす(ここでは英数字である)限り1文字ずつ消費します。こうして消費された文字はParser.getChompedStringで集められ、Parser Stringを返します。ここではパッケージ名を正規化したいので、Parser.mapを使ってパーサーが持つ文字列を小文字に変えます。

Parser.run importP "CAN HAS STDIO?"
    --> Ok (Import "stdio")
Parser.run importP "CAN HAS StDiO?"
    --> Ok (Import "stdio")
Parser.run importP "CAN HAS STDIO"
    -->  Err [{ problem = ExpectingSymbol "?", col = 14, row = 1 }]

次は、VISIBLE "HAI WORLD!"を次のものでパースします。

printP : Parser Command
printP =
    Parser.succeed Print
        |. Parser.keyword "VISIBLE"
        |. Parser.spaces
        |. Parser.symbol "\""
        |= stringP
        |. Parser.symbol "\""

stringP : Parser String
stringP =
    Parser.chompWhile (\c -> c /= '"')
        |> Parser.getChompedString

Parser.run printP "VISIBLE \"HAI WORLD!\""
    --> Ok (Print "HAI WORLD!")

これで各コマンドのパーサーが揃いました。もちろん、一般的なプログラムではこれらのコマンドの順序は予測できないので、汎用的なコマンドパーサーを組み立てます。

commandP : Parser Command
commandP =
    Parser.oneOf
        [ versionP
        , importP
        , printP
        ]

Parser.oneOfは、与えられた順にパーサーを1つずつ試します。パーサーが失敗すれば次のものを試し、成功すれば残りのパーサーは試さずにその結果を返します。すべてのパーサーが失敗すると、Parser.oneOfも失敗します。

Parser.run commandP "HAI 2"
    --> Ok (Version 2)
Parser.run commandP "CAN HAS STDIO?"
    --> Ok (Import "stdio")
Parser.run commandP "VISIBLE \"HAI WORLD!\""
    --> Ok (Print "HAI WORLD!")
Parser.run commandP "O NOES"
    --> Err [ { problem = ExpectingKeyword "HAI", col = 1, row = 1 }
    --      , { problem = ExpectingKeyword "CAN HAS", col = 1, row = 1 }
    --      , { problem = ExpectingKeyword "VISIBLE", col = 1, row = 1 }
    --      ]

失敗した試行がそれぞれParser.DeadEndを生成し、それがParser.oneOfに集められることに注目してください。

プログラム全体をパースするには、次のものを使って複数のコマンドを順番にパースする必要があります。

programP : Parser (List Command)
programP =
    Parser.sequence
        { start = ""
        , separator = "\n"
        , end = "KTHXBYE"
        , spaces = Parser.succeed ()
        , item = commandP
        , trailing = Parser.Optional
        }

Parser.sequenceは多くのオプションを持つ、とてもカスタマイズしやすいパーサーです。start、separator、endは、シーケンスの項目を区切る文字列を受け取ります。Elmのリストをパースするなら"["、","、"]"を使うでしょう。spacesは、項目と区切り文字の間に入るかもしれない空白を処理するパーサーを受け取ることで柔軟性を持たせます。ElmのリストをパースするならParser.spacesを使いますが、今回は空白を一切認めないので、何も消費せず即座に成功するParser.succeed ()を使います。itemは項目のパーサーを受け取り、最後にtrailingでは、末尾の区切り文字をParser.Forbidden、Parser.Mandatory、Parser.Optionalのどれにするかを決められます。

Parser.run programP "HAI 2\nCAN HAS STDIO?\nKTHXBYE"
    --> Ok [Version 2, Import "stdio"]
Parser.run programP "KTHXBYE"
    --> Ok []
Parser.run programP "HAI 2\nCAN HAS STDIO?\n"
    --> Err [ { problem = ExpectingKeyword "HAI", col = 1, row = 3 }
    --      , { problem = ExpectingKeyword "CAN HAS", col = 1, row = 3 }
    --      , { problem = ExpectingKeyword "VISIBLE", col = 1, row = 3 }
    --      , { problem = Expecting "KTHXBYE", col = 1, row = 3 }
    --      ]

パーサーは成功すると、文字列の消費をそこで止めます。

Parser.run Parser.int "1"
    --> Ok 1
Parser.run Parser.int "1 BTW VISIBLE \"U SEE NOTHING\""
    --> Ok 1

そのため、文字列を最後までパースしたことを確かめたいときは、Parser.endを使います。

fullProgramP : Parser (List Command)
fullProgramP =
    programP
        |. Parser.spaces
        |. Parser.end

Parser.run fullProgramP "HAI 2\nCAN HAS STDIO?\nKTHXBYE"
    --> Ok [Version 2, Import "stdio"]
Parser.run fullProgramP "HAI 2\nCAN HAS STDIO?\nKTHXBYE\nBTW VISIBLE \"U SEE NOTHING\""
    --> Err [{ problem = ExpectingEnd, col = 1, row = 4 }]

最後に、パースした内容によってパーサーを失敗させたいことがあります。たとえば、"HAI"コマンドが2つ続いて始まるプログラムを失敗させるパーサーが必要なら、次のように書けます。

singleVersionProgramP : Parser (List Command)
singleVersionProgramP =
    fullProgramP
        |> Parser.andThen
            (\commands ->
                case commands of
                    (Version _) :: (Version _) :: _ ->
                        Parser.problem "Multiple versions defined"

                    _ ->
                        Parser.succeed commands
            )

Parser.andThenはパーサーの内容を調べ、その値に応じて新しいパーサーを返します。ここでは、一方の場合にはParser.problemで独自のメッセージとともにパーサーを失敗させ、もう一方では元の値を返しています。

Parser.run singleVersionProgramP "HAI 2\nCAN HAS STDIO?\nKTHXBYE"
    --> Ok [Version 2, Import "stdio"]
Parser.run singleVersionProgramP "HAI 1\nHAI 2\nCAN HAS STDIO?\nKTHXBYE"
    --> Err [{ problem = Problem "Multiple versions defined", col = 8, row = 4 }]

説明

Paola's Prestigious Pizzaに新しいレストラン店長として加わることになり、胸を躍らせています。 お店を効率よく運営するための計画はたくさんありますが、そのためにはデータが必要です。 まず最初に、メニューを分析する必要があります。

メニューにはたくさんの種類のピザがあります。パオラがテキストファイルに、1行につき1つのピザを書いたリストを渡してくれました。 各ピザの項目には3つの要素があります。名前、省略可能なベジタリアン表示、そして価格です。

1. ピザの価格を解析する

メニューの最初の数行を見てみましょう。

Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...

価格から始めるのがよさそうです。 価格はすべてユーロ建ての整数です。

価格を解析するpriceParserを実装しましょう。 価格はすべてユーロなので、通貨を記録しておく必要はありませんが、解析はする必要があります。

Parser.run priceParser "8€"
    --> Ok 8
Parser.run priceParser "8"
    --> Err [{ problem = ExpectingSymbol "€", col = 2, row = 1 }]

2. ベジタリアン表示を解析する

もう一度メニューを見てみましょう。

Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...

ベジタリアン表示の"(v)"もとてもシンプルなので、次はこれにすることにします!

表示を解析するvegetarianParserを実装しましょう。 パーサーはBoolを返すようにします。表示があればTrue、なければFalseです。

Parser.run vegetarianParser "(v)"
    --> Ok True
Parser.run vegetarianParser ""
    --> Ok False

3. ピザと材料の名前を解析する

さて、次は何でしょう。

Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...

材料とピザの名前はかなり単純そうです。どれも大文字と小文字のASCII文字からなる1つの単語です。

名前を解析するwordParserを実装しましょう。 ついでに、統一するためにすべての単語を小文字にしましょう。

Parser.run wordParser "REGINA"
    --> Ok "regina"
Parser.run wordParser "tomato"
    --> Ok "tomato"
Parser.run wordParser "(v)"
    --> Ok ""
Parser.run wordParser ""
    --> Ok ""

4. 材料のリストを解析する

一方、材料はグループで登場します。

Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...

カンマと、場合によっては空白で区切られた単語です。

材料を解析するingredientsParserを実装しましょう。

Parser.run ingredientsParser "tomato, emmental"
    --> Ok ["tomato", "emmental"]
Parser.run ingredientsParser "tomato"
    --> Ok ["tomato"]

5. ピザ全体を解析する

材料がそろったので、いよいよPizzaを作る番です!

Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...

まずピザの名前、省略可能なベジタリアン表示、コロン':'、材料のリスト、ダッシュ'-'、そして価格が続きます。間には空白が入ることがあります。

ピザ全体を解析するpizzaParserを実装しましょう。

Parser.run pizzaParser "Regina: tomato, ham, mushrooms, cantal - 11€"
    --> Ok (Pizza "regina" False ["tomato", "ham", "mushrooms", "cantal"] 11)

6. メニュー全体を解析する

いよいよお楽しみの時間です!

テキストファイル内で改行文字'\n'で区切られた、すべてのピザのリストを解析するmenuParserを実装しましょう。 ファイルの終わりに達するまでパーサーを実行して、すべてのピザを取得できるようにしましょう。

Parser.run menuParser "Regina: tomato, ham, mushrooms, cantal - 11€\nFormaggio (v): tomato, emmental - 8€"
    --> Ok [Pizza "regina" False ["tomato", "ham", "mushrooms", "cantal"] 11,
    --      Pizza "formaggio" True ["tomato", "emmental"] 8]
Parser.run menuParser "Regina: tomato, ham, mushrooms, cantal - 11€\[END]"
    --> Err [{ problem = ExpectingEnd, col = 1, row = 2 }]

7. 複数単語の材料名を解析する

あらら、何かを見落としていたようです。 メニューのもう少し下の方では、いくつかの材料が1つの単語ではありません。

...
Tonno: tomato sauce, tuna - 10€
Hawaii: tomato sauce, fresh pineapple, ham - 9€
...

大文字と小文字のASCII文字、または空白' 'を受け付けるoneIngredientParserを実装しましょう。 ついでに、wordParserにあった小さな欠陥も直しておきましょう。空の文字列を有効な材料として認識しないようにし、代わりにProblem "empty string"を返すようにします。 そのついでに、文字列を小文字にし、両端の空白文字も取り除くようにしましょう。

Parser.run oneIngredientParser "Tomato Sauce"
    --> Ok "tomato sauce"
Parser.run oneIngredientParser "   tomato sauce     "
    --> Ok "tomato sauce"
Parser.run oneIngredientParser ""
    --> Err [{ problem = Problem "empty string", col = 1, row = 1 }]

パーサーを定義したら、ingredientsParserの中で使ってみましょう。

GitHubで編集する リンクは新しいウィンドウまたはタブで開きます
Elm Exercism

パオラの名高いピザを始める準備はできましたか?

Exercismに登録すれば、28個のコンセプト110個の演習、そして本物の人間によるメンタリングとともに、Elmを学んでマスターできます。すべて無料です。