學習軌道
/
Elm
Elm
/
練習
/
Paola 的頂級披薩
Paola 的頂級披薩

Paola 的頂級披薩

學習練習

簡介

解析

解析是把文字轉換成有意義資料的過程。 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 這個值。

管線中只要有任一個解析器失敗,就會回傳 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;它會逐一消耗字元,只要這些字元符合某個條件(在這裡是必須為英數字元)。 這些被消耗的字元接著會由 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 會依照給定的順序,一個接一個嘗試解析器。 如果某個解析器失敗,就換下一個試;要是成功,就直接回傳它的結果,不再嘗試剩下的解析器。 如果所有解析器都失敗,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" 指令的程式判定為失敗,可以這樣寫:

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,成為新任的餐廳經理。 對於要如何有效率地經營這間店,你有很多計畫,而為了達成這個目標,你需要一些資料。 首先,你需要分析菜單。

菜單上有很多種類的披薩,Paola 給了你一份文字檔清單,每一行是一份披薩。 每一筆披薩資料都有三個部分:名稱、選用的素食標記,以及價格。

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 字元組成。

實作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. 解析完整菜單

派對時間!

實作menuParser,解析文字檔中所有以換行字元'\n'分隔的披薩清單。 請讓解析器持續執行,直到抵達檔案結尾,以確保你取得了所有的披薩。

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. 解析多詞食材名稱

喔不,看來你漏掉了什麼。 在菜單更下方的某處,有些食材不是單一詞彙:

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

實作oneIngredientParser,它會接受大寫和小寫的 ASCII 字元,或是空格' '。 也趁這個機會修正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

準備好開始 Paola 的頂級披薩 了嗎?

註冊 Exercism,透過 28 個概念110 個練習 和真人引導來學習並精通 Elm,全部免費。