自從電腦有了分層文件系統(tǒng)以來,“我知道有這個(gè)文件,但不知道它放在哪”這個(gè)問題就一直困擾著人們。1974年發(fā)布的Unix第五個(gè)版本引入的 find 命令,到今天仍在使用。查找文件的藝術(shù)已經(jīng)走過了很長一段路:伴隨現(xiàn)代操作系統(tǒng)一起不斷發(fā)展的文件索引和搜索功能。
給程序員的工具箱里添加類似 find 這樣的功能依舊非常有價(jià)值,在本章,我們將通過編寫一個(gè)Haskell庫給我們的 find 命令添加更多功能,我們將通過一些有著不同的健壯度的方法來完成這個(gè)庫。
如果你不曾用過類Unix的系統(tǒng),或者你不是個(gè)重度shell用戶,那么你很可能從未聽說過 find ,通過給定的一組目錄,它遞歸搜索每個(gè)目錄并且打印出每個(gè)匹配表達(dá)式的實(shí)體名稱。
-- 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)
單個(gè)表達(dá)式可以識(shí)別像“符合這個(gè)全局模式的名稱”,“實(shí)體是一個(gè)文件”,“當(dāng)前最后一個(gè)被修改的文件”以及其他諸如此類的表達(dá)式,通過and或or算子就可以把他們裝配起來構(gòu)成更加復(fù)雜的表達(dá)式
在投入設(shè)計(jì)我們的庫之前,先解決一些規(guī)模稍小的問題,我們第一個(gè)問題就是遞歸地列出一個(gè)目錄下面的所有內(nèi)容和它的子目錄
filter 表達(dá)式確保一個(gè)目錄的列表不含特定的目錄名(比如代表當(dāng)前目錄的 . 和上一級(jí)目錄的 .. ),如果忘記過濾這些,隨后的查找將陷入無限循環(huán)。
我們?cè)谥暗恼鹿?jié)里完成了 forM 函數(shù),它是參數(shù)顛倒后的 mapM 函數(shù)。
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]
循環(huán)體將檢查當(dāng)前實(shí)體是否為目錄,如果是,則遞歸調(diào)用 getrecuresivacontents 函數(shù)列出這個(gè)目錄(的內(nèi)容),如果否,則返回只含有當(dāng)前實(shí)體名稱一個(gè)元素的列表,不要忘記 return 函數(shù)在 Haskell 中有特殊的含義,他通過monad的類型構(gòu)造器包裝了一個(gè)值。
另一個(gè)值得注意的地方是變量 isDirectory 的使用,在命令式語言如 Python 中,我們通常用 ifos.path.isdir(path) 來表示,然而, doesDirectoryExist 函數(shù)是一個(gè)動(dòng)作,它的返回類型是 IOBool 而非 Bool ,由于 if 表達(dá)式需要一個(gè)操作值為 bool 的表達(dá)式作為條件,我們使用 <- 來從io包裝器上得到這個(gè)動(dòng)作的的 bool 返回值,這樣我們就能在 if 中使用這個(gè)干凈的無包裝的 bool 。
循環(huán)體中每一次迭代生成的結(jié)果都是名稱列表,因此 forM 的結(jié)果是 IO[[FilePath]] ,我們通過 concat 將它轉(zhuǎn)換為一個(gè)元素列表(從以列表為元素的列表轉(zhuǎn)換為不含列表元素的列表)
在 Anonymous (lambda) functions [http://book.realworldhaskell.org/read/functional-programming.html#fp.anonymous] 這部分,我們列舉了一系列不使用匿名函數(shù)的原因,然而在這里,我們將使用它作為函數(shù)體,這是匿名函數(shù)在 Haskell 中最常見的用途之一。
我們已經(jīng)在 froM 和 mapM 上看到使用函數(shù)作為參數(shù)的方式,許多循環(huán)體是程序中只出現(xiàn)一次的代碼塊。既然我們喜歡在循環(huán)中使用一個(gè)再也不會(huì)出現(xiàn)的循環(huán)體,那么為什么要給他們命名?
顯而易見,有時(shí)候我們需要在不同的循環(huán)中嵌入相同的代碼,這時(shí)候我們不應(yīng)該使用匿名函數(shù),把他們剪貼和復(fù)制進(jìn)去,而是給這些匿名函數(shù)命名來調(diào)用,這樣顯得有意義一點(diǎn)
存在兩個(gè)相同的函數(shù)看起來是有點(diǎn)奇怪,但接受參數(shù)的順序之間的差異使他們適用于不同的情況。
我們來考察下之前的例子,使用匿名函數(shù)作為循環(huán)體,如果我們使用 mapM 而非 forM ,我們將不得不把變量 properNames 放置到函數(shù)體的后邊,而為了讓代碼正確解析,我們就必須將整個(gè)匿名函數(shù)用括號(hào)包起來,或者用一個(gè)不必要的命名函數(shù)將它取代,自己嘗試下,拷貝上邊的代碼,用 mapM 代替 forM ,觀察代碼可讀性上有什么變化
相反,如果循環(huán)體是一個(gè)命名函數(shù),而且我們要循環(huán)的列表是通過一個(gè)復(fù)雜表達(dá)式計(jì)算的,我們就找到了 mapM 的應(yīng)用場(chǎng)景
這里需要遵守的代碼風(fēng)格是無論通過 mapM 和 forM 都讓你寫出干凈的代碼,如果循環(huán)體或者循環(huán)中的表達(dá)式都很短,那么用哪個(gè)都無所謂,如果循環(huán)體很短,但數(shù)據(jù)很長,使用 mapM ,如果相反,則用 forM ,如果都很長,使用 let 或者 where 讓其中一個(gè)變短,通過這樣一些實(shí)踐,不同情況下那個(gè)實(shí)現(xiàn)最好就變得顯而易見
我們可以使用 getRecursiveContents 函數(shù)作為一個(gè)內(nèi)置的簡單文件查找器的基礎(chǔ)
-- file: ch09/SimpleFinder.hs
import RecursiveContents (getRecursiveContents)
simpleFind :: (FilePath -> Bool) -> FilePath -> IO [FilePath]
simpleFind p path = do
names <- getRecursiveContents path
return (filter p names)
上文的函數(shù)通過我們?cè)谶^濾器中的謂詞來匹配 getRecursiveContents 函數(shù)返回的名字,每個(gè)通過謂詞判斷的名稱都是文件全路徑,因此如何完成一個(gè)像“查找所有擴(kuò)展名以 .c 結(jié)尾的文件”的功能?
System.FilePath 模塊包含了許多有價(jià)值的函數(shù)來幫助我們操作文件名,在這個(gè)例子中,我們使用 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"
""
下面的代碼給我們一個(gè)包括獲得路徑,獲得擴(kuò)展名,然后和.c進(jìn)行比較的簡單功能的函數(shù)實(shí)現(xiàn),
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 在工作中有一些非常刺眼的問題,第一個(gè)就是謂詞并不能準(zhǔn)確而完全的表達(dá),他只關(guān)注文件夾中的實(shí)體名稱,而無法做到辨認(rèn)這是個(gè)文件還是個(gè)目錄此類的事情,——而我們使用 simpleFind 的嘗試就是想列舉所文件和有和文件一樣擁有 .c 擴(kuò)展名的文件夾
第二個(gè)問題是在 simpleFind 中我們無法控制它遍歷文件系統(tǒng)的方式,這是顯而易見的,想想在分布式版本控制系統(tǒng)中控制下的樹狀結(jié)構(gòu)中查找一個(gè)源文件的問題吧,所有被控制的目錄都含有一個(gè) .svn 的私有文件夾,每一個(gè)包含了許多我們毫不感興趣的子文件夾和文件,簡單的過濾所有包含 .svn 的路徑遠(yuǎn)比僅僅在讀取時(shí)避免遍歷這些文件夾更加有效。例如,一個(gè)分布式源碼樹包含了45000個(gè)文件,30000個(gè)分布在1200個(gè)不同的.svn文件夾中,避免遍歷這1200個(gè)文件夾比過濾他們包含的30000個(gè)文件代價(jià)更低。
最后。 simpleFind 是嚴(yán)格的,因?yàn)樗幌盗蠭O元操作執(zhí)行構(gòu)成的動(dòng)作,如果我們有一百萬個(gè)文件要遍歷,我們需要等待很長一段時(shí)間才能得到一個(gè)包含一百萬個(gè)名字的巨大的返回值,這對(duì)用戶體驗(yàn)和資源消耗都是噩夢(mèng),我們更需要一個(gè)只有當(dāng)他們獲得結(jié)果的時(shí)才展示的結(jié)果流。
在接下來的環(huán)節(jié)里,我們將解決每個(gè)遇到的問題
我們的謂詞只關(guān)注文件名,這將一系列有趣的操作排除在外,試想下,假如我們希望列出比某個(gè)給定值更大的文件呢?
面對(duì)這個(gè)問題的第一反應(yīng)是查找 IO :我們的謂詞是 FilePath->Bool 類型,為什么不把它變成 FilePath->IOBool 類型?這將使我們所有的IO操作都成為謂詞的一部分,但這在顯而易見的好處之外引入一個(gè)潛在的問題,使用這樣一個(gè)謂詞存在各種可能的后果,比如一個(gè)有 IOa 類型返回的函數(shù)將有能力生成任何它想產(chǎn)生的結(jié)果。
讓我們?cè)陬愋拖到y(tǒng)中尋找以寫出擁有更多謂詞,更少bug的代碼,我們通過避免污染IO來堅(jiān)持?jǐn)嘌缘募兇?,這將確保他們不會(huì)產(chǎn)生任何不純的結(jié)果,同時(shí)我們給他們提供更多信息,這樣他們就可以在不必誘發(fā)潛在的危險(xiǎn)的情況下獲得需要的表達(dá)式
Haskell 的 System.Directory 模塊提供了一個(gè)盡管受限但仍然有用的關(guān)于文件元數(shù)據(jù)的集合
ghci> :m +System.Directory
我們可以通過 doesFileExist 和 doesDirectoryExist 來判斷目錄實(shí)體是目錄還是文件,但暫時(shí)還沒有更多方式來查找這些年里出現(xiàn)的紛繁復(fù)雜的其他文件類型,比如管道,硬鏈接和軟連接。
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 函數(shù)讓我們確定當(dāng)前對(duì)于文件或目錄的操作是否是合法:
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 的特殊用法,回到第一章復(fù)習(xí)一下,如果我們的權(quán)限能夠列出它的內(nèi)容,那么這個(gè)目錄就應(yīng)該是可被搜索的,而文件則永遠(yuǎn)是不可搜索的
最后, getModificationTime 告訴我們實(shí)體上次被修改的時(shí)間:
ghci> :type getModificationTime
getModificationTime :: FilePath -> IO System.Time.ClockTime
ghci> getModificationTime "."
Mon Aug 18 12:08:24 CDT 2008
如果我們像標(biāo)準(zhǔn)的Haskell代碼一樣對(duì)可移植性要求嚴(yán)格,這些函數(shù)就是我們手頭所有的一切(我們同樣可以通過黑客手段來獲得文件大小),這些已經(jīng)足夠讓我們明白所感興趣領(lǐng)域中的原則,而非讓我們浪費(fèi)寶貴的時(shí)間對(duì)著一個(gè)例子冥思苦想,如果你需要寫滿足更多需求的代碼, System.Posix 和 System.Win32 模塊提供關(guān)于當(dāng)代兩種計(jì)算平臺(tái)的更多文件元數(shù)據(jù)的細(xì)節(jié)。 Hackage 中同樣有一個(gè) unix-compat 包,提供windows下的類unix的api 。
新的富類型謂詞需要關(guān)注的數(shù)據(jù)段到底有幾個(gè)?自從我們可以通過 Permissions 來判斷實(shí)體是文件還是目錄之后,我們就不再需要獲得 doesFileExist 和 doesDirectoryExist 的結(jié)果,因此一個(gè)謂詞需要關(guān)注的輸入有四個(gè)。
-- 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
這一謂詞類型只是一個(gè)有四個(gè)參數(shù)的函數(shù)的同義詞,他將給我們節(jié)省一些鍵盤工作和屏幕空間。
注意這一返回值是 Bool 而非 IOBool ,謂詞需要保證純粹,而且不能表現(xiàn)IO,在擁有這種類型以后,我們的查找函數(shù)仍然顯得非??瞻住?/p>
-- 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 的某些細(xì)節(jié),因此現(xiàn)在暫時(shí)先跳過它。
我們無法使用 filter 來調(diào)用我們的謂詞,因?yàn)?p 的純粹代表他不能作為IO收集元數(shù)據(jù)的方式
這讓我們將目光轉(zhuǎn)移到一個(gè)并不熟悉的函數(shù) filterM 上,它的動(dòng)作就像普通的 filter 函數(shù),但在這種情況下,它在 IOmonad 操作中使用它的謂詞,進(jìn)而通過謂詞表現(xiàn)IO:
ghci> :m +Control.Monad
ghci> :type filterM
filterM :: (Monad m) => (a -> m Bool) -> [a] -> m [a]
check 謂詞是純謂詞 p 的IO功能包裝器,他執(zhí)行了所有IO發(fā)生在 p 上的可能引起負(fù)面效果的任務(wù),因此我們可以使 p 對(duì)負(fù)面效果免疫,在收集完元數(shù)據(jù)后, check 調(diào)用 p ,通過 return 語句包裝 p 的IO返回結(jié)果
即使 System.Directory 不允許我們獲得一個(gè)文件的大小,我們?nèi)钥梢允褂?System.IO 的類似接口完成這項(xiàng)任務(wù),它包含了一個(gè)名為 hFileSize 的函數(shù),這一函數(shù)返回打開文件的字節(jié)數(shù),下面是他的簡單調(diào)用實(shí)例:
-- file: ch09/BetterPredicate.hs
simpleFileSize :: FilePath -> IO Integer
simpleFileSize path = do
h <- openFile path ReadMode
size <- hFileSize h
hClose h
return size
當(dāng)這個(gè)函數(shù)工作時(shí),他還不能完全為我們所用,在 betterFind 中,我們?cè)谀夸浵碌娜魏螌?shí)體上調(diào)用 getFileSize ,如果實(shí)體不是一個(gè)文件或者大小被 Just 包裝起來,他應(yīng)當(dāng)返回一個(gè)空值,而當(dāng)實(shí)體不是文件或者沒有被打開時(shí)(可能是由于權(quán)限不夠),這個(gè)函數(shù)會(huì)拋出一個(gè)異常然后返回一個(gè)未包裝的大小。
下文是安全的用法:
-- 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)
函數(shù)體幾乎完全一致,除了 handle 語句。
我們的異常捕捉在忽略通過的異常的同時(shí)返回一個(gè)空值,函數(shù)體唯一的變化就是允許通過 Just 包裝文件大小
saferFileSize 函數(shù)現(xiàn)在有正確的類型簽名,并且不會(huì)拋出任何異常,但他扔未能完全的正常工作,存在 openFile 會(huì)成功的目錄實(shí)體,但 hFileSize 會(huì)拋出異常,這將和被稱作命名管道的狀況一起發(fā)生,這樣的異常會(huì)被捕捉,但卻從未發(fā)起調(diào)用 hClose 。
當(dāng)發(fā)現(xiàn)不再使用文件句柄,Haskell會(huì)自動(dòng)關(guān)閉它,但這只有在運(yùn)行垃圾回收時(shí)才會(huì)執(zhí)行,如果無法斷言,則延遲到下一次垃圾回收。
文件句柄是稀缺資源,稀缺性是通過操作系統(tǒng)強(qiáng)制保證的,在linux中,一個(gè)進(jìn)程只能同時(shí)擁有1024個(gè)文件句柄。
不難想象這種場(chǎng)景,程序調(diào)用了一個(gè)使用 saferFileSize 的 betterFind 函數(shù),在足夠的垃圾文件句柄被關(guān)閉之前,由于 betterFind 造成文件句柄數(shù)耗盡導(dǎo)致了程序崩潰
這是bug危害性的一方面:通過合并起來的不同的部分使得bug不易被排查,只有在 betterFind 訪問足夠多的非文件達(dá)到進(jìn)程打開文件句柄數(shù)上限的時(shí)候才會(huì)被觸發(fā),隨后在積累的垃圾文件句柄被關(guān)閉之前返回一個(gè)嘗試打開另一個(gè)文件的調(diào)用。
任何程序內(nèi)由無法獲得數(shù)據(jù)造成的后續(xù)錯(cuò)誤都會(huì)讓事情變得更糟,直到垃圾回收為止。修正這樣一個(gè)bug需要程序結(jié)構(gòu)本身支持,文件系統(tǒng)內(nèi)容,如何關(guān)閉當(dāng)前正在運(yùn)行的程序以觸發(fā)垃圾回收
這種問題在開發(fā)中很容易被檢查出來,然而當(dāng)他在上線之后出現(xiàn)(這些惡心的問題一向如此),就變得非常難以發(fā)覺
幸運(yùn)的是,我們可以很容易避開這種錯(cuò)誤,同時(shí)又能縮短我們的函數(shù)。
每當(dāng) openFile 成功之后我們就必須保證調(diào)用 hClose , Control.Exception 模塊提供了 bracket 函數(shù)來支持這個(gè)想法:
ghci> :type bracket
bracket :: IO a -> (a -> IO b) -> (a -> IO c) -> IO c
bracket 函數(shù)需要三個(gè)動(dòng)作作為參數(shù),第一個(gè)動(dòng)作需要一個(gè)資源,第二個(gè)動(dòng)作釋放這個(gè)資源,第三個(gè)動(dòng)作在這兩個(gè)中執(zhí)行,當(dāng)資源被請(qǐng)求,我們稱他為操作動(dòng)作,當(dāng)請(qǐng)求動(dòng)作成功,釋放動(dòng)作隨后總是被調(diào)用,這保證了這個(gè)資源一直能夠被釋放,對(duì)通過的每個(gè)請(qǐng)求資源文件的操作,使用和釋放動(dòng)作都是必要的。
如果一個(gè)異常發(fā)生在使用過程中, bracket 調(diào)用釋放動(dòng)作并拋出異常,如果使用動(dòng)作成功, bracket 調(diào)用釋放動(dòng)作,同時(shí)返回使用動(dòng)作返回的值。
我們現(xiàn)在可以寫一個(gè)完全安全的函數(shù)了,他將不會(huì)拋出異常,也不會(huì)積累可能在我們程序其他地方制造失敗的垃圾文件句柄數(shù)。
-- file: ch09/BetterPredicate.hs
getFileSize path = handle (\_ -> return Nothing) $
bracket (openFile path ReadMode) hClose $ \h -> do
size <- hFileSize h
return (Just size)
仔細(xì)觀察 bracket 的參數(shù),首先打開文件,并且返回文件句柄,第二步關(guān)閉句柄,第三步在句柄上調(diào)用 hFileSize 并用 just 包裝結(jié)果返回
為了這個(gè)函數(shù)的正常工作,我們需要使用 bracket 和 handle ,前者保證我們不會(huì)積累垃圾文件句柄數(shù),后者保證我們免于異常。
深入謂詞寫作的內(nèi)部,我們的謂詞將檢查大于128kb的C++源文件:
-- file: ch09/BetterPredicate.hs
myTest path _ (Just size) _ =
takeExtension path == ".cpp" && size > 131072
myTest _ _ _ _ = False
這并不是令人感到愉快的工作,斷言需要四個(gè)參數(shù),并且總是忽略其中的兩個(gè),同時(shí)需要定義兩個(gè)等式,寫一些更有意義的謂詞代碼,我們可以做的更好。
有些時(shí)候,這種庫被用作嵌入式領(lǐng)域特定語言,我們通過編寫代碼的過程中通過編程語言的本地特性來優(yōu)雅的解決一些特定問題
第一步是寫一個(gè)返回當(dāng)前函數(shù)的一個(gè)參數(shù)的函數(shù),這個(gè)從參數(shù)中抽取路徑并傳給謂詞:
-- file: ch09/BetterPredicate.hs
pathP path _ _ _ = path
如果我們不能提供類型簽名, Haskell 將給這個(gè)函數(shù)提供一個(gè)通用類型,這在隨后會(huì)導(dǎo)致一個(gè)難以理解的錯(cuò)誤信息,因此給 pathP 一個(gè)類型:
-- 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
我們已經(jīng)創(chuàng)建了一個(gè)可以用做縮寫的類型,相似的結(jié)構(gòu)函數(shù),我們的類型代詞接受一個(gè)類型參數(shù),如此我們可以分辨不同的結(jié)果類型:
-- file: ch09/BetterPredicate.hs
sizeP :: InfoP Integer
sizeP _ _ (Just size) _ = size
sizeP _ _ Nothing _ = -1
我們?cè)谶@里做了些小動(dòng)作,對(duì)那些我們無法打開的文件或者不是文件的東西我們返回的實(shí)體大小是 -1 。
事實(shí)上,瀏覽中可以看出我們?cè)诒菊麻_始處定義謂詞類型的和 InfoPBool 一樣,因此我們可以合法的放棄謂詞類型。
pathP 和 sizeP 的用法?通過一些線索,我們發(fā)現(xiàn)可以在一個(gè)謂詞中使用它們(每個(gè)名稱中的前綴p代表斷言),從這開始事情就變得有趣起來:
-- 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 的類型簽名值得注意,他接受一個(gè) InfoPa ,同時(shí)兼容 pathP 和 sizeP ,他接受一個(gè) a ,并返回一個(gè)被認(rèn)為是謂詞同義詞的 InfoPBool ,換言之, equalP 構(gòu)造了一個(gè)謂詞。
equalP 函數(shù)通過返回一個(gè)匿名函數(shù)工作,謂詞接受參數(shù)之后將他們轉(zhuǎn)成 f ,并將結(jié)果和 f 進(jìn)行比對(duì)。
equalP 的相等強(qiáng)調(diào)了這一事實(shí),我們認(rèn)為它需要兩個(gè)參數(shù),在 Haskell 柯里化處理了所有函數(shù)的情況下,通過這種方式寫 equalP 并無必要,我們可以避免匿名函數(shù),同時(shí)通過柯里化來寫出表現(xiàn)相同的函數(shù):
-- 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
在繼續(xù)我們的探險(xiǎn)之前,先把寫好的模塊加載到 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.
讓我們來看看函數(shù)中的簡單謂詞能否正常工作:
ghci> :type betterFind (sizeP `equalP` 1024)
betterFind (sizeP `equalP` 1024) :: FilePath -> IO [FilePath]
注意我們并沒有直接調(diào)用 betterFind ,我們只是確定我們的表達(dá)式進(jìn)行了類型檢查,現(xiàn)在我們需要更多的方法來列出大小為特定值的所有文件,之前的成功給了我們繼續(xù)下去的勇氣。
除了 equalP ,我們還將能夠編寫其他二進(jìn)制函數(shù),我們更希望不去寫他們每個(gè)的具體實(shí)現(xiàn),因?yàn)檫@看起來只是重復(fù)工作:
-- 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 (<)
為了完成這個(gè),讓我們使用 Haskell 的抽象功能,定義 equalP 代替直接調(diào)用 == ,我們就可以把二進(jìn)制函數(shù)作為參數(shù)傳入我們想調(diào)用的函數(shù)。
函數(shù)動(dòng)作,比如 > ,以及將它轉(zhuǎn)換成另一個(gè)函數(shù)操作另一種上下文,在這里是 greaterP ,通過提升(lifting)將它引入到上下文,這解釋了當(dāng)前函數(shù)名稱中l(wèi)ifting出現(xiàn)的原因,提升讓我們復(fù)用代碼并降低模板的使用,在本書的后半部分的內(nèi)容中,我們將大量使用這一技術(shù)
當(dāng)我們提升一個(gè)函數(shù),我們通常將它轉(zhuǎn)換到原始類型和一個(gè)新版本——提升和未提升兩個(gè)版本
在這里,將 q 作為 liftP 的第一個(gè)參數(shù)是經(jīng)過深思熟慮的,這使得我們可能寫一個(gè)對(duì) greaterP 和 lesserP 都有意義的定義,實(shí)踐中發(fā)現(xiàn),相較其他語言,Haskell 中參數(shù)的最佳適配成為api設(shè)計(jì)中最重要的一部分。語言內(nèi)部要求參數(shù)轉(zhuǎn)換,在Haskell中放錯(cuò)一個(gè)參數(shù)的位置就將失去程序的所有意義。
我們可以通過組合字(combinators)恢復(fù)一些意義,比如,直到2007年 forM 才加入 Control.Monad 模塊,在此之前,人們用的是 flipmapM 。
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
現(xiàn)在我們知道了提升,他成為通過提升存在的布爾操作來削減代碼量的更自然的選擇。
-- 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 ,事實(shí)上,這更加通用,因?yàn)槲覀兛梢杂?liftP 代替 liftP2 :
-- 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中,我們更希望函數(shù)的傳入?yún)?shù)和返回值都是函數(shù),就像組合子一樣
回到之前定義的 myTest 函數(shù),現(xiàn)在我們可以使用一些幫助函數(shù)了。
-- file: ch09/BetterPredicate.hs
myTest path _ (Just size) _ =
takeExtension path == ".cpp" && size > 131072
myTest _ _ _ _ = False
在加入組合字以后這個(gè)函數(shù)會(huì)變成什么樣子:
-- file: ch09/BetterPredicate.hs
liftPath :: (FilePath -> a) -> InfoP a
liftPath f w _ _ _ = f w
myTest2 = (liftPath takeExtension `equalP` ".cpp") `andP`
(sizeP `greaterP` 131072)
由于操作文件名是如此平常的行為,我們加入了最終組合字 liftPath 。
可以通過特定領(lǐng)域語言定義新的操作:
-- file: ch09/BetterPredicate.hs
(==?) = equalP
(&&?) = andP
(>?) = greaterP
myTest3 = (liftPath takeExtension ==? ".cpp") &&? (sizeP >? 131072)
這個(gè)括號(hào)在定義中是必要的,因?yàn)椴⑽锤嬖VHaskell有關(guān)之前和相關(guān)的操作,領(lǐng)域語言的操作如果沒有邊界(fixities)聲明將會(huì)被以 infixl9 之類的東西對(duì)待,計(jì)算從左到右,如果跳過這個(gè)括號(hào),表達(dá)式將被解析成具有可怕錯(cuò)誤的 (((liftPathtakeExtension)==?".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 >
學(xué)會(huì)這些就可以寫一個(gè)不用括號(hào)的表達(dá)式,卻和 myTest3 的解析結(jié)果一致的表達(dá)式了
遍歷文件系統(tǒng)時(shí),我們喜歡在需要遍歷的文件夾上有更多的控制權(quán),簡便方法之一是可以在函數(shù)中允許給定文件夾的部分子文件夾通過,然后返回另一個(gè)列表,這個(gè)列表可以移除元素,也可以要求和原始列表不同,或兩者皆有,最簡單的控制函數(shù)就是id,原樣返回未修改的列表。
為了應(yīng)付多種情況,我們正在嘗試改變部分表達(dá),為了替代精心刻畫的函數(shù)類型 InfoP ,我們將使用一個(gè)普通代數(shù)數(shù)據(jù)類型來表達(dá)相同的含義
-- 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
記錄語法給我們自由控制函數(shù)的權(quán)限,如 infoPath , traverse 函數(shù)中的這種類型是簡單地,正如我們之前期望的那樣,如果需要一個(gè)文件或者目錄的信息,就調(diào)用 getInfo 函數(shù):
-- 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
現(xiàn)在不再引入新技術(shù),這就是我們遇到的最深?yuàn)W的函數(shù)定義,一行行的深入他,解釋它每行為何是這樣,不過開始部分的那幾行沒什么神秘的,它們只是之前看到代碼的拷貝。
觀察變量 contents 的時(shí)候情況變得有趣起來,從左到右仔細(xì)閱讀,已經(jīng)知道 names 是目錄實(shí)體的列表,同時(shí)確定當(dāng)前目錄的所有元素都在這個(gè)列表中,這時(shí)通過 mapM 將 getInfo 附加到結(jié)果返回的路徑上。
接下來的這一行更深?yuàn)W,繼續(xù)從左往右看,我們看到本行的最后一個(gè)元素以一個(gè)匿名函數(shù)的定義開始,并持續(xù)到這一段的結(jié)尾,給定一個(gè)Info值,函數(shù)或者遞歸訪問一個(gè)目錄(有額外的方法保證我們不在訪問這個(gè)路徑),或者返回當(dāng)前值作為列表唯一元素的列表(來匹配遞歸的返回類型)。
函數(shù)通過 forM 獲得 order 返回 info 列表中的每個(gè)元素, forM 是使用者提供的遞歸控制函數(shù)。
本行的新上下文中使用提升技術(shù), liftM 函數(shù)需要一個(gè)規(guī)則函數(shù), concat ,并且提升到 IO 的monad操作,換言之,他需要 forM 通過 IO monad 操作的的返回值,并將 concat 附加其上(獲得一個(gè) [Info] 類型的返回值,這也是我們所需要的)并將結(jié)果值返回給 IO monad 。
最后不要忘記定義 getInfo 函數(shù):
-- 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)
在此唯一值得記錄的事情是一個(gè)有用的組合字, maybeIO ,將一個(gè)可能拋出異常的 IO 操作轉(zhuǎn)換成用 Maybe 包裝的結(jié)果
traverse 這樣深度的代碼在Haskell中并不多見,在這種表達(dá)方式中里學(xué)習(xí)的收獲是巨大的,同時(shí)也并不需要大量的練習(xí)才能以這種方式流利的閱讀和寫作代碼:
-- 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]
作為對(duì)比,這里有一個(gè)不那么復(fù)雜的代碼,這也許適合一個(gè)對(duì)Haskell了解不那么深入的程序員
這里所做的一切都是創(chuàng)建一個(gè)新的替代,通過部分應(yīng)用(partial application)和函數(shù)組合(function composition)替代liberally,在 where 塊中我們已經(jīng)定義了一些本地函數(shù),在 maybe 組合子中,使用了 case 表達(dá)式,為了替代 liftM ,我們手動(dòng)將 concat 提升。
并不是說深度是一個(gè)不好的特征, traverse 函數(shù)的每一行原始代碼都很短,我們引入一個(gè)本地變量和本地函數(shù)來保證代碼干凈且足夠短,命名注意可讀性,同時(shí)使用函數(shù)組合管道,最長的管道只含有三個(gè)元素。
編寫可維護(hù)的Haskell代碼核心是找到深度和可讀性的折中,能否做到這點(diǎn)取決于你的實(shí)踐層次:
相比原始的 betterFind 函數(shù),迭代函數(shù)給我們更多控制權(quán)的同時(shí)仍存在一個(gè)問題,我們可以避免遞歸目錄,但我們不能過濾其他文件名直到我們獲得整個(gè)名稱樹,如果遞歸含有100000個(gè)文件的目錄的同時(shí)只關(guān)注其中三個(gè),在獲得這三個(gè)需要的文件名之前需要給出一個(gè)含有10000個(gè)元素的表。
一個(gè)可能的方法是提供一個(gè)過濾器作為遞歸的新參數(shù),我們將它應(yīng)用到生成的名單中,這將允許我們獲得一個(gè)只包含我們需要元素的列表
然而,這個(gè)方法也存在缺點(diǎn),假如說我們知道需要比三個(gè)多很多的實(shí)體組,并且這些實(shí)體組是這10000個(gè)我們需要遍歷實(shí)體中的前幾個(gè),這種情況下就不需要訪問剩下的實(shí)體,這并不是個(gè)故弄玄虛的問題,舉個(gè)栗子,郵箱文件夾中存放了包含許多郵件信息的文件夾——就像一個(gè)有大量文件的目錄,那么代表郵箱的目錄含有數(shù)千個(gè)文件就很正常。
從另一個(gè)角度看,我們嘗試定位之前兩個(gè)遍歷函數(shù)的弱點(diǎn):我們?nèi)绾慰创募到y(tǒng)遍歷階級(jí)目錄下的一個(gè)文件夾?
相似的文件夾, foldr 和 foldl' ,干凈的生成遍歷并計(jì)算出一個(gè)結(jié)果,很難把這個(gè)想法從列表擴(kuò)展到目錄樹,但我們?nèi)詷酚谠?fold 中加入一個(gè)控制元素,我們將這個(gè)控制表達(dá)為一個(gè)代數(shù)數(shù)據(jù)類型:
-- 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 類型給函數(shù)一個(gè)便于使用的別名,它需要一個(gè)種子和一個(gè) info 值來表達(dá)這個(gè)目錄實(shí)體,并返回一個(gè)新種子和一個(gè)我們 fold 函數(shù)的說明,這個(gè)說明通過 Iterate 類型的構(gòu)造器來表達(dá):
目錄邏輯上是左序的,因?yàn)槲覀冮_始從我們第一個(gè)遇到的實(shí)體開始 fold 操作,而每步中的種子是之前一步的結(jié)果。
-- 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)
這部分代碼中有意思的部分很少,開始是通過 scoping 避免通過額外的參數(shù),最高層 foldTree 函數(shù)只是 fold 的包裝器,用來揭開 fold 的最后結(jié)果的生成器。
由于 fold 是本地函數(shù),我們不需要通過 foldTree 的 iter 變量來進(jìn)入他,可以從外部進(jìn)入,相似的, walk 也可以在外部看到 path 。
另一個(gè)需要指出的點(diǎn)是 walk 是一個(gè)尾遞歸,在我們最初的函數(shù)中用來替代一個(gè)匿名函數(shù)調(diào)用。通過外部控制,可以在任何需要的時(shí)候停止,這使得當(dāng) iterator 返回 Done 的時(shí)候就可以退出。
即使 fold 調(diào)用 walk , walk 調(diào)用 fold 這樣的遞歸來遍歷子目錄,每個(gè)函數(shù)返回一個(gè)用 Iterate 包裝起來的種子,當(dāng) fold 被調(diào)用,并且返回, walk 檢查返回并觀察需要繼續(xù)還是退出,通過這種方式,一個(gè) Done 的返回直接終止兩個(gè)函數(shù)中的所有遞歸調(diào)用。
實(shí)踐中一個(gè) iterator 像什么,下面是一個(gè)觀察三個(gè)位圖文件(至多)的同時(shí)并不逆向遞歸元數(shù)據(jù)目錄的復(fù)雜例子:
-- 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
為了使用這個(gè)需要調(diào)用 foldTreeatMostThreePictures[] ,它給我們一個(gè) IO[FilePath] 類型的返回值。
當(dāng)然, iterators 并不需要如此復(fù)雜,下面是個(gè)對(duì)目錄進(jìn)行計(jì)數(shù)的代碼:
-- file: ch09/FoldDir.hs
countDirectories count info =
Continue (if isDirectory info
then count + 1
else count)
傳遞給 foldTree 的初始種子(seed)為數(shù)字零。
有許多好的Haskell程序員的習(xí)慣來自經(jīng)驗(yàn),我們有一些通用的經(jīng)驗(yàn)給你,這樣你可以更快的寫出易于閱讀的代碼。
正如已經(jīng)提到的,Haskell中永遠(yuǎn)使用空格,而不是tab 。
如果你發(fā)現(xiàn)代碼里有個(gè)片段聰明到炸裂,停下來,然后思考下如果你離開代碼一個(gè)月是否還能懂這段代碼。
常規(guī)命名類型和變量一般是駱駝法,例如 myVariableName ,這種風(fēng)格在Haskell中也同樣流行,不要去想你的其他命名習(xí)慣,如果你遵循一個(gè)不標(biāo)準(zhǔn)的慣例,那么你的代碼將會(huì)對(duì)其他人的眼睛造成折磨。
即使你已經(jīng)用了Haskell一段時(shí)間,在你寫小函數(shù)之前花費(fèi)幾分鐘的時(shí)間查閱庫函數(shù),如果標(biāo)準(zhǔn)庫并沒有提供你需要的函數(shù),你可能需要組合出一個(gè)新的函數(shù)來獲得你想要的結(jié)果。
組合函數(shù)的長管道難以閱讀,長意味著包含三個(gè)以上元素的序列,如果你有這樣一個(gè)管道,使用 let 或者 where 語句塊將它分解成若干個(gè)小部分,給每個(gè)管道元素一個(gè)有意義的名字,然后再將他們回填到代碼,如果你想不出一個(gè)有意義的名字,問下自己 能不能解釋這段代碼的功能,如果不能,簡化你的代碼。
即使在編輯器中很容易格式化長于八十列的代碼,寬度仍然是個(gè)重要問題,寬行在80行之外的內(nèi)容通常會(huì)被截?cái)?,這非常傷害可讀性,每一行不超過八十個(gè)字符,這樣你就可以寫入單獨(dú)的一行,這幫助你保持每一行代碼不那么復(fù)雜,從而更容易被人讀懂。
只要你的代碼遵守布局規(guī)范,那么他并不會(huì)給人一團(tuán)亂麻的感覺,因此也不會(huì)造成誤解,也就是說,有些布局風(fēng)格是常用的。
in 關(guān)鍵字通常正對(duì)著 let 關(guān)鍵字,如下所示:
-- file: ch09/Style.hs
tidyLet = let foo = undefinedwei's
bar = foo * 2
in undefined
單獨(dú)列出 in 或者讓 in 在一系列等式之后跟著的寫法都是正確的,但下面這種寫法則會(huì)顯得很奇怪:
-- file: ch09/Style.hs
weirdLet = let foo = undefined
bar = foo * 2
in undefined
strangeLet = let foo = undefined
bar = foo * 2 in
undefined
與此相反,讓 do 在行尾跟著而非在行首單獨(dú)列出:
-- file: ch09/Style.hs
commonDo = do
something <- undefined
return ()
-- not seen very often
rareDo =
do something <- undefined
return ()
括號(hào)和分號(hào)即使合法也很少用到,他們的使用并不存在問題,只是讓代碼看起來奇怪,同時(shí)讓Haskell寫成的代碼不必遵守排版規(guī)則。
-- 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
如果等式的右側(cè)另起一行,通常在和他本行內(nèi),相關(guān)變量名或者函數(shù)定義的下方之前留出一些空格。
-- file: ch09/Style.hs
normalIndent =
undefined
strangeIndent =
undefined
空格縮進(jìn)的數(shù)量有多種選擇,有時(shí)候在一個(gè)文件中,二,三,四格縮進(jìn)都很正常,一個(gè)縮進(jìn)也合法,但不常用,而且容易被誤讀。
寫 where 語句的縮進(jìn)時(shí),最好讓它分辨起來比較容易:
-- file: ch09/Style.hs
goodWhere = take 5 lambdas
where lambdas = []
alsoGood =
take 5 lambdas
where
lambdas = []
badWhere = -- legal, but ugly and hard to read
take 5 lambdas
where
lambdas = []
即使本章內(nèi)容指導(dǎo)你們完成文件查找代碼,但這并不意味著真正的系統(tǒng)編程,因?yàn)閔askell移植的 IO 庫并不暴露足夠的消息給我們寫有趣和復(fù)雜的查詢。
更多建議: