轨道
/
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。

管线中任何一个解析器失败,都会返回一个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

准备好开始 保拉的著名披萨 了吗?

注册 Exercism,借助 28 个概念110 个练习 和真人导师指导,学习并掌握 Elm,全部免费。