第十六章:使用Parsec

2018-08-12 22:19 更新

為一個(gè)文本文件或者不同類型的數(shù)據(jù)做語(yǔ)法分析(parsing),對(duì)程序員來(lái)說(shuō)是個(gè)很常見(jiàn)的任務(wù),在本書(shū)第198頁(yè)“使用正則表達(dá)式”一節(jié)中,我們已經(jīng)學(xué)習(xí)了Haskell 對(duì)正則表達(dá)式的支持。對(duì)很多這樣的任務(wù),正則表達(dá)式都很好用。

不過(guò),當(dāng)處理復(fù)雜的數(shù)據(jù)格式時(shí),正則表達(dá)式很快就會(huì)變得不實(shí)用、甚至完全不可用。比如說(shuō),對(duì)于多數(shù)編程語(yǔ)言來(lái)說(shuō),我們沒(méi)法(只)用正則表達(dá)式去parse 其源代碼。

Parsec 是一個(gè)很有用的 parsercombinator 庫(kù),使用Parsec,我們可以將一些小的、簡(jiǎn)單的 parser 組合成更復(fù)雜的 parser。Parsec提供了一些簡(jiǎn)單的 parser,以及一些用于將這些 parser組合在一起的組合子。毫不意外,這個(gè)為 Haskell 設(shè)計(jì)的 parser庫(kù)是函數(shù)式的。

將 Parsec 同其他語(yǔ)言的 parse工具做下對(duì)比是很有幫助的,語(yǔ)法分析有時(shí)會(huì)被分為兩個(gè)階段:詞法分析(這方面的工具比如flex )和語(yǔ)法分析(比如 bison ). Parsec可以同時(shí)處理詞法分析和語(yǔ)法分析。(譯注:詞法分析將輸入的字符串序列轉(zhuǎn)化為一個(gè)個(gè)的token,而語(yǔ)法分析進(jìn)一步接受這些 token 作為輸入生成語(yǔ)法樹(shù))

Parsec 初步:簡(jiǎn)單的 CSV parser?

讓我們來(lái)寫(xiě)一個(gè)解析 CSV 文件的代碼。CSV是純文本文件,常被用來(lái)表示表格或者數(shù)據(jù)庫(kù)。每行是一個(gè)記錄,一個(gè)記錄中的字段用逗號(hào)分隔。至于包含逗號(hào)的字段,有特殊的處理方法,不過(guò)在這一節(jié)我們暫時(shí)不考慮這種情況。

下面的代碼比實(shí)際需要的代碼要長(zhǎng)一些,不過(guò)接下來(lái),我們很快就會(huì)介紹一些Parsec 的特性,應(yīng)用這些特性,整個(gè) 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

我們來(lái)講解下這段代碼,在這段代碼中,我們并沒(méi)有使用 Parsec的特性,因此要記住這段代碼還能寫(xiě)得更簡(jiǎn)潔!

我們自頂向下的構(gòu)建了一個(gè) CSV 的 parser,第一個(gè)函數(shù)是csvFile。它的類型是 GenParser Char st [[String]],這表示這個(gè)函數(shù)的輸入是字符序列,也就是 Haskell 中的字符串,因?yàn)?code class="docutils literal">String 不過(guò)是 [Char] 的別名,而這個(gè)函數(shù)的返回類型是[[String]] : 一個(gè)字符串列表的列表。至于 st ,我們暫時(shí)忽略它

Parsec程序員經(jīng)常會(huì)寫(xiě)一些小函數(shù),因此他們常常懶得寫(xiě)函數(shù)的類型簽名。Haskell的類型推導(dǎo)系統(tǒng)能夠自動(dòng)識(shí)別函數(shù)類型。而在上面第一個(gè)例子中,我們寫(xiě)出了所有函數(shù)的類型,方便你了解函數(shù)到底在干什么。另外你可以在ghci 中使用 :t 來(lái)查看函數(shù)的類型。

csvFile 函數(shù)使用了 do 語(yǔ)句,如其所示,Parsec 庫(kù)是 monadic的,它定義了用于語(yǔ)法分析的[1][ref1]:Genparser monad。

csvFile 函數(shù)首先運(yùn)行的是many line,many是一個(gè)高階函數(shù),它接受一個(gè) parser函數(shù)作為參數(shù),不斷對(duì)輸入應(yīng)用這個(gè) parser,并把每次 parse的結(jié)果組成一個(gè)列表返回。在 csvFile 中,我們把對(duì) csv文件中所有行的解析結(jié)果存儲(chǔ)到 result中,然后,當(dāng)我們遇到文件終結(jié)符EOF 時(shí),就返回 result。也就是說(shuō):一個(gè) CSV 文件有好多行組成,以 EOF結(jié)尾。Parsec 寫(xiě)成的函數(shù)如此簡(jiǎn)潔,我們常常能夠像這樣直接用語(yǔ)言來(lái)解釋。

上一段說(shuō),一個(gè) CSV文件由許多行組成,現(xiàn)在,我們需要說(shuō)明,什么是“一行”,為此,我們定義了line 函數(shù)來(lái)解析 CSV文件中的一行,通過(guò)閱讀函數(shù)代碼,我們可以發(fā)現(xiàn),CSV文件中的一行,包括許多“單元格”,最后跟著一個(gè)換行符。

那么,什么是“許多單元格”呢,我們通過(guò) cells函數(shù)來(lái)解析一行中的所有單元格。一行中的所有單元格,包括一個(gè)到多個(gè)單元格。因此,我們首先解析第一個(gè)單元格的內(nèi)容,然后,解析剩下的單元格,返回剩下的單元格內(nèi)容組成的列表,最后,cells把第一個(gè)單元格與剩余單元格列表組成一個(gè)新的單元格列表返回。

我們先跳過(guò) remainingCells 函數(shù),去看cellContent函數(shù),cellContent解析一個(gè)單元格的內(nèi)容。一個(gè)單元格可以包含任意數(shù)量的字符,但每一個(gè)字符都不能是逗號(hào)或者換行符(譯注:實(shí)際可以包含逗號(hào),不過(guò)我們目前不考慮這種情況),我們使用noneOf函數(shù)來(lái)匹配這兩個(gè)特殊字符,來(lái)確保我們遇到的不是這樣的字符,于是,many noneOf ",\n"定義了一個(gè)單元格。

然后再來(lái)看 remainingCells函數(shù),這個(gè)函數(shù)用來(lái)在解析完一行中第一個(gè)單元格之后,解析該行中剩余的單元格。在這個(gè)函數(shù)中,我們初次使用了Parsec 中的選擇操作,選擇操作符是<|>。這個(gè)操作符是這樣定義的:它會(huì)首先嘗試操作符左邊的 parser函數(shù),如果這個(gè)parser沒(méi)能成功消耗任何輸入字符(譯注:沒(méi)有消耗任何輸入,即是說(shuō),從輸入字符串的第一個(gè)字符,就可以判定無(wú)法成功解析,例如,我們希望解析”html”這個(gè)字符串,遇到的卻是”php”,那從”php”的第一個(gè)字符’p’,就可以判定不會(huì)解析成功。而如果遇到的是”http”,那么我們需要消耗掉”ht”這兩個(gè)字符之后,才判定匹配失敗,此時(shí),即使已經(jīng)匹配失敗,”ht”這兩個(gè)字符仍然是被消耗掉了),那么,就嘗試操作符右邊的parser。

在函數(shù) remainingCells中,我們的任務(wù)是去解析第一個(gè)單元格之后的所有單元格,cellContent函數(shù)使用了 noneOf ",\n",所以逗號(hào)和換行符不會(huì)被 cellContent消耗掉,因此,如果我們?cè)诮馕鐾暌粋€(gè)單元格之后,見(jiàn)到了一個(gè)逗號(hào),這說(shuō)明這一行不止一個(gè)單元格。所以,remainingCells選擇操作中的第一個(gè)選擇的開(kāi)始是一個(gè) char ','來(lái)判斷是否還有剩余單元格,char 這個(gè) parser簡(jiǎn)單的匹配輸入中傳入的字符,如果我們發(fā)現(xiàn)一個(gè)逗號(hào),我們希望這個(gè)去繼續(xù)解析剩余的單元格,這個(gè)時(shí)候,“剩下的單元格”看上去跟一行中的所有單元格在格式上一致。所以,我們遞歸地調(diào)用cells去解析它們。如果我們沒(méi)有發(fā)現(xiàn)逗號(hào),說(shuō)明這一行中再?zèng)]有剩余的單元格,就返回一個(gè)空列表。

最后,我們需要定義換行符,我們將換行符設(shè)定為字符’\n’,這個(gè)設(shè)定到目前來(lái)講已經(jīng)夠用了。

在整個(gè)程序的最后,我們定義函數(shù) parseCSV,它接受一個(gè) String類型的參數(shù),并將其作為 CSV 文件進(jìn)行解析。這個(gè)函數(shù)只是對(duì) Parsec 中parse 函數(shù)的簡(jiǎn)單封裝,parse 函數(shù)返回Either ParseError [[String]]類型,如果輸入格式有錯(cuò)誤,則返回的是用 Left 標(biāo)記的錯(cuò)誤信息,否則,返回用Right 標(biāo)記的解析生成的數(shù)據(jù)類型。

理解了上面的代碼之后,我們?cè)囍?ghci 中運(yùn)行一下來(lái)看下它:

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 []

結(jié)果倒是合情合理, parse 一個(gè)空字符串,返回一個(gè)空列表。接下來(lái),我們?nèi)arse 一個(gè)單元格:

ghci> parseCSV "hi"
Left "(unknown)" (line 1, column 3):
unexpected end of input
expecting "," or "\n"

看下上面的報(bào)錯(cuò)信息,我們定義“一行”必須以一個(gè)換行符結(jié)尾,而在上面的輸入中,我們并沒(méi)有給出換行符。Parsec的報(bào)錯(cuò)信息給出了錯(cuò)誤的行號(hào)和列號(hào),甚至告訴了我們它期望得到的輸入。我們對(duì)上面的輸入給出換行符,并且繼續(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的行為與預(yù)期一致,甚至空單元格與空行它也能正確處理。

sepBy 與 endBy 組合子?

我們?cè)缦认蚰兄Z過(guò),上一節(jié)中的 CSV parser可以通過(guò)幾個(gè)輔助函數(shù)大大簡(jiǎn)化。有兩個(gè)函數(shù)可以大幅度簡(jiǎn)化上一節(jié)中的代碼。

第一個(gè)工具是 sepBy 函數(shù),這個(gè)函數(shù)接受兩個(gè) parser函數(shù)作為參數(shù)。第一個(gè)函數(shù)解析有效內(nèi)容,第二個(gè)函數(shù)解析一個(gè)分隔符。sepBy首先嘗試解析有效內(nèi)容,然后去解析分隔符,然后有效內(nèi)容與分隔符依次交替解析,直到解析完有效內(nèi)容之后無(wú)法繼續(xù)解析到分隔符為止。它返回有效內(nèi)容的列表。

第二個(gè)工具是 endBy, 它與sepBy相似,不過(guò)它期望它的最后一個(gè)有效內(nèi)容之后,還跟著一個(gè)分隔符(譯注,就是parse “a\nb\nc\n”這種,而 sepBy 是 parse “a,b,c”這種)。也就是說(shuō),它將一直進(jìn)行 parse,直到它無(wú)法繼續(xù)消耗任何輸入。

于是,我們可以用 endBy來(lái)解析行,因?yàn)槊恳恍斜囟ㄊ且砸粋€(gè)換行字符結(jié)尾。 我們可以用 sepBy來(lái)解析一行中的所有單元格,因?yàn)橐恍兄械膯卧褚远禾?hào)分割,而最后一個(gè)單元格后面并不跟著逗號(hào)。我們來(lái)看下現(xiàn)在的parser 有多么簡(jiǎn)單:

-- 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

這個(gè)程序的行為同上一節(jié)中的一樣,我們可以通過(guò)使用 ghci重新運(yùn)行上一節(jié)中的測(cè)試用例來(lái)驗(yàn)證,我們會(huì)得到完全相同的結(jié)果。然而現(xiàn)在的程序更短、可讀性更好。你不用花太多時(shí)間就能把這段代碼翻譯成中文描述,當(dāng)你閱讀這段代碼時(shí),你將看到:

  • 一個(gè) CSV 文件包含0行或者更多行,每一行都是以換行符結(jié)尾。
  • 一行包含一個(gè)或者多個(gè)單元格(譯者注, sepBy應(yīng)該是允許0個(gè)單元格的)
  • 一個(gè)單元格包含0個(gè)或者更多個(gè)字符,這些字符不能是逗號(hào)或者換行符
  • 換行符是’\n’

選擇與錯(cuò)誤處理?

不同操作系統(tǒng)采用不同的字符來(lái)表示換行,例如,Unix/Linux 系統(tǒng)中,以及Windows 的 text mode 中,簡(jiǎn)單地用 “\n” 來(lái)表示。DOS 以及 Windows系統(tǒng),使用 “\r\n”,而 Mac 一直采用 “\r”。我們還可以添加對(duì) “\n\r”的支持,因?yàn)橛行┤丝赡軙?huì)需要。

我們可以很容易地修改下上面的代碼來(lái)適應(yīng)這些不同的換行符。我們只需要做兩處改動(dòng),修改下eol 的定義,使它識(shí)別不同的換行符,修改下 cell 函數(shù)中的noneOf 的匹配模式,讓它忽略 “\r”。

這事做起來(lái)得小心些,之前 eol 的定義就是簡(jiǎn)單的char '\n',而現(xiàn)在我們使用另一個(gè)內(nèi)置的 parser 函數(shù)叫做string,它可以匹配一個(gè)給定的字符串,我們來(lái)考慮下如何用這個(gè)函數(shù)來(lái)增加對(duì)“\n\r” 的支持。

我們的初次嘗試,就像這樣:

-- file: ch16/csv3.hs
-- This function is not correct!
eol = string "\n" <|> string "\n\r"

然而上面的例子并不正確,<|> 操作符總是首先嘗試左邊的 parser,即string "\n", 但是對(duì)于 “\n” 和 “\n\r” 這兩種換行符,string "\n" 都會(huì)匹配成功,這可不是我們想要的,不妨在 ghci中嘗試一下:

ghci> :m Text.ParserCombinators.Parsec
ghci> let eol = string "\n
以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)