Real World Haskell 第八章 高效文件处理,正则表达式,文件名匹配

高效文件处理

这是一个简单的文件读取测试,读取一个全是数字的文本文件,输出它们的和。

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

虽然String类型是读写文件时默认使用的,但是它的效率较低,因此这个简单的程序执行效率会很糟糕。

String 用 Char值的列表表示; 列表上每个元素都是独立分配的,并且还有一些簿记的开销。要读写文本和二进制文件的程序,其内存占用和性能会受到这些因素影响。像这样简单的测试,在量比较大时,即使像Python这样的解释型语言的性能也比Haskell的好。

bytestring 库提供了对String类型快速廉价的替代。用bytestring 写的代码在性能和内存占用上经常可以媲美甚至超越C,同时保持Haskell的表达能力和简洁。

这个库提供了两个模块。每个模块中定义的函数正好替换掉与之相对的String上的函数。

 * Data.ByteString 模块定义了一个严格类型 ByteString。 它将二进制或文本的字符串保存在单独一个数组中。

 * Data.ByteString.Lazy 模块提供了惰性类型,也叫 ByteString。它将数据字符串表示成一系列块,每个最大64KB的数组。

两种ByteString类型分别在特定的场景下性能更好。对于大量的流式数据(几百M到上T),惰性 ByteString 类型通常是最好的。块的大小调整到适合现代CPU的L1 缓存的大小,垃圾回收器也可以快速的把不再需要的流数据清除掉。

严格ByteString 类型在应用程序不需要关心内存占用时性能最好,或者当需要进行随机数据访问时应用。

二进制 I/O 和qualified import

我们来开发些小的函数来演示ByteString 的一些API。我们将确定一个文件是不是ELF目标文件:这是几乎所有现代的Unix类操作系统上使用的可执行文件格式。

只需要简单的查看文件开头的四个字节,看看它们是否匹配一个特定的字节序列。标识出文件类型的序列通常称为魔术字。

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

上面代码中的import qualified 使用了 Haskell 的qualified import语法导入 ByteString 模块。这可以让我们用自己选择的名字来引用一个模块。

例如,当我们要指向 lazy ByteString 模块的take函数时,必须写成 L.take ,因为我们把这个模块用L的名字导入。如果我们不明确支出要的是那个版本的take的话,编译器将报告错误。

对ByteStirng模块我们总是用qualified import语法,因为它们提供的很多函数都与Prelude模块中重名。

提示
Qualified import 可以方便的切换 ByteString 的类型。只需要修改源文件头上的import声明;剩下的代码很可能不需要修改。这样可以方便的测试两种类型的 ByteString,来看看哪种更适合你的应用的需要。

不管用不用 qualified import,都可以用模块的全名来无歧义进行指定。例如, Data.ByteString.Lazy.length 和 L.length都指的是相同的函数,就像 Prelude.sum 和 sum。

惰性和严格ByteString模块适合用在二进制I/O中。Haskell中表示字节的数据类型是 Word8;如果需要引用它的名字,需要从 Data.Word 模块中导入它。

L.pack 函数取一个 Word8 值的列表,并把他们打包成一个惰性 ByteString。(L.unpack函数执行相反的操作)hasElfMagic函数简单的把文件的前4个字节与魔术字进行比较。

我们用经典的Haskell风格写的程序,hasElfMagic 函数不进行I/O操作。这里是将它用在文件上的函数。

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

 L.readFile 函数是readFile函数在惰性ByteString中的等价物。它对文件惰性的按需读取。它在同时读取最多64KB的块时效率也很高。惰性 ByteString对我们的任务是个好的选择:因为我们只需要读取文件头最多4个字节,我们可以安全的把这个函数用在任意大小的文件上。

文本 I/O

为了方便,bytestring 库提供了另外两个带有有限文本I/O能力的模块, Data.ByteString.Char8 和 Data.ByteString.Lazy.Char8。它们将单独字符串元素用Char而非Word8暴露。

警告
这些模块中的函数只处理字节大小的Char值,所以只适合于ASCII和一些欧洲字符集。超过255的值将被截断。

面向字符的bytestring模块提供了文本处理的有用工具。这里的文件包含了一个著名互联网公司2008年中每月股票价格。

ghci> putStr =<< readFile "prices.csv"
Date,Open,High,Low,Close,Volume,Adj Close
2008-08-01,20.09,20.12,19.53,19.80,19777000,19.80
2008-06-30,21.12,21.20,20.60,20.66,17173500,20.66
2008-05-30,27.07,27.10,26.63,26.76,17754100,26.76
2008-04-30,27.17,27.78,26.76,27.41,30597400,27.41

如何从这样一系列条目中找到最高的收盘价?收盘价是逗号分隔的第四列。这个函数从一行数据中取出收盘价。

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

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

因为这个函数用  (??)风格书写,因此我们从右到左读。 L.split 函数将惰性ByteString函数用匹配字符分割成列表。(!!)操作符获得列表中某项元素。 readPrice 函数将字符串表示的小数价格转换成实际的数字。

-- 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 函数,它解析一个整数。它返回整数和字符串剩下的部分。我们的定义有些复杂,因为L.readInt 在解析失败时需要返回Nothing。

我们寻找最高收盘价的函数很直截了当。

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

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

我们用了一个技巧来避开maximum函数不能应用在空列表上的限制。

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

由于我们不希望对空的股票数据抛出异常,因此用 (Nothing:)表达式来保证maximum 用的 Maybe Int 值永远不为空。

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

我们的函数可以工作么?

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从逻辑中分离开了,所以可以不用创建空文件就能测试没有数据的情况。

ghci> highestClose L.empty
Nothing

文件名匹配

很需哦面向系统的编程语言都提供库函数,来让我们将文件名与模式进行匹配,或者给出匹配模式的文件列表。其他语言里,这个函数经常称为 fnmatch。虽然Haskell的标准库里提供了很好的系统编程工具,但是并没有提供这类模式匹配函数。我们借此机会开发自己的。

我们要处理的模式种类有glob模式,通配符模式,或称shell风格模式。它们有一些简单的规则。你可能已经知道了,这里我们快速的回顾一下。

    * 从一个字符串的开头到结尾匹配一个模式

    * 大部分文本字节匹配本身。例如,模式中的文本foo 将只匹配到输入字符串里的foo。

    * *(星号)字符表示“任意匹配”;它将匹配任何的文本,包括空字符串。例如, foo* 模式将会匹配任何以 foo 开头的字符串,像 foo本身, foobar, 或者 foo.c 等。quux*.c 模式将匹配任何以 quux 开头,以 .c 结尾的字符串,如 quuxbaz.c 。

    * ? (问号)字符匹配任意一个字符。pic??.jpg 模式将匹配如 picaa.jpg 或 pic01.jpg 这样的名字。

    * [ (左方括号) 字符开始一个字符类型,以 ] 结束。它的意思是“匹配这种类型中任意一个字符”。字符类型可以在 [ 后跟一个 ! 来取反,它的意思是“匹配任何不在这个类型中的字符”。

     一个字符后跟 - (中划线),再跟另一个字符,表示一个范围:“匹配这个集合内的任意字符”。

      字符类型有一个巧妙的地方是它们不能为空。在[ 或者 [! 后的第一个字符是属于类型的一部分,因为我们可以写一个包含 ] 字符的类型 []aeiou]。  pic[0-9].[pP][nN][gG] 模式匹配的字符串是由 pic 字符串,跟单独一个数字,再跟任意大小写的 .png 字符串组成的。

虽然Haskell的标准库里并没有提供块模式的匹配,但它提供了一个很好的正则表达式匹配库。glob模式只是裁剪过的正则表达式,只有些微语法变化。glob模式很容易转换成正则表达式,不过要先理解如何在Haskell中使用正则表达式。

Haskell中的正则表达式

在这一节,我们假设你已经熟悉其他一些语言如Python, Perl 或者 Java 中的正则表达式。

为了简短,从现在开始把“正则表达式”简写为 regexp(译注:或简称正则)。

我们主要关注Haskell中处理正则与其他语言中的不同点。Haskell的正则匹配库比其他的语言更具表达能力,因此有很多要讨论的内容。

要开始探索正则库,只需要用 Text.Regex.Posix 模块。和往常摸索这个模块最方便的方式是用 ghci 交互模式。

ghci> :module +Text.Regex.Posix

要进行一半的正则匹配只需要一个函数,中缀表达式 (=~) (从Perl中借鉴的)。要跨过的第一个障碍是Haskell的正则库大量使用多态。结果是, (=~) 操作符的类型签名很难理解,因此我们不在这里解释它了。

=~ 操作符的参数和返回结果的类型都用类型类。第一个参数(在 =~ 左侧)是要匹配的文本;第二个参数(在右边)是要匹配的正则表达式。两者都可以用String 或者 ByteString。

结果的多种类型

=~ 操作符的返回值是多态的,因此Haskell编译器需要一些方法知道我们想要什么类型的结果。在实际的代码中,它也许可以通过我们后面使用结果的方式来推断它的正确类型。但是在用ghci摸索的时候经常缺少这些提示。如果我们没有指定结果的类型,解释器将会返回错误,因为它没有足够的信息来推断结果的类型。

当ghci无法推断目标类型时,我们告诉它我们想要的类型是什么。如果想要一个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

在正则库内部,有一个名为 RegexContext 的类型类用来描述目标类型的行为;基础库里定义类这个类型的很多实例。Bool类型是这个类型类的实例,因此我们得到了有用的结果。另一个这种实例是 Int,它给出正则匹配成功的次数。

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

如果要 String 类型结果,将会获得第一个匹配上的子串,如果没有匹配上则返回空的字符串。

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

另一个可用的类型结果是 [String],它返回所有匹配的字符串的列表。

ghci> "I, B. Ionsonii, uurit a lift'd batch" =~ "(uu|ii)" :: [String]

<interactive>:1:0:
    No instance for (RegexContext Regex [Char] [String])
      arising from a use of `=~' at <interactive>:1:0-50
    Possible fix:
      add an instance declaration for
      (RegexContext Regex [Char] [String])
    In the expression:
            "I, B. Ionsonii, uurit a lift'd batch" =~ "(uu|ii)" :: [String]
    In the definition of `it':
        it = "I, B. Ionsonii, uurit a lift'd batch" =~ "(uu|ii)" ::
             [String]
ghci> "hi ludi, F. Baconis nati, tuiti orbi" =~ "Shakespeare" :: [String]

<interactive>:1:0:
    No instance for (RegexContext Regex [Char] [String])
      arising from a use of `=~' at <interactive>:1:0-54
    Possible fix:
      add an instance declaration for
      (RegexContext Regex [Char] [String])
    In the expression:
            "hi ludi, F. Baconis nati, tuiti orbi" =~ "Shakespeare" :: [String]
    In the definition of `it':
        it = "hi ludi, F. Baconis nati, tuiti orbi" =~ "Shakespeare" ::
             [String]


[Note]    小心String结果

如果想要String类型的结果,要当心。因为 (=~) 返回空字符串来表示 “无匹配”,显然如果空字符串也可是合法的正则匹配的话,这将会造成困难。如果遇到这种情况,应该用一个不同的返回类型代替,如 [String]。

这些是“简单”的结果类型,不过我们不会就此结束。在继续前,用一个单独的模式来用在剩下的例子中。可以在ghci里把这个模式定义成变量,节省点输入。

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

当匹配发生时,我们可以获取很多信息。如果请求 (String, String, String) 类型的元组,将会获得第一次匹配前的文本,匹配的文本,和剩下的文本。

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

如果匹配失败,整个文本将做为元组中 “匹配之前”的部分,其他两个元素为空。

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

请求一个四元组将会得到第四个元素,它是所有匹配上的文本列表。

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

也可以获得关于匹配的数字信息。一对 Int 给出第一次匹配的子串的偏移量和长度。如果要一个这种数对的列表,将会获得所有匹配的信息。

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


如果匹配失败的话,在请求单独一个元组时返回的元组第一个元素值为 -1 (匹配偏移量),请求元组列表的话则返回空列表。

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


这不是RegexContext 类型类全部的内置实例。Text.Regex.Base.Context 模块的文档中有完整的列表。

这种函数返回结果类型多态的能力,在静态类性语言中很不寻常。

正则表达式进阶

混合和匹配字符串类型

之前已经注意到, =~ 操作符的参数类型和返回值类型都是类型类。可以用 String 或者严格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> pack "foo" =~ pack "o" :: [(Int, Int)]

不过,要注意如果想要一个字符串结果的话,要匹配的文本的字符串类型要相同。看下在实际中的含义。

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

在上面的例子里,用 pack 把 String转换成 ByteString。因为结果类型中是 ByteString,因此类型检查通过。不过如果用 String 来尝试的话,就不行了。

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

<interactive>:1:0:
    No instance for (RegexContext Regex [Char] [ByteString])
      arising from a use of `=~' at <interactive>:1:0-20
    Possible fix:
      add an instance declaration for
      (RegexContext Regex [Char] [ByteString])
    In the expression: "good food" =~ ".ood" :: [ByteString]
    In the definition of `it':
        it = "good food" =~ ".ood" :: [ByteString]

可以简单的修复这个问题,让左边字符串的类型和结果类型重新匹配就可以了。

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

这个限制并不适用于要匹配的正则表达式。它用String 还是 ByteString 都是没限制的。

其他需要知道的事情

在查看Haskell的库文档时,会看到几个与正则相关的模块。 Text.Regex.Base 下的模块定义了其他正则模块用的公共API。同时可能安装有多个不同的正则API实现。在本书写作时,GHC自带了一个实现 Text.Regex.Posix。如其名称所暗示的,这个包提供了 POSIX正则语义。


[Note]    Perl 和 POSIX 正则表达式

如果你从Perl,Python或者Java这些语言转到Haskell语言,并且用过这些语言中的正则表达式的话,应该注意在 Text.Regex.Posix中处理的POSIX正则与Perl风格的正则有很多重要的区别。这里是一些显著的不同点。

Perl 的正则引擎匹配时采用左侧优先,而POSIX引擎则是贪婪匹配。这意味着,对于一个正则表达式 (foo|fo*) 和一个字符串 foooooo,Perl风格的引擎将匹配到 foo (最左侧匹配),而POSIX引擎将匹配整个字符串(贪婪匹配)。

POSIX正则的语法相对于Perl风格的更少更统一。同时也缺少一些Perl风格正则提供的功能,如零宽度断言和对贪婪匹配的控制。

Hackage上还有其他的正则程序包可供下载。有些比当前的POSIX引擎提供了更好的性能(如 regex-tdfa);还有的提供了大多数程序员们都熟悉的Perl风格匹配(如 regex-pcre)。他们都支持本节讲到的标准API。

将 glob 模式转换成正则表达式

已经看到许多用正则表达式匹配文本的方式了,现在回过头来看下glob模式。我们要写一个函数把glob模式转换成正则表达式。glob模式和正则都是字符串,因此函数的类型就很清楚了。

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

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

globToRegex :: String -> String

生成的正则必须是锚定的,这样它从字符串的开头匹配到结尾。

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

String就是 [Char],一个Char的列表。: 操作符把一个值(这里是字符 ^ )放到列表的前端,这个列表是将要看到的 globToRegex' 函数的返回值。

[Note] 在定义之前使用一个值

在Haskell的源文件中使用一个值或者变量时,并不要求先声明或定义。定义出现在它第一次使用的后面是很正常的。Haskell编译器在源代码一级并不关心顺序。我们可以按照最合理的方式来灵活组织代码,而不是非要按照让编译器作者最舒服的顺序。

Haskell模块的作者经常把“最重要”的代码放在源文件的开头,把其他的连接辅助代码放到后面。在这里globToRegex函数和它的辅助函数就是这么做的。

globToRegex' 函数以正则表达式为基础,因此要做一些转换工作。我们用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

第一个子句规定如果遇到glob模式的结尾(空字符串),就返回正则中匹配行尾的 $ 符号。后面的一系列子句把glob语法转成正则语法。最后一个子句处理其他字符的转义。

escape函数保证正则引擎不把输入的字符当作正则的语法翻译。

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

charClass 辅助函数只检查一个字符类是否正确的结束。它把输入直接输出,直到遇到 ] 才把控制权交还给 globToRegex' 。

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

现在完成了globToRegex和它的辅助函数的定义,载入到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$"

它们看上去很像合理的正则表达式。可以用它匹配字符串么?

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

成功!我们再在 ghci 中玩一下。可以创建一个 fnmatch 的临时定义并使用。

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函数命名风格是具有描述性的驼峰匹配( camel cased)。驼峰匹配把单词连接起来,除了开头的外的单词都大写首字母。比如 “file name matches”的,将变成 fileNameMatches。驼峰匹配这个名字来自大写字母造成的“驼峰”。在我们库里,将用 matchesGlob 来命名。

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

你可能已经注意到了,我们用过的大多数变量名都比较短。作为一个经验,在更长的函数定义中,富有描述性的变量名更有用一些。而对于一个两行的函数来说,长函数名没太大用处。

练习

1. 在ghci中试验下,给globToRegex传入格式错误的模式,如 [ ,看看会发生什么。写一个小函数调用 globToRegex,传给它一个错误格式的模式,看看发生什么。

2. Unix类文件系统的文件名是大小写敏感的(如"G" 和  "g" 是不同的),而Windows的文件系统不是。给globToRegex 和 matchesGlob函数增加一个参数,控制是否进行大小写敏感匹配。

重要的离题:编写惰性函数

在命令式语言中,globToRegex'函数通常用循环表示。例如Python的标准 fnmatch 模块包含了一个名为 translate 的函数,它与globToRegex 功能相同。它就是用循环实现的。

如果你受到如 Scheme或ML这些语言的函数式编程思想的影响,你可能已经被灌输了这样的概念“通过尾递归模拟循环”。

看下globToRegex'函数,它并不是尾递归。再看下它最后一个子句(其他子句的解构类似)就知道原因了。

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

它递归的调用自身,并将递归调用的结果作为 (++)函数的参数。因为递归调用不是这个函数的最后的动作,因此globToRegex'不是尾递归。

为什么这个函数的定义不是尾递归呢?原因在于Haskell的非严格求值策略。在开始讨论它之前,我们先快速的看看为什么传统的语言里应该避免这种类型的递归。这是 (++) 的一个简化定义。它是递归的,但不是尾递归。

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

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

在严格语言中,如果要求值 "foo" ++ "bar" ,将构造整个列表,然后返回。非严格求值将大部分工作推迟到真正需要他们的时候。

如果要用 "foo" ++ "bar" 这个表达式的一个元素,函数定义的一个模式被匹配,并返回 x : (xs ++ ys) 表达式。(:) 构造子是非严格的, xs ++ ys 的求值被推迟:需要多少元素就产生多少。当产生更多结果时,将不再使用 x,因此垃圾回收器会回收它。因为我们按需生成需要的元素,并且不保留不需要的部分,因此编译器生成的代码可以使用不变的空间。

使用我们的模式匹配器

有了一个可以匹配glob模式的函数,还要能实际使用它。在Unix类系统上,glob模式返回所有匹配给定模式的文件和目录的名字。我们在Haskell中创建一个类似的函数。按照Haskell的命名规范,称其为 namesMatching。

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

我们规定使用Glob 模块的用户只能看到模块中的 namesMatching。

这个函数显然要进行很多文件系统路径的操作,运行时要进行分割和组合等。我们要用些之前不熟悉的模块。

System.Directory提供了操作目录和目录内容的标准函数。

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

System.FilePath 模块是操作系统路径名约定的抽象。(</>)函数将路径的两个部分进行组合。

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


 dropTrailingPathSeparator 函数的名字已经说明了一切了。

ghci> dropTrailingPathSeparator "foo/"
"foo"

 splitFileName 函数在路径最后一个斜线处分割。

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

 System.FilePath 和 System.Directory 模块一起用,可以写一个Unix类和Windows系统上都可用的 namesMatching 函数。

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

在这个模块里将模拟"for"循环;初次尝试Haskell的异常处理;当然还有我们刚写的 matchesGlob 函数。

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

因为目录和文件都是“现实世界”,操作它们有“副作用”,所以处理它们的函数结果具有 IO 类型。

如果传入的字符串不包含模式字符,我们简单的检查文件系统中是否有给定的名字。(注意这里用Haskell的守卫语法,以写出简洁的定义。用"if"也可以实现,但是审美上欠缺些)

-- 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 指向的函数后面很快会定义。

如果字符串是一个glob模式呢?函数的定义继续。


-- 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函数来把字符串分为两部分,最后的文件名和前面其他部分。如果第一个元素是空的,将在当前目录中寻找模式。否则,必须检查目录名看是否包含模式。如果没有,就创建只有这个目录名的列表。如果包含模式,将列出所有匹配的目录。

[Note]    需要小心的事

System.FilePath 模块有些棘手。上面就是个恰当的例子;splitFileName 函数在返回的目录名末尾留了一个斜线。

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

如果忘记(或不知道)去掉那个斜线的话,因为下面namesMatching的行为,namesMatching将会无限递归。

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

你可以猜到发生了什么事让我们加上这个注释。

最后,把每个目录中所有匹配的收集起来,得到一个列表的列表,把它们连接成一个单独的名字的列表。

上面不熟悉的forM函数有些像 "for" 循环:它把第二个参数(一个动作)映射到第一个参数(一个列表)上,并返回结果的列表。

还有些散乱的收尾工作要做。首先是上面用到的doesNameExist函数的定义。System.Directory模块并不能让我们检查一个名字是否存在。它强迫我们要么检查文件要么检查目录。这个API有些笨拙,因此我们把这两个检查放在一个函数中。为了性能,先检查文件,因为文件要远比目录普遍。

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

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

还有两个函数要定义,每个都返回目录中名字的一个列表。listMatches 函数返回目录中所有匹配给定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 [])) $ 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 函数要么返回空列表,要么返回一个元素的列表,取决于传入的名字是否存在。

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

仔细观察上面的listMatches定义,会发现我们调用了一个名为 handle 的函数。之前,我们从Control.Exception 模块中导入了它的定义;如其名字所暗示的,这是初次尝试Haskell中的异常处理。我们把它扔到ghci中看看有什么发现。

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

这说明 handle 取两个参数。第一个是一个函数,这个函数传入一个异常值,并可以具有副作用(其返回值类型带有 IO);抛出异常时执行这个函数。第二个参数是可能会抛出异常的代码。

作为异常处理器,handle 的类型限制了它的返回结果类型,必须与可能抛出异常的程序体的值的类型相同。因此在我们代码中,它要么抛出异常,要么返回一个String的列表。

const 函数取两个参数;总是返回它的第一个参数,而不管第二个参数。

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写一个异常处理器,它忽略掉传入的异常。在捕获到异常时,它可以让我们的代码返回空列表。

这里对异常处理没有什么可说的了。还有许多需要讨论的内容,我们放在第19章《错误处理》中。

练习

1. 虽然我们已经使用大小写敏感的globToRegex函数写了可移植的namesMatching函数。找出一种方法,修改namesMatching函数的定义而不改动其类型,使它在Unix类系统上大小写敏感,而在Windows上大小写不敏感。

提示:考虑阅读System.FilePath的文档,找出表示当前运行在Unix还是Windows系统上的变量。

2. 如果你在Unix类系统上,查看System.Posix.Files模块的文档,看看是否可以找到doesNameExist函数的替代。

3. * 通配符只在一个目录中匹配名字。很多shell有一个扩展的通配符语法 **,它可以在所有目录中递归的匹配名字。例如 **.c 意思是“在本目录及其任意深的子目录中匹配 .c 结尾的名字”。实现 ** 通配符。

API设计时的错误处理

当给globToRegex 传入格式错误的模式时,并不一定是灾难。也许是用户拼写错误,我们希望在这种情况下可以报告有意义的错误信息。

这类问题发生时就调用error 函数是很极端的反应(??)。error 抛出一个异常。纯Haskell代码无法处理异常,因此程序从纯代码中跳出,跳到最近的安装了合适的异常处理的IO调用中。如果没有安装这样的处理器,Haskell运行时默认会中止我们的程序(在ghci中打印出一堆讨厌的错误信息)。

因此调用error 有点类似于拉下战斗机弹射座椅的把手。我们从一个无法得体处理的灾难中逃离,在坠地后留下一堆燃烧的碎片。

我们已经确立了 error 是为了灾难发生时使用的,但是在globToRegex中我们依然使用它。非法的输入应该被拒绝,但是不该把事情搞大。更好的处理方法是什么呢?

Haskell的类型系统和库可以拯救我们!我们可以在globToRegex的类型签名中使用预定义的Either类型,来表示失败的可能性。

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

globToRegex :: String -> Either GlobError String

globToRegex 返回的值要么是 Left "错误信息",要么是 Right "有效的正则"。这个返回类型强迫调用者去处理可能的错误。(你会发现Haskell代码中Either类型的使用经常出现)

练习

1. 写一个用上面类型签名的globToRegex版本。

2. 修改namesMatching的类型签名,使它可以对模式的错误进行编码,让它使用你重写的globToRegex函数。

[Tip]    Tip
你会发现涉及的工作惊人的大。别担心:后面的章节中会介绍如何用更简洁更高级的方式处理错误。

实际应用代码

namesMatching 函数本身没什么激动人心的,但确实很有用的程序块。和其他一些函数组合后,就可以做些有趣的事情了。

这里有一个例子。定义一个 renameWith 函数,它不是简单的把文件重命名,而是把文件名应用到一个函数上,再将文件改名成此函数的返回结果。

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

我们再次使用了一个辅助函数,来绕开System.Directory 对文件/目录的区分。

-- 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 模块里提供了很多操作文件名的有用函数。这些函数可以很好的与我们的 renameWith 及 namesMatching函数结合,这就就可以快速的用它们创建复杂的行为了。下面就是个实例,这个简洁的函数用来处理C++源文件的后缀名。

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

cc2cpp 函数用了些将会反复看到的函数。flip 函数把另一个函数的参数进行交换(在ghci里看下replaceExtension的类型就知道为什么了)。 =<< 函数将其右侧动作的结果传给其左侧的动作。

练习

1. glob模式解释起来足够简单,可以不用通过正则而直接用Haskell写。试一下。

如果读者不知晓正则表达式的话,我们推荐Jeffrey Friedl 的《精通正则表达式》一书。(O'Reilly)

转载于:https://www.cnblogs.com/IBBC/archive/2011/07/25/2116327.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值