為一個文本文件或者不同類型的數(shù)據(jù)做語法分析(parsing),對程序員來說是個很常見的任務,在本書第198頁“使用正則表達式”一節(jié)中,我們已經(jīng)學習了Haskell 對正則表達式的支持。對很多這樣的任務,正則表達式都很好用。
不過,當處理復雜的數(shù)據(jù)格式時,正則表達式很快就會變得不實用、甚至完全不可用。比如說,對于多數(shù)編程語言來說,我們沒法(只)用正則表達式去parse 其源代碼。
Parsec 是一個很有用的 parsercombinator 庫,使用Parsec,我們可以將一些小的、簡單的 parser 組合成更復雜的 parser。Parsec提供了一些簡單的 parser,以及一些用于將這些 parser組合在一起的組合子。毫不意外,這個為 Haskell 設計的 parser庫是函數(shù)式的。
將 Parsec 同其他語言的 parse工具做下對比是很有幫助的,語法分析有時會被分為兩個階段:詞法分析(這方面的工具比如flex
)和語法分析(比如 bison
). Parsec可以同時處理詞法分析和語法分析。(譯注:詞法分析將輸入的字符串序列轉化為一個個的token,而語法分析進一步接受這些 token 作為輸入生成語法樹)
讓我們來寫一個解析 CSV 文件的代碼。CSV是純文本文件,常被用來表示表格或者數(shù)據(jù)庫。每行是一個記錄,一個記錄中的字段用逗號分隔。至于包含逗號的字段,有特殊的處理方法,不過在這一節(jié)我們暫時不考慮這種情況。
下面的代碼比實際需要的代碼要長一些,不過接下來,我們很快就會介紹一些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,第一個函數(shù)是csvFile
。它的類型是 GenParser Char st [[String]]
,這表示這個函數(shù)的輸入是字符序列,也就是 Haskell 中的字符串,因為String
不過是 [Char]
的別名,而這個函數(shù)的返回類型是[[String]]
: 一個字符串列表的列表。至于 st
,我們暫時忽略它
Parsec程序員經(jīng)常會寫一些小函數(shù),因此他們常常懶得寫函數(shù)的類型簽名。Haskell的類型推導系統(tǒng)能夠自動識別函數(shù)類型。而在上面第一個例子中,我們寫出了所有函數(shù)的類型,方便你了解函數(shù)到底在干什么。另外你可以在ghci
中使用 :t
來查看函數(shù)的類型。
csvFile
函數(shù)使用了 do
語句,如其所示,Parsec 庫是 monadic的,它定義了用于語法分析的[1][ref1]:Genparser
monad。
csvFile
函數(shù)首先運行的是many line
,many
是一個高階函數(shù),它接受一個 parser函數(shù)作為參數(shù),不斷對輸入應用這個 parser,并把每次 parse的結果組成一個列表返回。在 csvFile
中,我們把對 csv文件中所有行的解析結果存儲到 result
中,然后,當我們遇到文件終結符EOF 時,就返回 result
。也就是說:一個 CSV 文件有好多行組成,以 EOF結尾。Parsec 寫成的函數(shù)如此簡潔,我們常常能夠像這樣直接用語言來解釋。
上一段說,一個 CSV文件由許多行組成,現(xiàn)在,我們需要說明,什么是“一行”,為此,我們定義了line
函數(shù)來解析 CSV文件中的一行,通過閱讀函數(shù)代碼,我們可以發(fā)現(xiàn),CSV文件中的一行,包括許多“單元格”,最后跟著一個換行符。
那么,什么是“許多單元格”呢,我們通過 cells
函數(shù)來解析一行中的所有單元格。一行中的所有單元格,包括一個到多個單元格。因此,我們首先解析第一個單元格的內(nèi)容,然后,解析剩下的單元格,返回剩下的單元格內(nèi)容組成的列表,最后,cells
把第一個單元格與剩余單元格列表組成一個新的單元格列表返回。
我們先跳過 remainingCells
函數(shù),去看cellContent
函數(shù),cellContent
解析一個單元格的內(nèi)容。一個單元格可以包含任意數(shù)量的字符,但每一個字符都不能是逗號或者換行符(譯注:實際可以包含逗號,不過我們目前不考慮這種情況),我們使用noneOf
函數(shù)來匹配這兩個特殊字符,來確保我們遇到的不是這樣的字符,于是,many noneOf ",\n"
定義了一個單元格。
然后再來看 remainingCells
函數(shù),這個函數(shù)用來在解析完一行中第一個單元格之后,解析該行中剩余的單元格。在這個函數(shù)中,我們初次使用了Parsec 中的選擇操作,選擇操作符是<|>
。這個操作符是這樣定義的:它會首先嘗試操作符左邊的 parser函數(shù),如果這個parser沒能成功消耗任何輸入字符(譯注:沒有消耗任何輸入,即是說,從輸入字符串的第一個字符,就可以判定無法成功解析,例如,我們希望解析”html”這個字符串,遇到的卻是”php”,那從”php”的第一個字符’p’,就可以判定不會解析成功。而如果遇到的是”http”,那么我們需要消耗掉”ht”這兩個字符之后,才判定匹配失敗,此時,即使已經(jīng)匹配失敗,”ht”這兩個字符仍然是被消耗掉了),那么,就嘗試操作符右邊的parser。
在函數(shù) remainingCells
中,我們的任務是去解析第一個單元格之后的所有單元格,cellContent
函數(shù)使用了 noneOf ",\n"
,所以逗號和換行符不會被 cellContent
消耗掉,因此,如果我們在解析完一個單元格之后,見到了一個逗號,這說明這一行不止一個單元格。所以,remainingCells
選擇操作中的第一個選擇的開始是一個 char ','
來判斷是否還有剩余單元格,char
這個 parser簡單的匹配輸入中傳入的字符,如果我們發(fā)現(xiàn)一個逗號,我們希望這個去繼續(xù)解析剩余的單元格,這個時候,“剩下的單元格”看上去跟一行中的所有單元格在格式上一致。所以,我們遞歸地調用cells
去解析它們。如果我們沒有發(fā)現(xiàn)逗號,說明這一行中再沒有剩余的單元格,就返回一個空列表。
最后,我們需要定義換行符,我們將換行符設定為字符’\n’,這個設定到目前來講已經(jīng)夠用了。
在整個程序的最后,我們定義函數(shù) parseCSV
,它接受一個 String
類型的參數(shù),并將其作為 CSV 文件進行解析。這個函數(shù)只是對 Parsec 中parse
函數(shù)的簡單封裝,parse
函數(shù)返回Either ParseError [[String]]
類型,如果輸入格式有錯誤,則返回的是用 Left
標記的錯誤信息,否則,返回用Right
標記的解析生成的數(shù)據(jù)類型。
理解了上面的代碼之后,我們試著在 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 一個空字符串,返回一個空列表。接下來,我們?nèi)arse 一個單元格:
ghci> parseCSV "hi"
Left "(unknown)" (line 1, column 3):
unexpected end of input
expecting "," or "\n"
看下上面的報錯信息,我們定義“一行”必須以一個換行符結尾,而在上面的輸入中,我們并沒有給出換行符。Parsec的報錯信息給出了錯誤的行號和列號,甚至告訴了我們它期望得到的輸入。我們對上面的輸入給出換行符,并且繼續(xù)嘗試新的輸入:
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
的行為與預期一致,甚至空單元格與空行它也能正確處理。
我們早先向您承諾過,上一節(jié)中的 CSV parser可以通過幾個輔助函數(shù)大大簡化。有兩個函數(shù)可以大幅度簡化上一節(jié)中的代碼。
第一個工具是 sepBy
函數(shù),這個函數(shù)接受兩個 parser函數(shù)作為參數(shù)。第一個函數(shù)解析有效內(nèi)容,第二個函數(shù)解析一個分隔符。sepBy
首先嘗試解析有效內(nèi)容,然后去解析分隔符,然后有效內(nèi)容與分隔符依次交替解析,直到解析完有效內(nèi)容之后無法繼續(xù)解析到分隔符為止。它返回有效內(nèi)容的列表。
第二個工具是 endBy
, 它與sepBy
相似,不過它期望它的最后一個有效內(nèi)容之后,還跟著一個分隔符(譯注,就是parse “a\nb\nc\n”這種,而 sepBy
是 parse “a,b,c”這種)。也就是說,它將一直進行 parse,直到它無法繼續(xù)消耗任何輸入。
于是,我們可以用 endBy
來解析行,因為每一行必定是以一個換行字符結尾。 我們可以用 sepBy
來解析一行中的所有單元格,因為一行中的單元格以逗號分割,而最后一個單元格后面并不跟著逗號。我們來看下現(xiàn)在的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
這個程序的行為同上一節(jié)中的一樣,我們可以通過使用 ghci
重新運行上一節(jié)中的測試用例來驗證,我們會得到完全相同的結果。然而現(xiàn)在的程序更短、可讀性更好。你不用花太多時間就能把這段代碼翻譯成中文描述,當你閱讀這段代碼時,你將看到:
不同操作系統(tǒng)采用不同的字符來表示換行,例如,Unix/Linux 系統(tǒng)中,以及Windows 的 text mode 中,簡單地用 “\n” 來表示。DOS 以及 Windows系統(tǒng),使用 “\r\n”,而 Mac 一直采用 “\r”。我們還可以添加對 “\n\r”的支持,因為有些人可能會需要。
我們可以很容易地修改下上面的代碼來適應這些不同的換行符。我們只需要做兩處改動,修改下eol
的定義,使它識別不同的換行符,修改下 cell
函數(shù)中的noneOf
的匹配模式,讓它忽略 “\r”。
這事做起來得小心些,之前 eol
的定義就是簡單的char '\n'
,而現(xiàn)在我們使用另一個內(nèi)置的 parser 函數(shù)叫做string
,它可以匹配一個給定的字符串,我們來考慮下如何用這個函數(shù)來增加對“\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
更多建議: