Real World Haskell 第九章 I/O案例学习:搜索文件系统的库

自从计算机有了层次型文件系统以来,“我知道我有这个文件,但是我不知道它在哪”这个问题一直困扰着我们。1974年Unix第五版中引入了 find 命令,至今它依然不可或缺。计算机技术已经进展了很多,现代的操作系统附带了高级的文档索引和搜索能力。


在程序员的工具箱中,find这样的能力依然占据有其价值的位置。在本章,我们将在Haskell中开发一个提供find的很多能力的程序库。我们将探索集中不同的方法来写这个库,每种具有不同的能力。

如果你不使用Unix类的操作系统,或者并非重度的shell用户,很可能你并没有听说过find。给出目录列表,它会递归的搜索每一个目录,并输出匹配一个表达式的每一个项目的名字。

单独的表达式可以具有如下的形式“名字匹配这个模式块的”,“项目是普通文本文件”,“修改日期在某天前”等等。它们可以用"and"和"or"操作符缝合起来组成更复杂的表达式。

从简单开始:递归的列出目录

在开始设计自己的库之前,让我们先来解决些小点的问题。第一个问题是递归的列出一个目录和它的子目录的内容。


-- file: ch09/RecursiveContents.hs
module RecursiveContents (getRecursiveContents) where

import Control.Monad (forM)
import System.Directory (doesDirectoryExist, getDirectoryContents)
import System.FilePath ((</>))

getRecursiveContents :: FilePath -> IO [FilePath]

getRecursiveContents topdir = do
  names <- getDirectoryContents topdir
  let properNames = filter (`notElem` [".", ".."]) names
  paths <- forM properNames $ \name -> do
    let path = topdir </> name
    isDirectory <- doesDirectoryExist path
    if isDirectory
      then getRecursiveContents path
      else return [path]
  return (concat paths)

filter表达式保证在列出一个单独的目录时不会包含特殊目录名 . 和 .. ,它们分别指向当前目录和父目录。如果忘记过滤掉它们的话,将会产生无限递归。

在前一章中,我们遇到过 forM;它是参数翻转的 mapM。

ghci> :m +Control.Monad
ghci> :type mapM
mapM :: (Monad m) => (a -> m b) -> [a] -> m [b]
ghci> :type forM
forM :: (Monad m) => [a] -> (a -> m b) -> m [b]

循环体中检查当前的项是否是目录。如果是的话,就递归调用 getRecursiveContents 列出这个目录。否则,return一个包含当前项的名字的单元素列表。(别忘了,return函数在Haskell中具有独特的含义:用monad的类型构造子来包装一个值)。

另外值得指出的是 isDirectory变量的使用。在命令式语言,如Python中,我么一般写成 if os.path.isdir(path)。但是,doesDirectoryExist函数是一个操作;他的返回类型是 IO Bool而不是 Bool。 由于 if 需要一个 Bool 类型的表达式,因此我们必须用  <- 把操作返回值的IO包装去掉,获得 Bool类型的结果,这样我们就可以在if 中使用平常的无包装的Bool值了。

循环体的每次迭代会产生一个名字的列表,因此 forM 的结果是 IO [[FilePath]]。用 concat 把它展平成一个单独的列表。

回顾匿名和具名函数
Revisiting anonymous and named functions

在“lambda匿名函数”一节,我们列出了一些不使用匿名函数的原因,但在这里我们用了一个匿名函数作为循环体。这是匿名函数在Haskell里一个最常见的应用。

从forM 和 mapM的类型中已经可以看出它们将函数作为参数。程序中大部分的循环体是只出现一次的代码块。我们大多数情况只在一个地方使用循环体,为什么要给他们个名字呢?

当然,有时候会出现要在几个不同的循环中使用完全相同的代码的情况。在这种情况下,把一个已有的匿名函数提取成具名函数,要比剪切粘贴相同的匿名函数合理的多。

为什么 mapM 和 forM两个都提供?

存在两个除了参数顺序外完全相等的函数,这可能显得有些奇怪。但是mapM 和 forM 可以满足不同的情形。

考虑上面的例子,用匿名函数做循环体。如果用了 mapM 来替代 forM 的话,就要把properNames变量放到函数体的后面了。为了使代码解析正确,还要把整个匿名函数用括号包起来,或者把它替换成一个具名的函数,本无必要这样做。自己试一下,把上面的代码中 forM 替换成 mapM,看看对代码可读性的影响。

相对的,如果循环体已经是具名函数,而要循环处理的列表是一个复杂的表达式,用 mapM 替代会更好。

要遵循的风格规则是:不论mapM 还是forM,用让你的代码最整洁的那一个。如果循环体和产生循环处理数据的表达式都很短的话,用哪一个没关系。如果循环体短,但是数据部分长,用 mapM。如果循环体长,但是数据部分短,用 forM。如果两个都长的话,用let 或 where子句把其中一个变短。经过一点练习,在每种情况下用哪种方法就变得显而易见了。


一个朴素的find函数
基于 getRecursiveContents 函数可以构造一个头脑简单的文件搜索器。

-- file: ch09/SimpleFinder.hs
import RecursiveContents (getRecursiveContents)

simpleFind :: (FilePath -> Bool) -> FilePath -> IO [FilePath]

simpleFind p path = do
  names <- getRecursiveContents path
  return (filter p names)

这个函数需要一个断言来过滤 getRecursiveContents 返回的名字列表。传给断言函数的每一个名字是完整的路径,如何实现像“找出以 .c 扩展名结尾的所有文件”这样的一般操作呢?

System.FilePath 模块包含了很多帮助我们操作文件名的函数。在这里,我们要用 takeExtension。

ghci> :m +System.FilePath
ghci> :type takeExtension
takeExtension :: FilePath -> String
ghci> takeExtension "foo/bar.c"
Loading package filepath-1.1.0.0 ... linking ... done.
".c"
ghci> takeExtension "quux"
""

可以简单的写个函数,来把输入的路径取出扩展名,并与 .c 进行比较。

ghci> :load SimpleFinder
[1 of 2] Compiling RecursiveContents ( RecursiveContents.hs, interpreted )
[2 of 2] Compiling Main             ( SimpleFinder.hs, interpreted )
Ok, modules loaded: RecursiveContents, Main.
ghci> :type simpleFind (\p -> takeExtension p == ".c")
simpleFind (\p -> takeExtension p == ".c") :: FilePath -> IO [FilePath]

simpleFind 工作时有些显著的问题。首先,断言的表达能力不太好。它只能看到一个目录项的名字;无法知道它是文件还是目录。这表明我们尝试用simpleFind 时会把结尾为 .c 的目录也一起当作文件列出来。

第二个问题是 simpleFind 对如何遍历文件系统无法进行控制。来看下为何这是重要的,考虑在一个svn版本控制系统下的目录树中寻找源文件的问题。svn在它管理的每一个目录中维护一个私有的 .svn 目录;每个 .svn 目录里面有很多子目录和文件,那些我们并不关心。我们可以简单的把路径中包含 .svn 的项目过滤掉,更高效的方法是一开始就避免遍历这些目录。例如,有人的svn源代码树包含45000个文件,其中30000个保存在1200个不通的 .svn 目录中。避免遍历这 1200 个目录要比过滤掉它们包含的 30000 个文件节省多了。

最后, simpleFind 是严格执行的,因为它由一系列的 IO monad 操作组成。如果有上百万的文件要遍历,我们将在一个很长的延迟之后得到一个包含上百万名字的巨大结果。这种资源使用和响应性都很糟糕。我们更需要一个输出已找到结果的惰性流。

在下面的几节,我们要克服这些问题。

断言:从贫乏到丰富,并保持纯洁
我们的断言只能看到文件的名字。这排除了很多有趣的特性:例如,如果想要列出比给定的大小更大的文件呢?

对此一个简单反应是用 IO:如果我们的断言函数不是 FilePath -> Bool 类型,改成 FilePath -> IO Bool 如何?这可以让我们的断言函数执行任意的 I/O 操作。虽然这很吸引人,但是也是存在潜在问题的:这样的断言函数因为返回值具有 IO 类型,是可以具有任意的副作用的。


让我们在搜索中用类型系统,来帮助书写更可预知更少bug的代码:我们会避免IO污染,来保持断言函数的纯洁性。这将保证它们不会具有任何讨厌的副作用。我们也会给他们更多的信息,这样它们就可以获得我们想要的更多表达能力,并且没有潜在的危险。

Haskell的可移植模块 System.Directory 提供了有用的文件元信息集,虽然有限。

ghci> :m +System.Directory

    * 我们可以用 doesFileExist 和 doesDirectoryExist 来确定目录中的一个项目是文件还是目录。这些年已经广泛使用的其他的文件类型,比如命名管道,硬连接和符号连接,还没有可移植的方法进行查询。

      ghci> :type doesFileExist
      doesFileExist :: FilePath -> IO Bool
      ghci> doesFileExist "."
      Loading package old-locale-1.0.0.0 ... linking ... done.
      Loading package old-time-1.0.0.0 ... linking ... done.
      Loading package directory-1.0.0.0 ... linking ... done.
      False
      ghci> :type doesDirectoryExist
      doesDirectoryExist :: FilePath -> IO Bool
      ghci> doesDirectoryExist "."
      True

    * getPermissions 函数可以确定是否允许对文件或目录执行特定操作。

      ghci> :type getPermissions
      getPermissions :: FilePath -> IO Permissions
      ghci> :info Permissions
      data Permissions
        = Permissions {readable :: Bool,
                       writable :: Bool,
                       executable :: Bool,
                       searchable :: Bool}
            -- Defined in System.Directory
      instance Eq Permissions -- Defined in System.Directory
      instance Ord Permissions -- Defined in System.Directory
      instance Read Permissions -- Defined in System.Directory
      instance Show Permissions -- Defined in System.Directory
      ghci> getPermissions "."
      Permissions {readable = True, writable = True, executable = False, searchable = True}
      ghci> :type searchable
      searchable :: Permissions -> Bool
      ghci> searchable it
      True

(如果你不记得ghci的特殊变量 it 的话,可以翻回 "类型初步"那一节看一下。)  对目录来说如果我们具有列出其内容的权限,目录就是可以搜索的;文件总是不能搜索的。

    * 最后,getModificationTime 给出项目最后被修改的时间。

      ghci> :type getModificationTime
      getModificationTime :: FilePath -> IO System.Time.ClockTime
      ghci> getModificationTime "."
      Mon Aug 18 12:08:24 CDT 2008

如果我们坚持可移植,标准的Haskell代码的话,这些函数就是我们可以使用的全部了。(通过一些技巧可以得到文件的大小;下文介绍。)这些对于演示我们感兴趣的原理已经足够用了,不会让我们被太扩展的问题带跑题。如果需要写更高要求的代码,  System.Posix 和 System.Win32 两个模块族提供了两个主要现代平台上的更详细的文件元信息。在Hackage 上还有一个 unix兼容包,提供了windows平台上的Unix风格Api。


我们新的断言需要知道多少信息呢?由于可以通过查看一个项目的权限获知它是文件还是目录,因此我们不需要传入 doesFileExist 或 doesDirectoryExist 的结果。这样,断言有4项数据需要查看。

-- file: ch09/BetterPredicate.hs
import Control.Monad (filterM)
import System.Directory (Permissions(..), getModificationTime, getPermissions)
import System.Time (ClockTime(..))
import System.FilePath (takeExtension)
import Control.Exception (bracket, handle)
import System.IO (IOMode(..), hClose, hFileSize, openFile)

-- the function we wrote earlier
import RecursiveContents (getRecursiveContents)

type Predicate =  FilePath      -- path to directory entry
               -> Permissions   -- permissions
               -> Maybe Integer -- file size (Nothing if not file)
               -> ClockTime     -- last modified
               -> Bool

Predicate 类型是四个参数的函数的同义语。这会为我们节省一些输入和屏幕空间。

注意这个断言的返回值类型是 Bool, 而不是 IO Bool: 断言是纯函数的,不能执行 I/O操作。有了这个类型在手上,更具表达能力的 find 函数将依然保持简洁。

-- file: ch09/BetterPredicate.hs
-- soon to be defined
getFileSize :: FilePath -> IO (Maybe Integer)

betterFind :: Predicate -> FilePath -> IO [FilePath]

betterFind p path = getRecursiveContents path >>= filterM check
    where check name = do
            perms <- getPermissions name
            size <- getFileSize name
            modified <- getModificationTime name
            return (p name perms size modified)

来看下代码。很快我们会讨论 getFileSize 的细节,所以现在先跳过。

不能用 filter 来调用我们的断言 p ,由于 p 的纯粹性意味着它不能执行 I/O操作来收集它所需要的元信息。

因此我们遇到了不熟悉的 filterM 函数。它的行为类似于普通的 filter 函数,但是在此情况下,它在IO monad中执行它的断言函数,允许断言函数执行I/O操作。

ghci> :m +Control.Monad
ghci> :type filterM
filterM :: (Monad m) => (a -> m Bool) -> [a] -> m [a]


check这个断言是对纯函数断言p的I/O包装。它替p干了所有I/O的“脏活”,这样就可以保证p无法产生不需要的副作用。check在收集了元信息后调用p,然后把p的返回结果用IO包装后返回。

安全的获得文件尺寸

虽然 System.Directory 不允许我们获得文件大小,但是我们可以用类似的一个可移植模块 System.IO 来解决。它包含一个名为 hFileSize 的函数,它返回一个打开文件的字节数。下面是一个简单的包装函数。


-- file: ch09/BetterPredicate.hs
simpleFileSize :: FilePath -> IO Integer

simpleFileSize path = do
  h <- openFile path ReadMode
  size <- hFileSize h
  hClose h
  return size

虽然这个函数可以工作,但还不适合使用。在 betterFind 中会在任何目录项上无条件的调用 getFileSize;如果目录项不是纯文本文件它返回 Nothing,否则返回Just包装的文件大小。而这个函数在遇到目录项不是纯文本或者无法打开文件(可能因为权限不足)时会抛出异常,而且返回的尺寸没有包装。

这里是这个函数更安全的版本。

-- file: ch09/BetterPredicate.hs
saferFileSize :: FilePath -> IO (Maybe Integer)

saferFileSize path = handle (\_ -> return Nothing) $ do
  h <- openFile path ReadMode
  size <- hFileSize h
  hClose h
  return (Just size)

除了handle子句外,这个函数的函数体几乎一样。

上面的异常处理忽略了传入的异常并返回 Nothing。函数体唯一的改变是在最后把尺寸用Just包装。

现在saferFileSize函数有了正确的类型签名,并且不会再抛出任何异常了。但是它的行为依然不是完全正确。有一些目录项在 openFile 时会成功,但 hFileSize 会抛出异常。在比如命名管道上会发声。这样的异常可以被 handle 捕获,但是 hClose 将永远不会发生。

Haskell的实现在注意到一个文件句柄不再被使用时,会自动的关闭文件句柄。这直到垃圾收集运行时才会发生,而下一次垃圾收集前有多久的延迟是不可知的。

文件句柄是稀缺资源。它的稀缺性来自其运行的操作系统的限制。比如在Linux上,一个进程默认只允许同时打开1024个文件。

因此不难想象这样一个场景:调用使用 saferFileSize的 betterFind 版本有时会崩溃,原因是 betterFind 耗尽了文件句柄又没来得及关闭。

这是一种特别致命的bug:几个方面组合的结果是它非常难于被追踪。只有当下面条件成立时才会出发这个bug: betterFind 访问了特别多非文件并达到进程打开文件句柄上限,并且之后返回到调用者,在累积的垃圾文件句柄被关闭前试图打开其他的文件。

更糟糕的是,

这种bug依赖于程序的结构,文件系统的内容,以及程序当前执行的距离垃圾收集的远近。
??

To make matters worse, any subsequent error will be caused by data that is no longer reachable from within the program, and has yet to be garbage collected. Such a bug is thus dependent on the structure of the program, the contents of the filesystem, and how close the current run of the program is to triggering the garbage collector. No comments

这种类型的问题在开发中很容易被忽视,并且当它日后在实际使用中发生时(糟糕的程序总是这样),更加难以被诊断。

幸运的是,我们可以很容易避免这种类型的错误,并且可以让我们的函数更简短。

获取-使用-释放周期
我们需要openFile 成功后总是调用hClose。 为此Control.Exception 模块提供了 bracket 函数。

ghci> :type bracket
bracket :: IO a -> (a -> IO b) -> (a -> IO c) -> IO c

bracket 函数使用三个操作作为参数。第一个操作获取一个资源。第二个释放这个资源。第三个在获取了资源之后使用它,称为“使用”操作。如果“获取”操作成功,“释放”操作总是会被调用。这保证了资源总是被释放。“使用”和“释放”操作会分别传入“获取”操作得到的资源。

如果当“使用”操作中发生了异常,bracket 调用“释放”操作并抛出这个异常。如果“使用”操作成功,bracket调用“释放”操作,并返回“使用”操作返回的值。

现在可以写一个完全安全的函数:它不会抛出异常,亦不会累积垃圾文件句柄造成难以捉摸的程序错误。

-- file: ch09/BetterPredicate.hs
getFileSize path = handle (\_ -> return Nothing) $
  bracket (openFile path ReadMode) hClose $ \h -> do
    size <- hFileSize h
    return (Just size)

看下上面bracket的参数。第一个打开文件,并返回打开的文件句柄。第二个关闭这个句柄。第三个简单的在句柄上调用 hFileSize 并把结果用Just包装。

为了这个函数操作正确需要同时使用 bracket 和 handle。前一个保证不会累积垃圾文件句柄,后一个处理异常。

练习

1. 调用 bracket 和 handle 的顺序是否重要?为什么?

断言的领域特定语言

我们来尝试写一个断言。我们的断言尝试检查超过128KB大小的C++源文件。

-- file: ch09/BetterPredicate.hs
myTest path _ (Just size) _ =
    takeExtension path == ".cpp" && size > 131072
myTest _ _ _ _ = False

这并不太令人满意。这个断言需要四个参数,总是忽略其中两个,并获取另外两个来判断。当然我们可以做得更好。让我们来写点代码帮助我们写更方便到断言。

有时这种类型到库被当作领域特定语言:我们使用编程语言内置的功能(为了内置)来编写解决一些特定领域问题(领域特定)的优雅代码。

第一步写一个函数来返回它的一个参数。这一个从传入断言的参数中抽取路径。

-- file: ch09/BetterPredicate.hs
pathP path _ _ _ = path

如果不提供类型签名,Haskell的实现将维这个函数推断一个非常通用的类型。这在后面会导致难以解释的错误信息,所以我们给pathP一个类型声明。

-- file: ch09/BetterPredicate.hs
type InfoP a =  FilePath        -- path to directory entry
             -> Permissions     -- permissions
             -> Maybe Integer   -- file size (Nothing if not file)
             -> ClockTime       -- last modified
             -> a

pathP :: InfoP FilePath

我们创建了一个类型同义词,在写其他类似结构的函数时用作缩写。类型同义词接受一个类型参数,这样就可以指定不通的结果类型。

-- file: ch09/BetterPredicate.hs
sizeP :: InfoP Integer
sizeP _ _ (Just size) _ = size
sizeP _ _ Nothing     _ = -1

(在这里有些鬼鬼祟祟的,如果项目不是文件或无法打开时返回的是 -1 。)

实际上,很容易看出在本章开头定义的Predicate类型与 InfoP Bool 是相同的。(这样我们可以合理的处理Predicate 的类型)

pathP 和 sizeP的作用是什么呢?通过一些胶合,可以把它们用在断言中(名字后缀P指“predicate断言”)。这样事情开始变得有趣起来了。

-- file: ch09/BetterPredicate.hs
equalP :: (Eq a) => InfoP a -> a -> InfoP Bool
equalP f k = \w x y z -> f w x y z == k

equalP的类型签名值得注意。它获取一个 InfoP a, 和 pathP 和 sizeP 兼容;还取一个 a 类型。它返回一个 InfoP Bool值,我们已经注意到它是Predicate的同义词。也就是说,equalP 构造了一个断言。

equalP函数通过返回一个匿名函数来工作。这个函数把断言接受的参数传给 f ,并将结果与 k 进行比较。

equalP 的定义强调了一个事实:我们把它按取两个参数来考虑。因为Haskell对所有的函数都进行柯里化,这样写并不是必要的。我们可以靠柯里化来去除掉匿名函数,写出来的函数是等价的。

-- file: ch09/BetterPredicate.hs
equalP' :: (Eq a) => InfoP a -> a -> InfoP Bool
equalP' f k w x y z = f w x y z == k

继续探索前,先把模块载入到 ghci 中。

ghci> :load BetterPredicate
[1 of 2] Compiling RecursiveContents ( RecursiveContents.hs, interpreted )
[2 of 2] Compiling Main             ( BetterPredicate.hs, interpreted )
Ok, modules loaded: RecursiveContents, Main.

看下这些函数组合起来的简单断言能否工作。

ghci> :type betterFind (sizeP `equalP` 1024)
betterFind (sizeP `equalP` 1024) :: FilePath -> IO [FilePath]

注意我们并没有实际调用 betterFind,我们只是把表达式进行类型检查。现在要列出具有特定大小的文件列表,有了更具表达能力的方式。这些成功给了我们继续下去的信心。

用提升避免模板代码

除了 equalP,我们还想写出其他的二元函数。我们不希望每个都完全重复定义,因为没必要那么冗长。

为此我们使用Haskell抽象的能力。我们把equalP 的定义修改下,将要调用的另一个二元函数作为参数传入,而不是直接调用 (==) 。

-- file: ch09/BetterPredicate.hs
liftP :: (a -> b -> c) -> InfoP a -> b -> InfoP c
liftP q f k w x y z = f w x y z `q` k

greaterP, lesserP :: (Ord a) => InfoP a -> a -> InfoP Bool
greaterP = liftP (>)
lesserP = liftP (<)

把一个(>)这样的函数在不同的场景放入不同的函数中,在这里是 greaterP,这种操作称为提升进那个场景。函数名中的lift就是这个意思。提升可以用来重用代码,减少重复样板代码。在本书后面将用各种方式大量的使用它。

当提升一个函数时,将相应的称原函数与新函数为未提升的和已提升的函数。

另外,把要提升的函数 q 放在 liftP 的第一个参数的位置上,是经过深思熟虑的。这可以让我们写出很简洁的 greaterP 和 lesserP 的定义。在Haskell里,由于存在函数的部分应用,因此在设计API时,为参数寻找最佳顺序变得比其他语言中更重要。在其他没有函数部分应用的语言中,参数的顺序只是一个口味和习惯的问题。而在Haskell中如果把参数放在错误的位置上,就失去了函数部分应用为简洁性带来的好处了。

通过组合子可以恢复一些简洁性。例如,直到2007年 forM 才加入到 Control.Monad 模块中。在那之前,人们通过写 flip mapM 来代替。

ghci> :m +Control.Monad
ghci> :t mapM
mapM :: (Monad m) => (a -> m b) -> [a] -> m [b]
ghci> :t forM
forM :: (Monad m) => [a] -> (a -> m b) -> m [b]
ghci> :t flip mapM
flip mapM :: (Monad m) => [a] -> (a -> m b) -> m [b]

将断言胶合在一起

如果要把断言组合在一起,当然可以用手工的方式直接做。

-- file: ch09/BetterPredicate.hs
simpleAndP :: InfoP Bool -> InfoP Bool -> InfoP Bool
simpleAndP f g w x y z = f w x y z && g w x y z

现在直到了提升,用提升现有的布尔操作符来减少手写的代码量就变得很自然了。

-- file: ch09/BetterPredicate.hs
liftP2 :: (a -> b -> c) -> InfoP a -> InfoP b -> InfoP c
liftP2 q f g w x y z = f w x y z `q` g w x y z

andP = liftP2 (&&)
orP = liftP2 (||)

注意 liftP2 与之前的  liftP 很像。实际上,它更加通用,因为我们可以按照 liftP2 来写liftP。

-- file: ch09/BetterPredicate.hs
constP :: a -> InfoP a
constP k _ _ _ _ = k

liftP' q f k w x y z = f w x y z `q` constP k w x y z


[Note] 组合子
在Haskell里,有一种函数称为组合子,它们取其他函数作为参数并返回新的函数。

现在有了一些辅助函数,现在可以返回之前定义的 myTest 函数了。

-- file: ch09/BetterPredicate.hs
myTest path _ (Just size) _ =
    takeExtension path == ".cpp" && size > 131072
myTest _ _ _ _ = False

用我们新的组合子要如何写这个函数呢?

-- file: ch09/BetterPredicate.hs
liftPath :: (FilePath -> a) -> InfoP a
liftPath f w _ _ _ = f w

myTest2 = (liftPath takeExtension `equalP` ".cpp") `andP`
          (sizeP `greaterP` 131072)

我们增加了最后一个组合子 liftPath,因为操作文件名是很常见的动作。

定义与使用新的操作符

通过定义新的中缀操作符,可以让我们的领域特定语言更进一步。

-- file: ch09/BetterPredicate.hs
(==?) = equalP
(&&?) = andP
(>?) = greaterP

myTest3 = (liftPath takeExtension ==? ".cpp") &&? (sizeP >? 131072)

我们选择像 (==?) 这样的名字,是为了让提升后的函数与它们未提升的函数看上去相似。

上面定义中的括号是必须的,因为我们还没有告诉Haskell关于我们新的操作符的优先级和结合性。Haskell语言把未指定固定性(fixity ??)  的操作符当作 infixl 9 来对待,即它们从左到右按最高优先级求值。如果我们去掉了括号,上面的表达式将会被解析成 (((liftPath takeExtension) ==? ".cpp") &&? sizeP) >? 131072 , 这是完全错误的。

给新的操作符写固定性声明可以解决这个问题。第一步先找出未提升的操作符的固定性是什么,这样就可以照猫画虎了。

ghci> :info ==
class Eq a where
  (==) :: a -> a -> Bool
  ...
      -- Defined in GHC.Base
infix 4 ==
ghci> :info &&
(&&) :: Bool -> Bool -> Bool     -- Defined in GHC.Base
infixr 3 &&
ghci> :info >
class (Eq a) => Ord a where
  ...
  (>) :: a -> a -> Bool
  ...
      -- Defined in GHC.Base
infix 4 >

有了这些,就可以写出不用括号并与 myTest3 解析相同的表达式了。

-- file: ch09/BetterPredicate.hs
infix 4 ==?
infixr 3 &&?
infix 4 >?

myTest4 = liftPath takeExtension ==? ".cpp" &&? sizeP >? 131072

控制遍历

在遍历文件系统时,我们希望能够控制进入哪些目录,以及何时进入。要解决控制进入哪些目录一个简单的办法是,给一个函数传入给定目录的子目录列表,并返回另一个列表。这个列表可以被删除一些元素,或者按不同的顺序重新排列,或者两者同时。这种控制函数最简单的是 id ,它把输入的列表不修改的返回。

为了多样化些,我们这里改变下表达方式。我们用一个普通的代数数据类型来代替精巧的 InfoP a 函数,它们表达的信息大致相同。

-- file: ch09/ControlledVisit.hs
data Info = Info {
      infoPath :: FilePath
    , infoPerms :: Maybe Permissions
    , infoSize :: Maybe Integer
    , infoModTime :: Maybe ClockTime
    } deriving (Eq, Ord, Show)

getInfo :: FilePath -> IO Info

我们用记录语法,这样就可以用infoPath这样的“自由”访问函数。traverse 函数的类型很简单,像之前提议过的那样。要获得关于文件或者目录的信息,调用 getInfo 操作。

-- file: ch09/ControlledVisit.hs
traverse :: ([Info] -> [Info]) -> FilePath -> IO [Info]

traverse 的定义比较短,但却很稠密。

-- file: ch09/ControlledVisit.hs
traverse order path = do
    names <- getUsefulContents path
    contents <- mapM getInfo (path : map (path </>) names)
    liftM concat $ forM (order contents) $ \info -> do
      if isDirectory info && infoPath info /= path
        then traverse order (infoPath info)
        else return [info]

getUsefulContents :: FilePath -> IO [String]
getUsefulContents path = do
    names <- getDirectoryContents path
    return (filter (`notElem` [".", ".."]) names)

isDirectory :: Info -> Bool
isDirectory = maybe False searchable . infoPerms

这里没有介绍任何新的技术,这是我们至今遇到的最稠密的函数定义。我们一行一行的过一遍,详细解释。头两行没什么神秘感,它们几乎是之前代码的照抄。

在分配变量内容时开始变得有趣起来了。我们从右到左来读这一行。我们知道names 是目录项的列表。我们把每个元素前面加上当前目录,并把当前目录也包含在列表中。用 mapM getInfo来收集结果路径。

接下来的一行更加稠密。再次从右到左读,我们看到这一行的最后一个元素开始了一个匿名函数的定义,这个匿名函数一直到整段的末尾。给这个函数一个 Info 值,它要么递归的访问这个目录(有附加的检查来确保不会重复访问一个路径),要么把这个值当作单元素列表返回(为了与 traverse 的结果类型匹配)。

使用forM 把这个函数应用到 order 返回的 Info 值列表的每一个元素上,order 是用户提供的遍历控制函数。

在这一行的开头,我们在新的场景中使用提升技术。liftM 函数取一个普通函数 concat,把它提升到 IO monad 里。换句话说,它把 forM 的结果(类型为 IO [[Info]])从IO monad中提取出来,应用 concat (产生 [Info]类型的结果,这是我们需要的),再把这个结果放回 IO monad里。

最后不能忘了定义我们的 getInfo 函数。

-- file: ch09/ControlledVisit.hs
maybeIO :: IO a -> IO (Maybe a)
maybeIO act = handle (\_ -> return Nothing) (Just `liftM` act)

getInfo path = do
  perms <- maybeIO (getPermissions path)
  size <- maybeIO (bracket (openFile path ReadMode) hClose hFileSize)
  modified <- maybeIO (getModificationTime path)
  return (Info path perms size modified)

这里唯一值得注意的是一个有用的组合子 maybeIO,它把一个可能抛出异常IO操作的结果用 Maybe包装。

练习

1. 要按字母顺序的反序遍历一个目录树,应该给traverse 函数传递什么?

2. 用 id 作为控制函数, traverse id 对一个目录树进行前序遍历:它先返回父目录,之后是目录内的项。写一个控制函数进行后序遍历,它在先返回目录的项再返回父目录。

3. 让“把断言胶合起来”一节里的断言和组合子可以处理新的 Info 类型。

4. 给 traverse 函数写一个包装,允许用一个断言控制遍历,用另一个来控制过滤结果。

密度,可读性以及学习过程

像 traverse 这么稠密的代码在Haskell中并不常见。代码可读性带来的好处是巨大的,这需要相对少量的练习才能做到流利的阅读和书写这种风格代码。

作为比较,这里是相同代码不那么稠密的写法。对缺少经验的Haskell程序员来说这很典型。

-- file: ch09/ControlledVisit.hs
traverseVerbose order path = do
    names <- getDirectoryContents path
    let usefulNames = filter (`notElem` [".", ".."]) names
    contents <- mapM getEntryName ("" : usefulNames)
    recursiveContents <- mapM recurse (order contents)
    return (concat recursiveContents)
  where getEntryName name = getInfo (path </> name)
        isDirectory info = case infoPerms info of
                             Nothing -> False
                             Just perms -> searchable perms
        recurse info = do
            if isDirectory info && infoPath info /= path
                then traverseVerbose order (infoPath info)
                else return [info]

这里所做的就是做些替换。在where块中定义一些局部函数,来代替大量的部分函数应用和函数组合。用case表达式来代替 maybe 组合子。我们自己手工的提升 concat ,而不是用 liftM。

这不是说密集一律都是好的属性。原来的 traverse 函数每一行都是短的。我们特地引入一个局部变量(usefulNames)和一个局部函数(isDirectory),来保持代码行简短,代码更清晰。这些名字很具描述性。当用函数组合和流水线时,最长的流水线只包含三个元素。

书写可维护的Haskell代码的关键是寻找密集和可读之间的平衡。受到你的经验等级的影响,你的代码可能在这方面不具连续性。

    * Haskell初级程序员 Andrew  不太熟悉标准库。结果是,他无意中重复写了很多已存在的代码。

    * Zack 已经编程几个月,他已经掌握了用 (.) 来组合长长的流水线代码。每次他需要对程序做些轻微修改时,他都要从头构建新的流水线:他已经再也无法理解之前存在的流水线了,而且无论如何它们都很容易被改坏。

    * Monica已经编码了一阵子了。她已经很熟悉Haskell的库和书写紧致代码的习惯,但是她避免写得太过紧密。她的代码是可维护的,并且当面对修改需求时,她也很容易搞定。

关于遍历的另一面

虽然traverse函数比原来的 betterFind 函数给了我们更多的控制,但它依然有个显著的缺陷:我们可以避免递归进入目录,但是在生成了目录树的完整名字列表之前,无法过滤其他名字。如果我们遍历一个含有10万个文件的目录,我们只关心其中3个,我们首先要分配10万个元素的列表,之后才有机会把它缩减到真正需要的3个。

一种办法是提供一个过滤函数作为新的参数给traverse,在生成名字的列表时应用这个函数。这可以让我们只分配需要数量的元素。

但是这种方法也有个弱点:比如说我们知道我们只要整个列表上至多三项,而这三项碰巧是要遍历的十万个文件的头三个。这种情况下,就不需要访问其他99997 项了。这并非只是个生造的例子:比如 Maildir 邮箱里有一个目录把邮件保存成独立的文件。表示邮箱的一个单独的目录里包含成千上万文件很正常。

我们可以从不同的角度来考虑前面两个遍历函数的弱点:如果把文件系统遍历相像成在目录层次上折叠(fold)呢?

常见的 fold函数, foldr 和 foldl' 巧妙的归纳了遍历列表并累积结果的思想。从列表的折叠到目录树的折叠,思路几乎不用任何扩展,但是我们还想增加一个控制元素。我们用代数数据类型来表示这种控制。

-- file: ch09/FoldDir.hs
data Iterate seed = Done     { unwrap :: seed }
                  | Skip     { unwrap :: seed }
                  | Continue { unwrap :: seed }
                    deriving (Show)

type Iterator seed = seed -> Info -> Iterate seed

Iterator 类型是 fold 操作的函数的别名。它取一个 seed 和一个表示目录项的Info值,返回一个新的 seed 和一个指令,指令用 Iterate类型的构造子表示。

    * 如果指令是 Done,遍历应该立刻结束。Done包装的值应当作为结果返回。

    * 如果指令是 Skip并且当前的Info 表示一个目录,将不会递归遍历这个目录。

    * 其他情况,遍历继续,把其包装的值作为下次调用 fold 函数时的输入。

我们的fold 是一种左侧折叠函数,因为我们从遇到的第一个元素开始折叠,在每一步中seed表示上一次的结果。

-- file: ch09/FoldDir.hs
foldTree :: Iterator a -> a -> FilePath -> IO a

foldTree iter initSeed path = do
    endSeed <- fold initSeed path
    return (unwrap endSeed)
  where
    fold seed subpath = getUsefulContents subpath >>= walk seed

    walk seed (name:names) = do
      let path' = path </> name
      info <- getInfo path'
      case iter seed info of
        done@(Done _) -> return done
        Skip seed'    -> walk seed' names
        Continue seed'
          | isDirectory info -> do
              next <- fold seed' path'
              case next of
                done@(Done _) -> return done
                seed''        -> walk (unwrap seed'') names
          | otherwise -> walk seed' names
    walk seed _ = return (Continue seed)

上面的代码的写法有些有趣的地方。首先是使用作用域来避免把多余的参数传来传去。顶层的 foldTree 函数只是 fold 的包装,它只是把fold 的最终结果的构造子去除。

因为fold 是局部函数,我们不需要把 foldTree 的 iter 参数传递给它;它已经可以访问外层作用域中的 iter 了。相似的, walk 函数也可以看到外层作用域中的 path。

另外要注意的一点是 walk 是一个尾递归循环,而不是之前的函数中 forM 调用的匿名函数。自己把握了缰绳后,就可以在需要的时候提前停止遍历。当迭代器返回 Done 的时候,我们可以提前退出。

虽然 fold 调用 walk,walk又递归的调用 fold 来遍历子目录。每个函数返回一个用 Iterate 包装的种子: 当 walk 调用fold 后,walk 检查fold 的结果来确定是否要继续或者因为它返回 Done 而退出。这样,通过调用者提供的迭代器返回 Done 将立刻把所有的递归调用结束掉。

迭代器在实际中是什么样子呢?这里有个稍微复杂的例子,它寻找至多三个位图图片,并且不会在 Subversion 的元信息目录中递归。

-- file: ch09/FoldDir.hs
atMostThreePictures :: Iterator [FilePath]

atMostThreePictures paths info
    | length paths == 3
      = Done paths
    | isDirectory info && takeFileName path == ".svn"
      = Skip paths
    | extension `elem` [".jpg", ".png"]
      = Continue (path : paths)
    | otherwise
      = Continue paths
  where extension = map toLower (takeExtension path)
        path = infoPath info

要使用它,调用 foldTree atMostThreePictures [] , 返回类型为  IO [FilePath] 的值。

当然,迭代器不一定非要这么复杂。这一个数碰到的目录的个数。

-- file: ch09/FoldDir.hs
countDirectories count info =
    Continue (if isDirectory info
              then count + 1
              else count)

这里,传给 foldTree 的初始值应该是数字0。

练习

1.修改foldTree,允许调用者改变遍历目录项的顺序。

2. foldTree 函数进行前序遍历。修改它,允许调用者指定遍历顺序。

3. 写一个组合子的库,用来表达可以用在 foldTree上的迭代器种类。它是否让你写的迭代器更加简明?

有用的代码准则

虽然很多好的Haskell编程习惯要在练习中获得,不过我们这里有些一般的代码准则,可以帮助你更快的写出更可读的代码。

在“关于tab和空格”一节已经提过,在Haskell代码中永远不要使用tab。用空格!

如果你为写出了一段魔鬼般的代码感到骄傲的话,先停下来想想,你一个月后再回头来看它能否看懂?

类型和变量名如果为组合单词,常规的写法是使用“骆驼式写法”,即 myVariableName。这种风格在Haskell中最通用。不管你对其他命名方式的看法如何,如果按照非标准的约定写,其他的读者看你的Haskell代码就会觉得扎眼。

在你用Haskell足够长的时间以前,每当要写自己的小函数,先花些时间搜索下库函数。这对于无所不在的类型如列表, Maybe 和 Either 尤其管用。如果标准库里还没有提供你想要的,你也可以组合一些函数来实现。


把函数用流水线方式组合起来,如果流水线太长的话就很难阅读,超过三或四个函数就算是长了。如果有这么长的流水线,应该用let或者where块来把它们变成小块的。给流水线上的每个元素分别取个有意义的名字,然后再连接回去。如果你不能给他一个有意义的名字,问下自己是否能描述其行为。如果回答是否定的,就要简化你的代码了。

虽然文本编辑器的宽度可以很容易调大到超过80列,但是这个宽度依然是很通用的。这一个80列宽的文本编辑器窗口中,比较宽的行将会折行或者截断,这将会严重影响可读性。把每一行都不超过80个字符长,会限制一行中可以塞下的内容的数量。这将有助于减少单独一行的复杂度,代码也就更容易理解。

通用布局风格

如果你的代码遵守布局规则的话,Haskell的实现就能很好的处理缩进并明白无误的解析代码。也就是说,有些布局的模式是广泛使用的。

in 关键字通常直接在下面与 let 关键字对齐,它后面直接跟表达式。

-- file: ch09/Style.hs
tidyLet = let foo = undefined
              bar = foo * 2
          in undefined

虽然可以把 in 按不同方式缩进,或者把它“悬挂”在一系列等式的结尾,但是下面这些一般被认为很奇怪。

-- file: ch09/Style.hs
weirdLet = let foo = undefined
               bar = foo * 2
    in undefined

strangeLet = let foo = undefined
                 bar = foo * 2 in
    undefined

相对的,经常让 do 悬挂在一行的结尾,而不是放到一行的开头。


-- file: ch09/Style.hs
commonDo = do
  something <- undefined
  return ()

-- 不常见
rareDo =
  do something <- undefined
     return ()

花括号和分号,虽然合法但几乎从来不会被使用。它们本身没什么错,只是它们会让代码看上去很奇怪。它们实际上是为了让程序生成Haskell代码时不用实现布局规则,而不是为了给人用的。

-- file: ch09/Style.hs
unusualPunctuation =
    [ (x,y) | x <- [1..a], y <- [1..b] ] where {
                                           b = 7;
 a = 6 }

preferredLayout = [ (x,y) | x <- [1..a], y <- [1..b] ]
    where b = 7
          a = 6

如果等式的右侧从一个新行开始,经常相对于这个变量名或者函数名向右缩进几个空格。

-- file: ch09/Style.hs
normalIndent =
    undefined

strangeIndent =
                           undefined

缩进变量用的空格数,有时在一个单独的文件中也不同。两个,三个,或者四个空格差不多一样常见。一个单独空格也是可以的,但是看上去不容易区分,比较容易读错。

当缩进where子句的时候,最好让他们看上去一样。

-- file: ch09/Style.hs
goodWhere = take 5 lambdas
    where lambdas = []

alsoGood =
    take 5 lambdas
  where
    lambdas = []

badWhere =           -- 合法,但较丑并难于阅读
    take 5 lambdas
    where
    lambdas = []

虽然本章描述的文件查找代码很适合学习,但是它并不是非常适合实际的系统编程,因为Haskell的可移植I/O库没有给出足够的信息供我们写出有趣的复杂查询。

1.将本章的代码移植到你的系统平台的原生API,用 System.Posix 或者 System.Win32。

2. 给代码添加找出目录项拥有者的功能。使断言可以使用这项信息。

posted @ 2011-07-25 16:15  银河系漫游指南  阅读(981)  评论(0编辑  收藏  举报