第十六章:使用Parsec
为一个文本文件或者不同类型的数据做语法分析(parsing),对程序员来说是个很常见的任务,在本书第198页“使用正则表达式”一节中,我们已经学习了Haskell 对正则表达式的支持。对很多这样的任务,正则表达式都很好用。
不过,当处理复杂的数据格式时,正则表达式很快就会变得不实用、甚至完全不可用。比如说,对于多数编程语言来说,我们没法(只)用正则表达式去parse 其源代码。
Parsec 是一个很有用的 parsercombinator 库,使用Parsec,我们可以将一些小的、简单的 parser 组合成更复杂的 parser。Parsec提供了一些简单的 parser,以及一些用于将这些 parser组合在一起的组合子。毫不意外,这个为 Haskell 设计的 parser库是函数式的。
将 Parsec 同其他语言的 parse工具做下对比是很有帮助的,语法分析有时会被分为两个阶段:词法分析(这方面的工具比如flex
)和语法分析(比如 bison
). Parsec可以同时处理词法分析和语法分析。(译注:词法分析将输入的字符串序列转化为一个个的token,而语法分析进一步接受这些 token 作为输入生成语法树)
Parsec 初步:简单的 CSV parser¶
让我们来写一个解析 CSV 文件的代码。CSV是纯文本文件,常被用来表示表格或者数据库。每行是一个记录,一个记录中的字段用逗号分隔。至于包含逗号的字段,有特殊的处理方法,不过在这一节我们暂时不考虑这种情况。
下面的代码比实际需要的代码要长一些,不过接下来,我们很快就会介绍一些Parsec 的特性,应用这些特性,整个 parser 只需要四行。
-- file: ch16/csv1.hs
import Text.ParserCombinators.Parsec
{- A CSV file contains 0 or more lines, each of which is terminated
by the end-of-line character (eol). -}
csvFile :: GenParser Char st [[String]]
csvFile =
do result <- many line
eof
return result
-- Each line contains 1 or more cells, separated by a comma
line :: GenParser Char st [String]
line =
do result <- cells
eol -- end of line
return result
-- Build up a list of cells. Try to parse the first cell, then figure out
-- what ends the cell.
cells :: GenParser Char st [String]
cells =
do first <- cellContent
next <- remainingCells
return (first : next)
-- The cell either ends with a comma, indicating that 1 or more cells follow,
-- or it doesn't, indicating that we're at the end of the cells for this line
remainingCells :: GenParser Char st [String]
remainingCells =
(char ',' >> cells) -- Found comma? More cells coming
<|> (return []) -- No comma? Return [], no more cells
-- Each cell contains 0 or more characters, which must not be a comma or
-- EOL
cellContent :: GenParser Char st String
cellContent =
many (noneOf ",\n")
-- The end of line character is \n
eol :: GenParser Char st Char
eol = char '\n'
parseCSV :: String -> Either ParseError [[String]]
parseCSV input = parse csvFile "(unknown)" input
我们来讲解下这段代码,在这段代码中,我们并没有使用 Parsec的特性,因此要记住这段代码还能写得更简洁!
我们自顶向下的构建了一个 CSV 的 parser,第一个函数是csvFile
。它的类型是 GenParser Char st [[String]]
,这表示这个函数的输入是字符序列,也就是 Haskell 中的字符串,因为String
不过是 [Char]
的别名,而这个函数的返回类型是[[String]]
: 一个字符串列表的列表。至于 st
,我们暂时忽略它
Parsec程序员经常会写一些小函数,因此他们常常懒得写函数的类型签名。Haskell的类型推导系统能够自动识别函数类型。而在上面第一个例子中,我们写出了所有函数的类型,方便你了解函数到底在干什么。另外你可以在ghci
中使用 :t
来查看函数的类型。
csvFile
函数使用了 do
语句,如其所示,Parsec 库是 monadic的,它定义了用于语法分析的[1][ref1]:Genparser
monad。
csvFile
函数首先运行的是many line
,many
是一个高阶函数,它接受一个 parser函数作为参数,不断对输入应用这个 parser,并把每次 parse的结果组成一个列表返回。在 csvFile
中,我们把对 csv文件中所有行的解析结果存储到 result
中,然后,当我们遇到文件终结符EOF 时,就返回 result
。也就是说:一个 CSV 文件有好多行组成,以 EOF结尾。Parsec 写成的函数如此简洁,我们常常能够像这样直接用语言来解释。
上一段说,一个 CSV文件由许多行组成,现在,我们需要说明,什么是“一行”,为此,我们定义了line
函数来解析 CSV文件中的一行,通过阅读函数代码,我们可以发现,CSV文件中的一行,包括许多“单元格”,最后跟着一个换行符。
那么,什么是“许多单元格”呢,我们通过 cells
函数来解析一行中的所有单元格。一行中的所有单元格,包括一个到多个单元格。因此,我们首先解析第一个单元格的内容,然后,解析剩下的单元格,返回剩下的单元格内容组成的列表,最后,cells
把第一个单元格与剩余单元格列表组成一个新的单元格列表返回。
我们先跳过 remainingCells
函数,去看cellContent
函数,cellContent
解析一个单元格的内容。一个单元格可以包含任意数量的字符,但每一个字符都不能是逗号或者换行符(译注:实际可以包含逗号,不过我们目前不考虑这种情况),我们使用noneOf
函数来匹配这两个特殊字符,来确保我们遇到的不是这样的字符,于是,many noneOf ",\n"
定义了一个单元格。
然后再来看 remainingCells
函数,这个函数用来在解析完一行中第一个单元格之后,解析该行中剩余的单元格。在这个函数中,我们初次使用了Parsec 中的选择操作,选择操作符是<|>
。这个操作符是这样定义的:它会首先尝试操作符左边的 parser函数,如果这个parser没能成功消耗任何输入字符(译注:没有消耗任何输入,即是说,从输入字符串的第一个字符,就可以判定无法成功解析,例如,我们希望解析”html”这个字符串,遇到的却是”php”,那从”php”的第一个字符’p’,就可以判定不会解析成功。而如果遇到的是”http”,那么我们需要消耗掉”ht”这两个字符之后,才判定匹配失败,此时,即使已经匹配失败,”ht”这两个字符仍然是被消耗掉了),那么,就尝试操作符右边的parser。
在函数 remainingCells
中,我们的任务是去解析第一个单元格之后的所有单元格,cellContent
函数使用了 noneOf ",\n"
,所以逗号和换行符不会被 cellContent
消耗掉,因此,如果我们在解析完一个单元格之后,见到了一个逗号,这说明这一行不止一个单元格。所以,remainingCells
选择操作中的第一个选择的开始是一个 char ','
来判断是否还有剩余单元格,char
这个 parser简单的匹配输入中传入的字符,如果我们发现一个逗号,我们希望这个去继续解析剩余的单元格,这个时候,“剩下的单元格”看上去跟一行中的所有单元格在格式上一致。所以,我们递归地调用cells
去解析它们。如果我们没有发现逗号,说明这一行中再没有剩余的单元格,就返回一个空列表。
最后,我们需要定义换行符,我们将换行符设定为字符’\n’,这个设定到目前来讲已经够用了。
在整个程序的最后,我们定义函数 parseCSV
,它接受一个 String
类型的参数,并将其作为 CSV 文件进行解析。这个函数只是对 Parsec 中parse
函数的简单封装,parse
函数返回Either ParseError [[String]]
类型,如果输入格式有错误,则返回的是用 Left
标记的错误信息,否则,返回用Right
标记的解析生成的数据类型。
理解了上面的代码之后,我们试着在 ghci
中运行一下来看下它:
ghci> :l csv1.hs
[1 of 1] Compiling Main ( csv1.hs, interpreted )
Ok, modules loaded: Main.
ghci> parseCSV ""
Loading package parsec-2.1.0.0 ... linking ... done.
Right []
结果倒是合情合理, parse 一个空字符串,返回一个空列表。接下来,我们去parse 一个单元格:
ghci> parseCSV "hi"
Left "(unknown)" (line 1, column 3):
unexpected end of input
expecting "," or "\n"
看下上面的报错信息,我们定义“一行”必须以一个换行符结尾,而在上面的输入中,我们并没有给出换行符。Parsec的报错信息给出了错误的行号和列号,甚至告诉了我们它期望得到的输入。我们对上面的输入给出换行符,并且继续尝试新的输入:
ghci> parseCSV "hi\n"
Right [["hi"]]
ghci> parseCSV "line1\nline2\nline3\n"
Right [["line1"],["line2"],["line3"]]
ghci> parseCSV "cell1,cell2,cell3\n"
Right [["cell1","cell2","cell3"]]
ghci> parseCSV "l1c1,l1c2\nl2c1,l2c2\n"
Right [["l1c1","l1c2"],["l2c1","l2c2"]]
ghci> parseCSV "Hi,\n\n,Hello\n"
Right [["Hi",""],[""],["","Hello"]]
可以看出,parseCSV
的行为与预期一致,甚至空单元格与空行它也能正确处理。
sepBy 与 endBy 组合子¶
我们早先向您承诺过,上一节中的 CSV parser可以通过几个辅助函数大大简化。有两个函数可以大幅度简化上一节中的代码。
第一个工具是 sepBy
函数,这个函数接受两个 parser函数作为参数。第一个函数解析有效内容,第二个函数解析一个分隔符。sepBy
首先尝试解析有效内容,然后去解析分隔符,然后有效内容与分隔符依次交替解析,直到解析完有效内容之后无法继续解析到分隔符为止。它返回有效内容的列表。
第二个工具是 endBy
, 它与sepBy
相似,不过它期望它的最后一个有效内容之后,还跟着一个分隔符(译注,就是parse “a\nb\nc\n”这种,而 sepBy
是 parse “a,b,c”这种)。也就是说,它将一直进行 parse,直到它无法继续消耗任何输入。
于是,我们可以用 endBy
来解析行,因为每一行必定是以一个换行字符结尾。 我们可以用 sepBy
来解析一行中的所有单元格,因为一行中的单元格以逗号分割,而最后一个单元格后面并不跟着逗号。我们来看下现在的parser 有多么简单:
-- file: ch16/csv2.hs
import Text.ParserCombinators.Parsec
csvFile = endBy line eol
line = sepBy cell (char ',')
cell = many (noneOf ",\n")
eol = char '\n'
parseCSV :: String -> Either ParseError [[String]]
parseCSV input = parse csvFile "(unknown)" input
这个程序的行为同上一节中的一样,我们可以通过使用 ghci
重新运行上一节中的测试用例来验证,我们会得到完全相同的结果。然而现在的程序更短、可读性更好。你不用花太多时间就能把这段代码翻译成中文描述,当你阅读这段代码时,你将看到:
- 一个 CSV 文件包含0行或者更多行,每一行都是以换行符结尾。
- 一行包含一个或者多个单元格(译者注, sepBy应该是允许0个单元格的)
- 一个单元格包含0个或者更多个字符,这些字符不能是逗号或者换行符
- 换行符是’\n’
选择与错误处理¶
不同操作系统采用不同的字符来表示换行,例如,Unix/Linux 系统中,以及Windows 的 text mode 中,简单地用 “\n” 来表示。DOS 以及 Windows系统,使用 “\r\n”,而 Mac 一直采用 “\r”。我们还可以添加对 “\n\r”的支持,因为有些人可能会需要。
我们可以很容易地修改下上面的代码来适应这些不同的换行符。我们只需要做两处改动,修改下eol
的定义,使它识别不同的换行符,修改下 cell
函数中的noneOf
的匹配模式,让它忽略 “\r”。
这事做起来得小心些,之前 eol
的定义就是简单的char '\n'
,而现在我们使用另一个内置的 parser 函数叫做string
,它可以匹配一个给定的字符串,我们来考虑下如何用这个函数来增加对“\n\r” 的支持。
我们的初次尝试,就像这样:
-- file: ch16/csv3.hs
-- This function is not correct!
eol = string "\n" <|> string "\n\r"
然而上面的例子并不正确,<|>
操作符总是首先尝试左边的 parser,即string "\n"
, 但是对于 “\n” 和 “\n\r” 这两种换行符,string "\n"
都会匹配成功,这可不是我们想要的,不妨在 ghci
中尝试一下:
ghci> :m Text.ParserCombinators.Parsec
ghci> let eol = string "\n