第八章:高效文件處理、正則表達(dá)式、文件名匹配

2018-02-24 15:49 更新

第八章:高效文件處理、正則表達(dá)式、文件名匹配

高效文件處理

下面是個簡單的基準(zhǔn)測試,讀取一個由數(shù)字構(gòu)成的文本文件,并打印它們的和。

-- file: ch08/SumFile.hs
main = do
    contents <- getContents
    print (sumFile contents)
  where sumFile = sum . map read . words

盡管讀寫文件時,默認(rèn)使用 String 類型,但它并不高效,所以這樣簡單的程序效率會很糟糕。

一個 String 代表一個元素類型為 Char 的列表;列表的每個元素被單獨分配內(nèi)存,并有一定的寫入開銷。對那些要讀取文本及二進(jìn)制數(shù)據(jù)的程序來說,這些因素會影響內(nèi)存消耗和執(zhí)行效率。在這個簡單的測試中,即使是 Python 那樣的解釋型語言的表現(xiàn)也會大大好于使用 String 的 Haskell 代碼。

bytestring 庫是 String 類型的一個快速、經(jīng)濟(jì)的替代品。在保持 Haskell 代碼的表現(xiàn)力和簡潔的同時,使用 bytestring 編寫的代碼在內(nèi)存占用和執(zhí)行效率經(jīng)??梢赃_(dá)到或超過 C 代碼。

這個庫提供兩個模塊。每個都定義了與 String 類型上函數(shù)對應(yīng)的替代物。

  • Data.ByteString 定義了一個名為 ByteString 的嚴(yán)格類型,其將一個字符串或二進(jìn)制數(shù)據(jù)或文本用一個數(shù)組表示。
  • Data.ByteString.Lazy 模塊定義了一個惰性類型,同樣命名為 ByteString 。其將字符串?dāng)?shù)據(jù)表示為一個由 塊 組成的列表,每個塊是大小為 64KB 的數(shù)組。

這兩種 ByteString 適用于不同的場景。對于大體積的文件流(幾百 MB 至幾 TB),最好使用惰性的 ByteString 。其塊的大小被調(diào)整得對現(xiàn)代 CPU 的 L1 緩存特別友好,并且在流中已經(jīng)被處理過塊可以被垃圾收集器快速丟棄。

對于不在意內(nèi)存占用而且需要隨機(jī)訪問的數(shù)據(jù),最好使用嚴(yán)格的 ByteString 類型。

二進(jìn)制 I/O 和有限載入

讓我們來開發(fā)一個小函數(shù)以說明 ByteString API 的一些用法。我們將檢測一個文件是否是 ELF object 文件:這種文件類型幾乎被所有現(xiàn)代類 Unix 系統(tǒng)作為可執(zhí)行文件。

這個簡單的問題可以通過查看文件頭部的四個字節(jié)解決,看他們是否匹配某個特定的字節(jié)序列。表示某種文件類型的字節(jié)序列通常被稱為 魔法數(shù) 。

-- file: ch08/ElfMagic.hs
import qualified Data.ByteString.Lazy as L

hasElfMagic :: L.ByteString -> Bool
hasElfMagic content = L.take 4 content == elfMagic
    where elfMagic = L.pack [0x7f, 0x45, 0x4c, 0x46]

我們使用 Haskell 的 有限載入 語法載入 ByteString 模塊, 像上面 importqualified 那句那樣。這樣可以把一個模塊關(guān)聯(lián)到另一個我們選定的名字。

例如,使用到惰性 ByteString 模塊的 take 函數(shù)時,要寫成 L.take ,因為我們將這個模塊載入到了 L 這個名字下。若沒有明確指明使用哪個版本的函數(shù),如此處的 take ,編譯器會報錯。

我們將一直使用有限載入語法使用 ByteString 模塊,因為其中提供的很多函數(shù)與 Prelude 模塊中的函數(shù)重名。

Note

有限載入使得可以方便地切換兩種 ByteString 類型。只需要在代碼的頭部改變 import 聲明;剩余的代碼可能無需任何修改。你可以方便地比較兩種類型,以觀察哪種類型更符合你程序的需要。

無論是否使用有限載入,始終可以使用模塊的全名來識別某些混淆。例如, Data.ByteString.Lazy.length 和 L.length 表示相同的函數(shù), Prelude.sum 和 sum 也是如此。

ByteString 模塊為二進(jìn)制 I/O 而設(shè)計。Haskell 中表達(dá)字節(jié)的類型是 Word8 ;如果需要按名字引用它,需要將其從 Data.Word 模塊載入。

L.pack 函數(shù)接受一個由 Word8 組成的列表,并將其裝入一個惰性 ByteString ( L.unpack 函數(shù)的作用恰好相反。)。 hasElfMagic 函數(shù)簡單地將一個 ByteString 的前四字節(jié)與一個魔法數(shù)相比較。

我們使用了典型的 Haskell 風(fēng)格編寫 hasElfMagic 函數(shù),其并不執(zhí)行 I/O。這里是如何在真正的文件上使用它。

-- file: ch08/ElfMagic.hs
isElfFile :: FilePath -> IO Bool
isElfFile path = do
  content <- L.readFile path
  return (hasElfMagic content)

L.readFile 函數(shù)是 readFile 的惰性 ByteString 等價物。它是惰性執(zhí)行的,將文件讀取為數(shù)據(jù)是需要的。它也很高效,立即讀取 64KB 大小的塊。對我們的任務(wù)而言,惰性 ByteString 是一個好選擇,我們可以安全的將這個函數(shù)應(yīng)用在任意大小的文件上。

文本 I/O

方便起見, bytestring 庫提供兩個具有有限文本 I/O 功能的模塊, Data.ByteString.Char8 和 Data.ByteSring.Lazy.Char8 。它們將每個字符串的元素暴露為 Char 而非 Word8 。

Warning

這些模塊中的函數(shù)適用于單字節(jié)大小的 Char 值,所以他們僅適用于 ASCII 及某些歐洲字符集。大于 255 的值將被截斷。

這兩個面向字符的 bytestring 模塊提供了用于文本處理的函數(shù)。以下文件包含了一家知名互聯(lián)網(wǎng)公司在 2008 年中期每個月的股價。

如何在這一系列記錄中找到最高收盤價呢?收盤價位于以逗號分隔的第四列。以下函數(shù)從單行數(shù)據(jù)中獲取收盤價。

-- file: ch08/HighestClose.hs
import qualified Data.ByteString.Lazy.Char8 as L

closing = readPrice . (!!4) . L.split ','

這個函數(shù)使用 point-free 風(fēng)格編寫,我們要從右向左閱讀。 L.split 函數(shù)將一個惰性 ByteString 按某個分隔符切分為一個由 ByteString 組成的列表。 (!!) 操作符檢索列表中的第 k 個元素。 readPrice 函數(shù)將一個表示小數(shù)的字符串轉(zhuǎn)換為一個數(shù)。

- file: ch08/HighestClose.hs
readPrice :: L.ByteString -> Maybe Int
readPrice str =
    case L.readInt str of
      Nothing             -> Nothing
      Just (dollars,rest) ->
        case L.readInt (L.tail rest) of
          Nothing           -> Nothing
          Just (cents,more) ->
            Just (dollars * 100 + cents)

我們使用 L.readInt 函數(shù)來解析一個整數(shù)。當(dāng)發(fā)現(xiàn)數(shù)字時,它會將一個整數(shù)和字符串的剩余部分一起返回。 L.readInt 在解析失敗時返回 Nothing ,這導(dǎo)致我們的函數(shù)稍有些復(fù)雜。

查找最高收盤價的函數(shù)很容易編寫。

-- file: ch08/HighestClose.hs
highestClose = maximum . (Nothing:) . map closing . L.lines

highestCloseFrom path = do
    contents <- L.readFile path
    print (highestClose contents)

不能對空列表使用 maximum 函數(shù),所以我們耍了點小把戲。

ghci> maximum [3,6,2,9]
9
ghci> maximum []
*** Exception: Prelude.maximum: empty list

我們想在沒有股票數(shù)據(jù)時也不拋出異常,所以用 (Nothing:) 這個表達(dá)式來確保輸入到 maximum 函數(shù)的由 MaybeInt 值構(gòu)成的列表總是非空。

ghci> maximum [Nothing, Just 1]
Just 1
ghci> maximum [Nothing]
Nothing

我們的函數(shù)工作正常嗎?

ghci> :load HighestClose
[1 of 1] Compiling Main             ( HighestClose.hs, interpreted )
Ok, modules loaded: Main.
ghci> highestCloseFrom "prices.csv"
Loading package array-0.1.0.0 ... linking ... done.
Loading package bytestring-0.9.0.1 ... linking ... done.
Just 2741

因為我們把邏輯和 I/O 分離開了,所以即使不創(chuàng)建一個空文件也可以測試無數(shù)據(jù)的情況。

ghci> highestClose L.empty
Nothing

匹配文件名

很多面向操作系統(tǒng)的編程語言提供了檢測某個文件名是否匹配給定模式的庫函數(shù),或者返回一個匹配給定模式的文件列表。在其他語言中,這個函數(shù)通常叫做 fmatch 。盡管 Haskell 標(biāo)準(zhǔn)庫提供了很多有用的系統(tǒng)編程設(shè)施,但是并沒有提供這類用于匹配文件名的函數(shù)。所以我們可以自己開發(fā)一個。

我們需要處理的模式種類通常稱為 glob 模式(我們將使用這個術(shù)語),通配符模式,或稱 shell 風(fēng)格模式。它們僅是一些簡單規(guī)則。你可能已經(jīng)了解了,但是這里將做一個簡要的回顧。

Note

  • 對某個模式的匹配從字符串頭部開始,在字符串尾部結(jié)束。
  • 多數(shù)文本字符匹配自身。例如,文本 foo 作為模式匹配其自身 foo ,且在一個輸入字符串中僅匹配 foo 。
    • (星號) 意味著 “匹配所有”; 其將匹配所有文本,包括空字符串。 例如, 模式 foo 將匹配任意以 foo 開頭的字符串,比如 foo 自身, foobar , 或 foo.c 。 模式 quux.c 將匹配任何以 quux 開頭且以 .c 結(jié)束的字符串,如 quuxbaz.c 。
  • ? (問號) 匹配任意單個字符。模式 pic??.jpg 將匹配類似 picaa.jpg 或 pic01.jpg 的文件名。
  • [ (左方括號) 將開始定義一個字符類,以 ] 結(jié)束。其意思是 “匹配在這個字符類中的任意字符”。 [! 開啟一個否定的字符類,其意為 “匹配不在這個字符類中的任意字符”。

用 - (破折號) 連接的兩個字符,是一種表示范圍的速記方法,表示:“匹配這個圍內(nèi)的任意字符”。

字符類有一個附加的條件;其不可為空。在 [ 或 [! 后的字符是這個字符類的一部分,所以我們可以編寫包含 ] 的字符類,如 []aeiou] 。模式 pic[0-9].[pP][nN][gG] 將匹配由字符串 pic 開始,跟隨單個數(shù)字,最后是字符串 .png 的任意大小寫形式。

盡管 Haskell 的標(biāo)準(zhǔn)庫沒有提供匹配 glob 模式的方法,但它提供了一個良好的正則表達(dá)式庫。Glob 模式僅是一個從正則表達(dá)式中切分出來的略有不同的子集。很容易將 glob 模式轉(zhuǎn)換為正則表達(dá)式,但在此之前,我們首先要了解怎樣在 Haskell 中使用正則表達(dá)式。

Haskell 中的正則表達(dá)式

在這一節(jié),我們將假設(shè)讀者已經(jīng)熟悉 Python、Perl 或 Java 等其他語言中的正則表達(dá)式。

為了簡潔,此后我們將 “regular expression” 簡寫為 regexp。

我們將以與其他語言對比的方式介紹 Haskell 如何處理 regexp,而非從頭講解何為 regexp。Haskell 的正則表達(dá)式庫比其他語言具備更加強(qiáng)大的表現(xiàn)力,所以我們有很多可以聊的。

在我們對 regexp 庫的探索開始時,只需使用 Text.Regex.Posix 工作。一般通過在 ghci 進(jìn)行交互是探索一個模塊最方便的辦法。

ghci> :module +Text.Regex.Posix

可能正則表達(dá)式匹配函數(shù)是我們平時需要使用的唯一的函數(shù),其以中綴預(yù)算符 (=~) (從 Perl 中借鑒) 表示。要克服的第一個障礙是 Haskell 的 regexp 庫重度使用了多態(tài)。其結(jié)果就是, (=~) 的類型簽名非常難懂,所以我們在此對其不做解釋。 =~ 操作符的參數(shù)和返回值都使用了類型類。第一個參數(shù) (=~ 左側(cè)) 是要被匹配的文本;第二個參數(shù) (=~ 右側(cè)) 是準(zhǔn)備匹配的正則表達(dá)式。對每個參數(shù)我們都可以使用 String 或者 ByteString 。 結(jié)果的多種類型 =~ 操作符的返回類型是多態(tài)的,所以 Haskell 編譯器需要一通過一些途徑知道我們想獲得哪種類型的結(jié)果。實際編碼中,可以通過我們?nèi)绾问褂闷ヅ浣Y(jié)果推導(dǎo)出它的類型。但是當(dāng)我們通過 ghci 進(jìn)行探索時,缺少類型推導(dǎo)的線索。如果不指明匹配結(jié)果的類型,ghci 將因其無法獲得足夠信息對匹配結(jié)果進(jìn)行類型推導(dǎo)而報錯。 當(dāng) ghci 無法推斷目標(biāo)的類型時,我們要告訴它想要哪種類型。若想知道正則匹配是否通過時,需要將結(jié)果類型指定為 Bool 型。

ghci> "my left foot" =~ "foo" :: Bool
Loading package array-0.1.0.0 ... linking ... done.
Loading package containers-0.1.0.1 ... linking ... done.
Loading package bytestring-0.9.0.1 ... linking ... done.
Loading package mtl-1.1.0.0 ... linking ... done.
Loading package regex-base-0.93.1 ... linking ... done.
Loading package regex-posix-0.93.1 ... linking ... done.
True
ghci> "your right hand" =~ "bar" :: Bool
False
ghci> "your right hand" =~ "(hand|foot)" :: Bool
True

在 regexp 庫內(nèi)部,有一種類型類名為 RegexContext ,其描述了目標(biāo)類型的行為?;A(chǔ)庫定義了很多這個類型類的實例。 Bool 型是這種類型類的一個實例,所以我們?nèi)』亓艘粋€可用的結(jié)果. 另一個實例是 Int ,可以描述正則表達(dá)式匹配了多少次。

ghci> "a star called henry" =~ "planet" :: Int
0
ghci> "honorificabilitudinitatibus" =~ "[aeiou]" :: Int
13

如果指定結(jié)果類型為 String ,將得到第一個匹配的子串,或者表示無匹配的空字符串。

ghci> "I, B. Ionsonii, uurit a lift'd batch" =~ "(uu|ii)" :: String
"ii"
ghci> "hi ludi, F. Baconis nati, tuiti orbi" =~ "Shakespeare" :: String
""

另一個合法的返回值類型是 [[Srtring]] ,將返回由所有匹配的的字符串組成的列表。

ghci> "I, B. Ionsonii, uurit a lift'd batch" =~ "(uu|ii)" :: [[String]]
[["ii","ii"],["uu","uu"]]
ghci> "hi ludi, F. Baconis nati, tuiti orbi" =~ "Shakespeare" :: [[String]]
[]

Warning

注意 String 類型的結(jié)果

指定結(jié)果為普通的字符串時,要當(dāng)心。因為 (=~) 在表示 “無匹配” 時會返回空字符串,很明顯這導(dǎo)致了難以處理可以匹配空字符串的正則表達(dá)式。這情況出現(xiàn)時,就需要使用另一種不同的結(jié)果類型,比如 [[String]]。

以上是一些 “簡單”的結(jié)果類型,不過還沒說完。在繼續(xù)講解之前,我們先來定義一個在之后的例子中共同使用的模式串??梢栽?ghci 中將這個模式串定義為一個變量,以便節(jié)省一些輸入操作。

ghci> let pat = "(foo[a-z]*bar|quux)"

當(dāng)模式匹配了字符串時,可以獲取很多關(guān)于上下文的信息。如果指定 (String,String,String) 類型的元組作為結(jié)果類型,可以獲取字符串中首次匹配之前的部分,首次匹配的子串,和首次匹配之后的部分。

ghci> "before foodiebar after" =~ pat :: (String,String,String)
("before ","foodiebar"," after")

若匹配失敗,整個字符串會作為 “首次匹配之前” 的部分返回,元組的其他兩個元素將為空字符串。

ghci> "no match here" =~ pat :: (String,String,String)
("no match here","","")

使用四元組作為返回結(jié)果時,元組的第四個元素是一個包含了模式中所有分組的列表。

ghci> "before foodiebar after" =~ pat :: (String,String,String,[String])
("before ","foodiebar"," after",["foodiebar"])

也可以獲得關(guān)于匹配結(jié)果的數(shù)字信息。二元組類型的結(jié)果可以表示首次匹配在字符串中的偏移,以及匹配結(jié)果的長度。如果使用由這種二元組構(gòu)成的列表作為結(jié)果類型,我們將得到所有字符串中所有匹配的此類信息。

ghci> "before foodiebar after" =~ pat :: (Int,Int)
(7,9)
ghci> getAllMatches  ("i foobarbar a quux" =~ pat) :: [(Int,Int)]
[(2,9),(14,4)]

二元組的首個元素(表示偏移的那個),其值為 -1 時,表示匹配失敗。當(dāng)指定返回值為列表時,空表表示失敗。

ghci> "eleemosynary" =~ pat :: (Int,Int)
(-1,0)
ghci> "mondegreen" =~ pat :: [(Int,Int)]
[]

以上并非 RegexContext 類型類的內(nèi)置實例的完整清單。完整的清單可以在 Text.Regex.Base.Context 模塊的文檔中找到。

使函數(shù)具有多態(tài)返回值的能力對于一個靜態(tài)類型語言來說是個不同尋常的特性。

進(jìn)一步了解正則表達(dá)式

不同類型字符串的混合與匹配

之前提到過, =~ 操作符的輸入和返回值都使用了類型類。我們可以在正則表達(dá)式和要匹配的文本中使用 String 或者嚴(yán)格的 ByteString 類型。

ghci> :module +Data.ByteString.Char8
ghci> :type pack "foo"
pack "foo" :: ByteString

我們可以嘗試不同的 String 和 ByteString 組合。

ghci> pack "foo" =~ "bar" :: Bool
False
ghci> "foo" =~ pack "bar" :: Int
0
ghci> getAllMatches (pack "foo" =~ pack "o") :: [(Int, Int)]
[(1,1),(2,1)]

不過,我們需要注意,文本匹配的結(jié)果必須于被匹配的字符串類型一致。讓我們實踐一下,看這是什么意思。

ghci> pack "good food" =~ ".ood" :: [[ByteString]]
[["good"],["food"]]

上面的例子中,我們使用 pack 將一個 String 轉(zhuǎn)換為 ByteString 。這種情況可以通過類型檢查,因為 ByteString 也是一種合法的結(jié)果類型。但是如果輸入字符串類型為 String 類型,在嘗試獲得 ByteString 類型結(jié)果時將會失敗。

ghci> "good food" =~ ".ood" :: [[ByteString]]

<interactive>:55:13:
    No instance for (RegexContext Regex [Char] [[ByteString]])
      arising from a use of ‘=~’
    In the expression: "good food" =~ ".ood" :: [[ByteString]]
    In an equation for ‘it’:
        it = "good food" =~ ".ood" :: [[ByteString]]

將結(jié)果類型指定為與被匹配字符串相同的 String 類型就可以輕松地解決這個問題。

ghci> "good food" =~ ".ood" :: [[String]]
[["good"],["food"]]

對于正則表達(dá)式不存在這個限制。正則表達(dá)式可以是 String 或 ByteString ,而不必在意輸入或結(jié)果是何種類型。

你要知道的其他一些事情

查閱 Haskell 的庫文檔,會發(fā)現(xiàn)很多和正則表達(dá)式有關(guān)的模塊。 Text.Regex.Base 下的模塊定義了供其他所有正則表達(dá)式庫使用的通用 API ??梢酝瑫r安裝許多不同實現(xiàn)的正則表達(dá)式模塊。寫作本書時, GHC 自帶一個實現(xiàn), Text.Regex.Posix 。正如其名字,這個模塊提供了 POSIX 語義的正則表達(dá)式實現(xiàn)。

Note

Perl 風(fēng)格和 POSIX 風(fēng)格的正則表達(dá)式

如果你此前用過其他語言,如 Perl,Python,或 Java,并且使用過其中的正則表達(dá)式, 你應(yīng)該知道 Text.Regex.Posix 模塊處理的 POSIX 風(fēng)格的正則表達(dá)式與 Perl 風(fēng)格的正則表達(dá)式有一些顯著的不同。

當(dāng)有多個匹配結(jié)果候選時,Perl 的正則表達(dá)式引擎表現(xiàn)為左側(cè)最小匹配,而 POSIX 引擎會選擇貪婪匹配(最長匹配)。當(dāng)使用正則表達(dá)式 (foo|fo*) 匹配字符串 foooooo 時,Perl 風(fēng)格引擎將返回 foo (最左的匹配),而 POSIX 引擎將返回的結(jié)果將包含整個字符串 (貪婪匹配)。

POSIX 正則表達(dá)式比 Perl 風(fēng)格的正則表達(dá)式缺少一些格式語法。它們也缺少一些 Perl 風(fēng)格正則表達(dá)式的功能,比如零寬度斷言和對貪婪匹配的控制。

Hackage 上也有其他 Haskell 正則表達(dá)式包可供下載。其中一些比內(nèi)置的 POSIX 引擎擁有更好的執(zhí)行效率 (如 regex-tdfa); 另外一些提供了大多數(shù)程序員熟悉的 Perl 風(fēng)格正則匹配 (如 regex-pcre)。它們都按照我們這節(jié)提到的 API 編寫。

將 glob 模式翻譯為正則表達(dá)式

我們已經(jīng)看到了用正則表達(dá)式匹配文本的多種方法,現(xiàn)在讓我們將注意力回到 glob 模式。我們要編寫一個函數(shù),接收一個 glob 模式作為輸入,返回其對應(yīng)的正則表達(dá)式。glob 模式和正則表達(dá)式都以文本字符串表示,所以這個函數(shù)的類型應(yīng)該已經(jīng)清楚了。

-- file: ch08/GlobRegex.hs
module GlobRegex
    (
      globToRegex
    , matchesGlob
    ) where

import Text.Regex.Posix ((=~))

globToRegex :: String -> String

我們生成的正則表達(dá)式必須被錨定,所以它要對一個字符串從頭到尾完整匹配。

-- file: ch08/GlobRegex.hs
globToRegex cs = '^' : globToRegex' cs ++ "$"

回想一下, String 僅是 [Char] 的同義詞,一個由字符組成的數(shù)組。 : 操作符將一個值加入某個列表頭部,此處是將字符 ^ 加入 globToRegex' 函數(shù)返回的列表頭部。

Note

在定義之前使用一個值

Haskell 在使用某個值或函數(shù)時,并不需要其在之前的源碼中被聲明。在某個值首次被使用之后才定義它是很平常的。Haskell 編譯器并不關(guān)心這個層面上的順序。這使我們可以用最符合邏輯的方式靈活地組織代碼,而不是為使編譯器作者更輕松而遵守某種順序。

Haskell 模塊的作者們經(jīng)常利用這種靈活性,將“更重要的”代碼放在源碼文件更靠前的位置,將繁瑣的實現(xiàn)放在后面。這也是我們實現(xiàn) globToRegex' 函數(shù)及其輔助函數(shù)的方法。

globToRegex' 將使用正則表達(dá)式做大部分的翻譯工作。我們將使用 Haskell 的模式匹配特性輕松地窮舉出需要處理的每一種情況

-- file: ch08/GlobRegex.hs

globToRegex' :: String -> String
globToRegex' "" = ""

globToRegex' ('*':cs) = ".*" ++ globToRegex' cs

globToRegex' ('?':cs) = '.' : globToRegex' cs

globToRegex' ('[':'!':c:cs) = "[^" ++ c : charClass cs
globToRegex' ('[':c:cs)     = '['  :  c : charClass cs
globToRegex' ('[':_)        = error "unterminated character class"

globToRegex' (c:cs) = escape c ++ globToRegex' cs

我們的第一條規(guī)則是,如果觸及 glob 模式的尾部(也就是說當(dāng)輸入為空字符串時),我們返回 $ ,正則表達(dá)式中表示“匹配行尾”的符號。我們按照這樣一系列規(guī)則將模式串由 glob 語法轉(zhuǎn)化為正則表達(dá)式語法。最后一條規(guī)則匹配所有字符,首先將可轉(zhuǎn)義字符進(jìn)行轉(zhuǎn)義。

escape 函數(shù)確保正則表達(dá)式引擎不會將普通字符串解釋為構(gòu)成正則表達(dá)式語法的字符。

-- file: ch08/GlobRegex.hs
escape :: Char -> String
escape c | c `elem` regexChars = '\\' : [c]
         | otherwise = [c]
    where regexChars = "\\+()^$.{}]|"

charClass 輔助函數(shù)僅檢查一個字符類是否正確地結(jié)束。這個并不改變其輸入,直到遇到一個 ] 字符,其將控制流交還給 globToRegex'

-- file: ch08/GlobRegex.hs
charClass :: String -> String
charClass (']':cs) = ']' : globToRegex' cs
charClass (c:cs)   = c : charClass cs
charClass []       = error "unterminated character class"

現(xiàn)在我們已經(jīng)完成了 globToRegex 函數(shù)及其輔助函數(shù)的定義,讓我們在 ghci 中裝載并且實驗一下。

ghci> :load GlobRegex.hs
[1 of 1] Compiling GlobRegex        ( GlobRegex.hs, interpreted )
Ok, modules loaded: GlobRegex.
ghci> :module +Text.Regex.Posix
ghci> globToRegex "f??.c"
Loading package array-0.1.0.0 ... linking ... done.
Loading package containers-0.1.0.1 ... linking ... done.
Loading package bytestring-0.9.0.1 ... linking ... done.
Loading package mtl-1.1.0.0 ... linking ... done.
Loading package regex-base-0.93.1 ... linking ... done.
Loading package regex-posix-0.93.1 ... linking ... done.
"^f..\\.c$"

果然,看上去像是一個合理的正則表達(dá)式??梢允褂盟齺砥ヅ淠硞€字符串碼?

ghci> "foo.c" =~ globToRegex "f??.c" :: Bool
True
ghci> "test.c" =~ globToRegex "t[ea]s*" :: Bool
True
ghci> "taste.txt" =~ globToRegex "t[ea]s*" :: Bool
True

奏效了!現(xiàn)在讓我們在 ghci 里玩耍一下。我們可以臨時定義一個 fnmatch 函數(shù),并且試用它。

ghci> let fnmatch pat name  =  name =~ globToRegex pat :: Bool
ghci> :type fnmatch
fnmatch :: (RegexLike Regex source1) => String -> source1 -> Bool
ghci> fnmatch "d*" "myname"
False

但是 fnmatch 沒有真正的 “Haskell 味道”。目前為止,最常見的 Haskell 風(fēng)格是賦予函數(shù)具有描述性的,“駝峰式” 命名。將單詞連接為駝峰狀,首字母小寫后面每個單詞的首字母大寫。例如,“file name matches” 這幾個詞將轉(zhuǎn)換為 fileNameMatch 這個名字。 “駝峰式” 這種說法來自與大寫字母形成的“駝峰”。在我們的庫中,將使用 matchesGlob 這個函數(shù)名。

-- file: ch08/GlobRegex.hs
matchesGlob :: FilePath -> String -> Bool
name `matchesGlob` pat = name =~ globToRegex pat

你可能注意到目前為止我們使用的都是短變量名。從經(jīng)驗來看,描述性的名字在更長的函數(shù)定義中更有用,它們有助于可讀性。對一個僅有兩行的函數(shù)來說,長變量名價值較小。

練習(xí)

  1. 使用 ghci 探索當(dāng)你向 globToRegex 傳入一個畸形的模式時會發(fā)生什么,如 “[” 。編寫一個小函數(shù)調(diào)用 globToRegex ,向其傳入一個畸形的模式。發(fā)生了什么?
  2. Unix 的文件系統(tǒng)的文件名通常是對大小寫敏感的(如:”G” 和 “g” 不同),Windows 文件系統(tǒng)則不是。為 globToRegex 和 matchesGlob 函數(shù)添加一個參數(shù),以控制它們是否大小寫敏感。

重要的題外話:編寫惰性函數(shù)

在命令式語言中, globToRegex 通常是個被我們寫成循環(huán)的函數(shù)。舉個例子,Python 標(biāo)準(zhǔn)庫中的 fnmatch 模塊包括了一個名叫 translate 的函數(shù)與我們的 globToRegex 函數(shù)做了完全相同的工作。它就被寫成一個循環(huán)。

如果你了解過函數(shù)式編程語言比如 Scheme 或 ML ,可能有個概念已經(jīng)深入你的腦海,“模擬一個循環(huán)的方法是使用尾遞歸”。

觀察 globToRegex' ,可以發(fā)現(xiàn)其不是一個尾遞歸函數(shù)。至于原因,重新檢查一下它的最后一組規(guī)則(它的其他規(guī)則也類似)。

-- file: ch08/GlobRegex.hs
globToRegex' (c:cs) = escape c ++ globToRegex' cs

其遞歸地執(zhí)行自身,并以遞歸執(zhí)行的結(jié)果作為 (++) 函數(shù)的參數(shù)。因為遞歸執(zhí)行并不是這個函數(shù)的最后一個操作,所以 globToRegex' 不是尾遞歸函數(shù)。

為何我們的函數(shù)沒有定義成尾遞歸的?答案是 Haskell 的非嚴(yán)格求值策略。在我們開始討論它之前,先快速的了解一下為什么,傳統(tǒng)編程語言中,這類遞歸定義是我們要避免的。這里有一個簡化的 (++) 操作符定義。它是遞歸的,但不是尾遞歸的。

-- file: ch08/append.hs
(++) :: [a] -> [a] -> [a]

(x:xs) ++ ys = x : (xs ++ ys)
[]     ++ ys = ys

在嚴(yán)格求值語言中,如果我們執(zhí)行 “foo” ++ “bar”,將馬上構(gòu)建并返回整個列表。非嚴(yán)格求值將這項工作延后很久執(zhí)行,知道其結(jié)果在某處被用到。

如果我們需要 “foo” ++ “bar” 這個表達(dá)式結(jié)果中的一個元素,函數(shù)定義中的第一個模式被匹配,返回表達(dá)式 x : (xs ++ ys)。因為 (:) 構(gòu)造器是非嚴(yán)格的,xs ++ ys 的求值被延遲到當(dāng)我們需要生成更多結(jié)果中的元素時。當(dāng)生成了結(jié)果中的更多元素,我們不再需要 x ,垃圾收集器可以將其回收。因為我們按需要計算結(jié)果中的元素,且不保留已經(jīng)計算出的結(jié)果,編譯器可以用常數(shù)空間對我們的代碼求值。

利用我們的模式匹配器

有一個函數(shù)可以匹配 glob 模式很好,但我們希望可以在實際中使用它。在類 Unix 系統(tǒng)中,glob 函數(shù)返回一個由匹配給定 glob 模式串的文件和目錄組成的列表。讓我們用 Haskell 構(gòu)造一個類似的函數(shù)。按 Haskell 的描述性命名規(guī)范,我們將這個函數(shù)稱為 namesMatching 。

-- file: ch08/Glob.hs
module Glob (namesMatching) where

我們將 namesMatching 指定為我們的 Glob 模塊中唯一對用戶可見的名字。

-- file: ch08/Glob.hs
import System.Directory (doesDirectoryExist, doesFileExist,
                      getCurrentDirectory, getDirectoryContents)

System.FilePath 抽象了操作系統(tǒng)路徑名稱的慣例。(>) 函數(shù)將兩個部分組合為一個路徑。

ghci> :m +System.FilePath
ghci> "foo" </> "bar"
Loading package filepath-1.1.0.0 ... linking ... done.
"foo/bar"

The name of the dropTrailingPathSeparator function is perfectly descriptive. No commentsdropTrailingPathSeparator 函數(shù)的名字完美地描述了其作用。

ghci> dropTrailingPathSeparator "foo/"
"foo"

splitFileName 函數(shù)以路徑中的最后一個斜線將路徑分割為兩部分。

ghci> splitFileName "foo/bar/Quux.hs"
("foo/bar/","Quux.hs")
ghci> splitFileName "zippity"
("","zippity")

配合 Systems.FilePath 和 Systems.Directory 兩個模塊,我們可以編寫一個在類 Unix 和 Windows 系統(tǒng)上都可以運(yùn)行的可移植的 namesMatching 函數(shù)。

-- file: ch08/Glob.hs
import System.FilePath (dropTrailingPathSeparator, splitFileName, (</>))

在這個模塊中,我們將模擬一個 “for” 循環(huán);首次嘗試在 Haskell 中處理異常;當(dāng)然還會用到我們剛寫的 matchesGlob 函數(shù)。

-- file: ch08/Glob.hs
import Control.Exception (handle, SomeException)
import Control.Monad (forM)
import GlobRegex (matchesGlob)

目錄和文件存在于各種帶有副作用的活動的“真實世界”,我們的 glob 模式處理函數(shù)的返回值類型中將必須帶有 IO 。

如果的輸入字符串中不包含模式字符,我們簡單的在文件系統(tǒng)中檢查輸入的名字是否已經(jīng)建立。(注意,此處使用 Haskell 的 guard 語法可以編寫精細(xì)整齊的定義?!癷f” 語句也可以做到,但是在美學(xué)上不能令人滿意。 )

-- file: ch08/Glob.hs
isPattern :: String -> Bool
isPattern = any (`elem` "[?*")

namesMatching pat
  | not (isPattern pat) = do
    exists <- doesNameExist pat
    return (if exists then [pat] else [])

doesNameExist 是一個我們將要簡要定義的函數(shù)的名字。

如果字符串是一個 glob 模式呢?繼續(xù)定義我們的函數(shù)。

-- file: ch08/Glob.hs
  | otherwise = do
    case splitFileName pat of
      ("", baseName) -> do
          curDir <- getCurrentDirectory
          listMatches curDir baseName
      (dirName, baseName) -> do
          dirs <- if isPattern dirName
                  then namesMatching (dropTrailingPathSeparator dirName)
                  else return [dirName]
          let listDir = if isPattern baseName
                        then listMatches
                        else listPlain
          pathNames <- forM dirs $ \dir -> do
                           baseNames <- listDir dir baseName
                           return (map (dir </>) baseNames)
          return (concat pathNames)

我們使用 splitFileName 將字符串分割為目錄名和文件名。如果第一個元素為空,說明我們正在當(dāng)前目錄尋找符合模式的文件。否則,我們必須檢查目錄名,觀察其是否包含模式。若不含模式,我們建立一個只由目錄名一個元素組成的列表。如果含有模式,我們列出所有匹配的目錄。

Note

注意事項

System.FilePath 模塊稍有點詭異。上面的情況就是一個例子。 splitFileName 函數(shù)在其返回值的目錄名部分的結(jié)尾保留了一個斜線。

ghci> :module +System.FilePath
ghci> splitFileName "foo/bar"
Loading package filepath-1.1.0.0 ... linking ... done.
("foo/","bar")

If we didn't remember (or know enough) to remove that slash, we'd recurse endlessly in namesMatching, because of the following behaviour of splitFileName. 1 comment 如果忘記(或不夠了解)要去掉這個斜線,我們將在 namesMatching 函數(shù)中進(jìn)行無止盡的遞歸匹配,看看后面演示的 splitFileName 的行為你就會明白。

ghci> splitFileName "foo/"
("foo/","")

你或許能夠想想想象是什么促使我們加入這份注意事項。

最終,我們將每個目錄中的匹配收集起來,得到一個由列表組成的列表,然后將它們連接為一個單獨的由文件名組成的列表。

上面那個函數(shù)中出現(xiàn)的陌生的 forM 函數(shù),其行為有些像 “for” 循環(huán):它將其第二個參數(shù)(一個動作)映射到其第一個參數(shù)(一個列表),并返回由其結(jié)果組成的列表。

我們還剩余一些零散的目標(biāo)需要完成。首先是上面用到過的 doesNameExist 函數(shù)的定義。 System.Directory 函數(shù)無法檢查一個名字是否已經(jīng)在文件系統(tǒng)中建立。它強(qiáng)制我們明確要檢查的是一個文件還是目錄。這個 API 設(shè)計的很丑陋,所以我們必須在一個函數(shù)中完成兩次檢驗。出于效率考慮,我們首先檢查文件名,因為文件比目錄更常見。

-- file: ch08/Glob.hs
doesNameExist :: FilePath -> IO Bool

doesNameExist name = do
    fileExists <- doesFileExist name
    if fileExists
      then return True
      else doesDirectoryExist name

還有兩個函數(shù)需要定義,返回值都是由某個目錄下的名字組成的列表。 listMatches 函數(shù)返回由某目錄下全部匹配給定 glob 模式的文件名組成的列表。

-- file: ch08/Glob.hs
listMatches :: FilePath -> String -> IO [String]
listMatches dirName pat = do
    dirName' <- if null dirName
                then getCurrentDirectory
                else return dirName
    handle (const (return [])::(SomeException->IO [String]))
           $ do names <- getDirectoryContents dirName'
                let names' = if isHidden pat
                             then filter isHidden names
                             else filter (not . isHidden) names
                return (filter (`matchesGlob` pat) names')

isHidden ('.':_) = True
isHidden _       = False

listPlain 接收的函數(shù)名若存在,則返回由這個文件名組成的單元素列表,否則返回空列表。

-- file: ch08/Glob.hs
listPlain :: FilePath -> String -> IO [String]
listPlain dirName baseName = do
    exists <- if null baseName
              then doesDirectoryExist dirName
              else doesNameExist (dirName </> baseName)
    return (if exists then [baseName] else [])

仔細(xì)觀察 listMatches 函數(shù)的定義,將發(fā)現(xiàn)一個名為 handle 的函數(shù)。之前,我們從 Control.Exception 模塊中將其載入。正如其暗示的那樣,這個函數(shù)讓我們初次體驗了 Haskell 中的異常處理。把它扔進(jìn) ghci 中看我們會發(fā)現(xiàn)什么。

ghci> :module +Control.Exception
ghci> :type handle
handle :: (Exception -> IO a) -> IO a -> IO a

可以看出 handle 接受兩個參數(shù)。首先是一個函數(shù),其接受一個異常值,且有副作用(其返回值類型帶有 IO 標(biāo)簽);這是一個異常處理器。第二個參數(shù)是可能會拋出異常的代碼。 關(guān)于異常處理器,異常處理器的類型限制其必須返回與拋出異常的代碼相同的類型。所以它只能選擇或是拋出一個異常,或像在我們的例子中返回一個由字符串組成的列表。 const 函數(shù)接受兩個參數(shù);無論第二個參數(shù)是什么,其始終返回第一個參數(shù)。

ghci> :type const
const :: a -> b -> a
ghci> :type return []
return [] :: (Monad m) => m [a]
ghci> :type handle (const (return []))
handle (const (return [])) :: IO [a] -> IO [a]

我們使用 const 編寫異常處理器忽略任何向其傳入的異常。取而代之,當(dāng)我們捕獲異常時,返回一個空列表。

本章不會再展開任何異常處理相關(guān)的話題。然而還有更多可說,我們將在第 19 章異常處理時重新探討這個主題。

練習(xí)

  1. 盡管我們已經(jīng)編寫了一個可移植 namesMatching 函數(shù),這個函數(shù)使用了我們的大小寫敏感的 globToRegex 函數(shù)。嘗試在不改變其類型簽名的前提下,使 namesMatching 在 Unix 下大小寫敏感,在 Windows 下大小寫不敏感。

提示:查閱一下 System.FilePath 的文檔,其中有一個變量可以告訴我們程序是運(yùn)行在類 Unix 系統(tǒng)上還是在 Windows 系統(tǒng)上。

  1. 如果你在使用類 Unix 系統(tǒng),查閱 System.Posix.Files 模塊的文檔,看是否能找到一個 doesNameExist 的替代品。
    • 通配符,僅匹配一個單獨目錄中的名字。很多 shell 可以提供擴(kuò)展通配符語法, ,其將在所有目錄中進(jìn)行遞歸匹配。舉個例子,.c 意為 “在當(dāng)前目錄及其任意深度的子目錄下匹配一個 .c 結(jié)尾的文件名”。實現(xiàn) ** 通配符匹配。

通過 API 設(shè)計進(jìn)行錯誤處理

向 globToRegex 傳入一個畸形的正則表達(dá)式未必會是一場災(zāi)難。用戶的表達(dá)式可能會有輸入錯誤,這時我們更希望得到有意義的報錯信息。

當(dāng)這類問題出現(xiàn)時,調(diào)用 error 函數(shù)會有很激烈的反應(yīng)(其結(jié)果在 Q: 1 這個練習(xí)中探索過。)。 error 函數(shù)會拋出一個異常。純函數(shù)式的 Haskell 代碼無法處理異常,所以控制流會突破我們的純函數(shù)代碼直接交給處于距離最近一層 IO 中并且安裝有合適的異常處理器的調(diào)用者。如果沒有安裝異常處理器, Haskell 運(yùn)行時的默認(rèn)動作是終結(jié)我們的程序(如果是在 ghci 中,則會打出一條令人不快的錯誤信息。)

所以,調(diào)用 error 有點像是拉下了戰(zhàn)斗機(jī)的座椅彈射手柄。我們從一個無法優(yōu)雅處理的災(zāi)難性場景中逃離,而等我們著地時會撒出很多燃燒著的殘骸。

我們已經(jīng)確定了 error 是為災(zāi)難情場景準(zhǔn)備的,但我們?nèi)耘f在 globToRegex 中使用它?;蔚妮斎雽⒈痪芙^,但不會導(dǎo)致大問題。處理這種情況有更好的方式嗎?

Haskell 的類型系統(tǒng)和庫來救你了!我們可以使用內(nèi)置的 Either 類型,在 globToRegex 函數(shù)的類型簽名中描述失敗的可能性。

-- file: ch08/GlobRegexEither.hs
type GlobError = String

globToRegex :: String -> Either GlobError String

globToRegex 的返回值將為兩種情況之一,或者為 Left"出錯信息" 或者為 Right"一個合法正則表達(dá)式" 。這種返回值類型,強(qiáng)制我們的調(diào)用者處理可能出現(xiàn)的錯誤。(你會發(fā)現(xiàn)這是 Haskell 代碼中 Either 類型最廣泛的用途。)

練習(xí)

  1. 編寫一個使用上面那種類型簽名的 globToRegex 版本。
  2. 改變 namesMatching 的類型簽名,使其可以處理畸形的正則表達(dá)式,并使用它重寫 globToRegex 函數(shù)。

Tip

你會發(fā)現(xiàn)牽扯到的工作量大得驚人。別怕,我們將在后面的章節(jié)介紹更多簡單老練的處理錯誤的方式。

讓我們的代碼工作

namesMatching 函數(shù)本身并不是很令人興奮,但它是一個很有用的構(gòu)建模塊。將它與稍多點的函數(shù)組合在一起,就會讓我們做出有趣的東西。

這里有個例子。定義一個 renameWith 函數(shù),并不簡單的重命名一個文件,取而代之,對文件名執(zhí)行一個函數(shù),并將返回值作為新的文件名。

-- file: ch08/Useful.hs
import System.FilePath (replaceExtension)
import System.Directory (doesFileExist, renameDirectory, renameFile)
import Glob (namesMatching)

renameWith :: (FilePath -> FilePath)
           -> FilePath
           -> IO FilePath

renameWith f path = do
    let path' = f path
    rename path path'
    return path'

我們再一次通過一個輔助函數(shù)使用 System.Directory 中難看的文件/目錄函數(shù)

-- file: ch08/Useful.hs
rename :: FilePath -> FilePath -> IO ()

rename old new = do
    isFile <- doesFileExist old
    let f = if isFile then renameFile else renameDirectory
    f old new

System.FilePath 模塊提供了很多有用的函數(shù)用于操作文件名。這些函數(shù)洽好漏過了我們的 renameWith 和 namesMatching 函數(shù),所以我們可以通過將他們組合起來的方式來快速的創(chuàng)建新函數(shù)。例如,這個簡潔的函數(shù)修改了 C++ 源碼文件的后綴名。

-- file: ch08/Useful.hs
cc2cpp =
  mapM (renameWith (flip replaceExtension ".cpp")) =<< namesMatching "*.cc"

cc2cpp 函數(shù)使用了幾個我們已經(jīng)見過多次的函數(shù)。 flip 函數(shù)接受另一個函數(shù)作為參數(shù),交換其參數(shù)的順序(可以在 ghci 中調(diào)查 replaceExtension 的類型以了解詳情)。 =<< 函數(shù)將其右側(cè)動作的結(jié)果喂給其左側(cè)的動作。

練習(xí)

  1. Glob 模式解釋起來很簡單,用 Haskell 可以很容易的直接寫出其匹配器,正則表達(dá)式則不然。試一下編寫正則匹配。
以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號