第九章 Haskell輸入與輸出

2022-08-08 14:26 更新

輸入與輸出

我們已經(jīng)說明了 Haskell 是一個純粹函數(shù)式語言。雖說在命令式語言中我們習(xí)慣給電腦執(zhí)行一連串指令,在函數(shù)式語言中我們是用定義東西的方式進(jìn)行。在 Haskell 中,一個函數(shù)不能改變狀態(tài),像是改變一個變量的內(nèi)容。(當(dāng)一個函數(shù)會改變狀態(tài),我們說這函數(shù)是有副作用的。)在 Haskell 中函數(shù)唯一可以做的事是根據(jù)我們給定的參數(shù)來算出結(jié)果。如果我們用同樣的參數(shù)調(diào)用兩次同一個函數(shù),它會回傳相同的結(jié)果。盡管這從命令式語言的角度來看是蠻大的限制,我們已經(jīng)看過它可以達(dá)成多么酷的效果。在一個命令式語言中,編程語言沒辦法給你任何保證在一個簡單如打印出幾個數(shù)字的函數(shù)不會同時燒掉你的房子,綁架你的狗并刮傷你車子的烤漆。例如,當(dāng)我們要建立一棵二元樹的時候,我們并不插入一個節(jié)點(diǎn)來改變原有的樹。由于我們無法改變狀態(tài),我們的函數(shù)實(shí)際上回傳了一棵新的二元樹。

函數(shù)無法改變狀態(tài)的好處是它讓我們促進(jìn)了我們理解程序的容易度,但同時也造成了一個問題。假如說一個函數(shù)無法改變現(xiàn)實(shí)世界的狀態(tài),那它要如何打印出它所計算的結(jié)果?畢竟要告訴我們結(jié)果的話,它必須要改變輸出設(shè)備的狀態(tài)(譬如說屏幕),然后從屏幕傳達(dá)到我們的腦,并改變我們心智的狀態(tài)。

不要太早下結(jié)論,Haskell 實(shí)際上設(shè)計了一個非常聰明的系統(tǒng)來處理有副作用的函數(shù),它漂亮地將我們的程序區(qū)分成純粹跟非純粹兩部分。非純粹的部分負(fù)責(zé)跟鍵盤還有屏幕溝通。有了這區(qū)分的機(jī)制,在跟外界溝通的同時,我們還是能夠有效運(yùn)用純粹所帶來的好處,像是惰性求值、容錯性跟模塊性。

Hello, world!

到目前為止我們都是將函數(shù)加載 GHCi 中來測試,像是標(biāo)準(zhǔn)函式庫中的一些函式。但現(xiàn)在我們要做些不一樣的,寫一個真實(shí)跟世界交互的 Haskell 程序。當(dāng)然不例外,我們會來寫個 "hello world"。

現(xiàn)在,我們把下一行打到你熟悉的編輯器中

main = putStrLn "hello, world"

我們定義了一個 main,并在里面以 "hello, world" 為參數(shù)調(diào)用了 putStrLn??雌饋頉]什么大不了,但不久你就會發(fā)現(xiàn)它的奧妙。把這程序存成 helloworld.hs。

現(xiàn)在我們將做一件之前沒做過的事:編譯你的程序。打開你的終端并切換到包含 helloworld.hs 的目錄,并輸入下列指令。

$ ghc --make helloworld
[1 of 1] Compiling Main                 ( helloworld.hs, hellowowlrd.o )
Linking helloworld ...

順利的話你就會得到如上的消息,接著你便可以執(zhí)行你的程序 ./helloworld

$ ./helloworld
hello, world

這就是我們第一個編譯成功并打印出字串到屏幕的程序。很簡單吧。

讓我們來看一下我們究竟做了些什么,首先來看一下 putStrLn 函數(shù)的型態(tài):

ghci> :t putStrLn
putStrLn :: String -> IO ()
ghci> :t putStrLn "hello, world"
putStrLn "hello, world" :: IO ()

我們可以這么解讀 putStrLn 的型態(tài):putStrLn 接受一個字串并回傳一個 I/O action,這 I/O action 包含了 () 的型態(tài)。(即空的 tuple,或者是 unit 型態(tài))。一個 I/O action 是一個會造成副作用的動作,常是指讀取輸入或輸出到屏幕,同時也代表會回傳某些值。在屏幕打印出幾個字串并沒有什么有意義的回傳值可言,所以這邊用一個 () 來代表。

那究竟 I/O action 會在什么時候被觸發(fā)呢?這就是 main 的功用所在。一個 I/O action 會在我們把它綁定到 main 這個名字并且執(zhí)行程序的時候觸發(fā)。

把整個程序限制在只能有一個 I/O action 看似是個極大的限制。這就是為什么我們需要 do 表示法來將所有 I/O action 綁成一個。來看看下面這個例子。

main = do
    putStrLn "Hello, what's your name?"
    name <- getLine
    putStrLn ("Hey " ++ name ++ ", you rock!")

新的語法,有趣吧!它看起來就像一個命令式的程序。如果你編譯并執(zhí)行它,它便會照你預(yù)期的方式執(zhí)行。我們寫了一個 do 并且接著一連串指令,就像寫個命令式程序一般,每一步都是一個 I/O action。將所有 I/O action 用 do 綁在一起變成了一個大的 I/O action。這個大的 I/O action 的型態(tài)是 IO (),這完全是由最后一個 I/O action 所決定的。

這就是為什么 main 的型態(tài)永遠(yuǎn)都是 main :: IO something,其中 something 是某個具體的型態(tài)。按照慣例,我們通常不會把 main 的型態(tài)在程序中寫出來。

另一個有趣的事情是第三行 name <- getLine。它看起來像是從輸入讀取一行并存到一個變量 name 之中。真的是這樣嗎?我們來看看 getLine 的型態(tài)吧

ghci> :t getLine
getLine :: IO String

我們可以看到 getLine 是一個回傳 String 的 I/O action。因?yàn)樗鼤扔脩糨斎肽承┳执?,這很合理。那 name <- getLine 又是如何?你能這樣解讀它:執(zhí)行一個 I/O action getLine 并將它的結(jié)果綁定到 name 這個名字。getLine 的型態(tài)是 IO String,所以 name 的型態(tài)會是 String。你能把 I/O action 想成是一個長了腳的盒子,它會跑到真實(shí)世界中替你做某些事,像是在墻壁上涂鴉,然后帶回來某些數(shù)據(jù)。一旦它帶了某些數(shù)據(jù)給你,打開盒子的唯一辦法就是用 <-。而且如果我們要從 I/O action 拿出某些數(shù)據(jù),就一定同時要在另一個 I/O action 中。這就是 Haskell 如何漂亮地分開純粹跟不純粹的程序的方法。getLine 在這樣的意義下是不純粹的,因?yàn)閳?zhí)行兩次的時候它沒辦法保證會回傳一樣的值。這也是為什么它需要在一個 IO 的型態(tài)建構(gòu)子中,那樣我們才能在 I/O action 中取出數(shù)據(jù)。而且任何一段程序一旦依賴著 I/O 數(shù)據(jù)的話,那段程序也會被視為 I/O code。

但這不表示我們不能在純粹的代碼中使用 I/O action 回傳的數(shù)據(jù)。只要我們綁定它到一個名字,我們便可以暫時地使用它。像在 name <- getLine 中 name 不過是一個普通字串,代表在盒子中的內(nèi)容。我們能將這個普通的字串傳給一個極度復(fù)雜的函數(shù),并回傳你一生會有多少財富。像是這樣:

main = do
    putStrLn "Hello, what's your name?"
    name <- getLine
    putStrLn $ "Read this carefully, because this is your future: " ++ tellFortune name

tellFortune 并不知道任何 I/O 有關(guān)的事,它的型態(tài)只不過是 String -> String。

再來看看這段代碼吧,他是合法的嗎?

nameTag = "Hello, my name is " ++ getLine

如果你回答不是,恭喜你。如果你說是,你答錯了。這么做不對的理由是 ++ 要求兩個參數(shù)都必須是串列。他左邊的參數(shù)是 String,也就是 [Char]。然而 getLine 的型態(tài)是 IO String。你不能串接一個字串跟 I/O action。我們必須先把 String 的值從 I/O action 中取出,而唯一可行的方法就是在 I/O action 中使用 name <- getLine。如果我們需要處理一些非純粹的數(shù)據(jù),那我們就要在非純粹的環(huán)境中做。所以我們最好把 I/O 的部分縮減到最小的比例。

每個 I/O action 都有一個值封裝在里面。這也是為什么我們之前的程序可以這么寫:

main = do
    foo <- putStrLn "Hello, what's your name?"
    name <- getLine
    putStrLn ("Hey " ++ name ++ ", you rock!")

然而,foo 只會有一個 () 的值,所以綁定到 foo 這個名字似乎是多余的。另外注意到我們并沒有綁定最后一行的 putStrLn 給任何名字。那是因?yàn)樵谝粋€ do block 中,最后一個 action 不能綁定任何名字。我們在之后講解 Monad 的時候會說明為什么?,F(xiàn)在你可以先想成 do block 會自動從最后一個 action 取出值并綁定給他的結(jié)果。

除了最后一行之外,其他在 do 中沒有綁定名字的其實(shí)也可以寫成綁定的形式。所以 putStrLn "BLAH" 可以寫成 _ <- putStrLn "BLAH"。但這沒什么實(shí)際的意義,所以我們寧愿寫成 putStrLn something。

初學(xué)者有時候會想錯

    name = getLine

以為這行會讀取輸入并給他綁定一個名字叫 name 但其實(shí)只是把 getLine 這個 I/O action 指定一個名字叫 name 罷了。記住,要從一個 I/O action 中取出值,你必須要在另一個 I/O action 中將他用 <- 綁定給一個名字。

I/O actions 只會在綁定給 main 的時候或是在另一個用 do 串起來的 I/O action 才會執(zhí)行。你可以用 do 來串接 I/O actions,再用 do 來串接這些串接起來的 I/O actions。不過只有最外面的 I/O action 被指定給 main 才會觸發(fā)執(zhí)行。

喔對,其實(shí)還有另外一個情況。就是在 GHCi 中輸入一個 I/O action 并按下 Enter 鍵,那也會被執(zhí)行

ghci> putStrLn "HEEY"
HEEY

就算我們只是在 GHCi 中打幾個數(shù)字或是調(diào)用一個函數(shù),按下 Enter 就會計算它并調(diào)用 show,再用 putStrLn 將字串打印出在終端上。

還記得 let binding 嗎?如果不記得,回去溫習(xí)一下這個章節(jié)。它們的形式是 let bindings in expression,其中 bindings 是 expression 中的名字、expression 則是被運(yùn)用到這些名字的算式。我們也提到了 list comprehensions 中,in 的部份不是必需的。你能夠在 do blocks 中使用 let bindings 如同在 list comprehensions 中使用它們一樣,像這樣:

import Data.Char

main = do
    putStrLn "What's your first name?"
    firstName <- getLine
    putStrLn "What's your last name?"
    lastName <- getLine
    let bigFirstName = map toUpper firstName
        bigLastName = map toUpper lastName
    putStrLn $ "hey " ++ bigFirstName ++ " " ++ bigLastName ++ ", how are you?"

注意我們是怎么編排在 do block 中的 I/O actions,也注意到我們是怎么編排 let 跟其中的名字的,由于對齊在 Haskell 中并不會被無視,這么編排才是好的習(xí)慣。我們的程序用 map toUpper firstName 將 "John" 轉(zhuǎn)成大寫的 "JOHN",并將大寫的結(jié)果綁定到一個名字上,之后在輸出的時候參考到了這個名字。

你也許會問究竟什么時候要用 <-,什么時候用 let bindings?記住,<- 是用來運(yùn)算 I/O actions 并將他的結(jié)果綁定到名稱。而 map toUpper firstName 并不是一個 I/O action。他只是一個純粹的 expression。所以總結(jié)來說,當(dāng)你要綁定 I/O actions 的結(jié)果時用 <-,而對于純粹的 expression 使用 let bindings。對于錯誤的 let firstName = getLine,我們只不過是把 getLine 這個 I/O actions 給了一個不同的名字罷了。最后還是要用 <- 將結(jié)果取出。

現(xiàn)在我們來寫一個會一行一行不斷地讀取輸入,并將讀進(jìn)來的字反過來輸出到屏幕上的程序。程序會在輸入空白行的時候停止。

main = do
    line <- getLine
    if null line
        then return ()
        else do
            putStrLn $ reverseWords line
            main

reverseWords :: String -> String
reverseWords = unwords . map reverse . words

在分析這段程序前,你可以執(zhí)行看看來感受一下程序的運(yùn)行。

首先,我們來看一下 reverseWords。他不過是一個普通的函數(shù),假如接受了個字串 "hey there man",他會先調(diào)用 words 來產(chǎn)生一個字的串列 ["hey", "there", "man"]。然后用 reverse 來 map 整個串列,得到 ["yeh", "ereht", "nam"],接著用 unwords 來得到最終的結(jié)果 "yeh ereht nam"。這些用函數(shù)合成來簡潔的表達(dá)。如果沒有用函數(shù)合成,那就會寫成丑丑的樣子 reverseWords st = unwords (map reverse (words st))

那 main 又是怎么一回事呢?首先,我們用 getLine 從終端讀取了一行,并把這行輸入取名叫 line。然后接著一個條件式 expression。記住,在 Haskell 中 if 永遠(yuǎn)要伴隨一個 else,這樣每個 expression 才會有值。當(dāng) if 的條件是 true (也就是輸入了一個空白行),我們便執(zhí)行一個 I/O action,如果 if 的條件是 false,那 else 底下的 I/O action 被執(zhí)行。這也就是說當(dāng) if 在一個 I/O do block 中的時候,長的樣子是 if condition then I/O action else I/O action。

我們首先來看一下在 else 中發(fā)生了什么事。由于我們在 else 中只能有一個 I/O action,所以我們用 do 來將兩個 I/O actions 綁成一個,你可以寫成這樣:

else (do
    putStrLn $ reverseWords line
    main)

這樣可以明顯看到整個 do block 可以看作一個 I/O action,只是比較丑。但總之,在 do block 里面,我們依序調(diào)用了 getLine 以及 reverseWords,在那之后,我們遞歸調(diào)用了 main。由于 main 也是一個 I/O action,所以這不會造成任何問題。調(diào)用 main 也就代表我們回到程序的起點(diǎn)。

那假如 null line 的結(jié)果是 true 呢?也就是說 then 的區(qū)塊被執(zhí)行。我們看一下區(qū)塊里面有 then return ()。如果你是從 C、Java 或 Python 過來的,你可能會認(rèn)為 return 不過是作一樣的事情便跳過這一段。但很重要的: return 在 Hakell 里面的意義跟其他語言的 return 完全不同!他們有相同的樣貌,造成了許多人搞錯,但確實(shí)他們是不一樣的。在命令式語言中,return 通常結(jié)束 method 或 subroutine 的執(zhí)行,并且回傳某個值給調(diào)用者。在 Haskell 中,他的意義則是利用某個 pure value 造出 I/O action。用之前盒子的比喻來說,就是將一個 value 裝進(jìn)箱子里面。產(chǎn)生出的 I/O action 并沒有作任何事,只不過將 value 包起來而已。所以在 I/O 的情況下來說,return "haha" 的型態(tài)是 IO String。將 pure value 包成 I/O action 有什么實(shí)質(zhì)意義呢?為什么要弄成 IO 包起來的值?這是因?yàn)槲覀円欢ㄒ?else 中擺上某些 I/O action,所以我們才用 return () 做了一個沒作什么事情的 I/O action。

在 I/O do block 中放一個 return 并不會結(jié)束執(zhí)行。像下面這個程序會執(zhí)行到底。

main = do
    return ()
    return "HAHAHA"
    line <- getLine
    return "BLAH BLAH BLAH"
    return 4
    putStrLn line

所有在程序中的 return 都是將 value 包成 I/O actions,而且由于我們沒有將他們綁定名稱,所以這些結(jié)果都被忽略。我們能用 <- 與 return 來達(dá)到綁定名稱的目的。

main = do
    a <- return "hell"
    b <- return "yeah!"
    putStrLn $ a ++ " " ++ b

可以看到 return 與 <- 作用相反。return 把 value 裝進(jìn)盒子中,而 <- 將 value 從盒子拿出來,并綁定一個名稱。不過這么做是有些多余,因?yàn)槟憧梢杂?let bindings 來綁定

main = do
    let a = "hell"
        b = "yeah"
    putStrLn $ a ++ " " ++ b

在 I/O do block 中需要 return 的原因大致上有兩個:一個是我們需要一個什么事都不做的 I/O action,或是我們不希望這個 do block 形成的 I/O action 的結(jié)果值是這個 block 中的最后一個 I/O action,我們希望有一個不同的結(jié)果值,所以我們用 return 來作一個 I/O action 包了我們想要的結(jié)果放在 do block 的最后。

在我們接下去講文件之前,讓我們來看看有哪些實(shí)用的函數(shù)可以處理 I/O。

putStr 跟 putStrLn 幾乎一模一樣,都是接受一個字串當(dāng)作參數(shù),并回傳一個 I/O action 打印出字串到終端上,只差在 putStrLn 會換行而 putStr 不會罷了。

main = do putStr "Hey, "
          putStr "I'm "
          putStrLn "Andy!"
$ runhaskell putstr_test.hs
Hey, I'm Andy!

他的 type signature 是 putStr :: String -> IO (),所以是一個包在 I/O action 中的 unit。也就是空值,沒有辦法綁定他。

putChar 接受一個字符,并回傳一個 I/O action 將他打印到終端上。

main = do putChar 't'
          putChar 'e'
          putChar 'h'
$ runhaskell putchar_test.hs
teh

putStr 實(shí)際上就是 putChar 遞歸定義出來的。putStr 的邊界條件是空字串,所以假設(shè)我們打印一個空字串,那他只是回傳一個什么都不做的 I/O action,像 return ()。如果打印的不是空字串,那就先用 putChar 打印出字串的第一個字符,然后再用 putStr 打印出字串剩下部份。

putStr :: String -> IO ()
putStr [] = return ()
putStr (x:xs) = do
    putChar x
    putStr xs

看看我們?nèi)绾卧?I/O 中使用遞歸,就像我們在 pure code 中所做的一樣。先定義一個邊界條件,然后再思考剩下如何作。

print 接受任何是 Show typeclass 的 instance 的型態(tài)的值,這代表我們知道如何用字串表示他,調(diào)用 show 來將值變成字串然后將其輸出到終端上?;旧?,他就是 putStrLn . show。首先調(diào)用 show 然后把結(jié)果喂給 putStrLn,回傳一個 I/O action 打印出我們的值。

main = do print True
          print 2
          print "haha"
          print 3.2
          print [3,4,3]
$ runhaskell print_test.hs
True
2
"haha"
3.2
[3,4,3]

就像你看到的,這是個很方便的函數(shù)。還記得我們提到 I/O actions 只有在 main 中才會被執(zhí)行以及在 GHCI 中運(yùn)算的事情嗎?當(dāng)我們用鍵盤打了些值,像 3 或 [1,2,3] 并按下 Enter,GHCI 實(shí)際上就是用了 print 來將這些值輸出到終端。

ghci> 3
3
ghci> print 3
3
ghci> map (++"!") ["hey","ho","woo"]
["hey!","ho!","woo!"]
ghci> print (map (++"!") ["hey", "ho", "woo"])
["hey!","ho!","woo!"]

當(dāng)我們需要打印出字串,我們會用 putStrLn,因?yàn)槲覀儾幌胍車幸?,但對于輸出值來說,print 才是最常用的。

getChar 是一個從輸入讀進(jìn)一個字符的 I/O action,因此他的 type signature 是 getChar :: IO Char,代表一個 I/O action 的結(jié)果是 Char。注意由于緩沖區(qū)的關(guān)系,只有當(dāng) Enter 被按下的時候才會觸發(fā)讀取字符的行為。

main = do
    c <- getChar
    if c /= ' '
        then do
            putChar c
            main
        else return ()

這程序看起來像是讀取一個字符并檢查他是否為一個空白。如果是的話便停止,如果不是的話便打印到終端上并重復(fù)之前的行為。在某種程度上來說也不能說錯,只是結(jié)果不如你預(yù)期而已。來看看結(jié)果吧。

$ runhaskell getchar_test.hs
hello sir
hello

上面的第二行是輸入。我們輸入了 hello sir 并按下了 Enter。由于緩沖區(qū)的關(guān)系,程序是在我們按了 Enter 后才執(zhí)行而不是在某個輸入字符的時候。一旦我們按下了 Enter,那他就把我們直到目前輸入的一次做完。

when 這函數(shù)可以在 Control.Monad 中找到他 (你必須 import Contorl.Monad 才能使用他)。他在一個 do block 中看起來就像一個控制流程的 statement,但實(shí)際上他的確是一個普通的函數(shù)。他接受一個 boolean 值跟一個 I/O action。如果 boolean 值是 True,便回傳我們傳給他的 I/O action。如果 boolean 值是 False,便回傳 return (),即什么都不做的 I/O action。我們接下來用 when 來改寫我們之前的程序。

import Control.Monad

main = do
    c <- getChar
    when (c /= ' ') $ do
        putChar c
        main

就像你看到的,他可以將 if something then do some I/O action else return () 這樣的模式封裝起來。

sequence 接受一串 I/O action,并回傳一個會依序執(zhí)行他們的 I/O action。運(yùn)算的結(jié)果是包在一個 I/O action 的一連串 I/O action 的運(yùn)算結(jié)果。他的 type signature 是 sequence :: [IO a] -> IO [a]

main = do
    a <- getLine
    b <- getLine
    c <- getLine
    print [a,b,c]

其實(shí)可以寫成

main = do
    rs <- sequence [getLine, getLine, getLine]
    print rs

所以 sequence [getLine, getLine, getLine] 作成了一個執(zhí)行 getLine 三次的 I/O action。如果我們對他綁定一個名字,結(jié)果便是這串結(jié)果的串列。也就是說,三個用戶輸入的東西組成的串列。

一個常見的使用方式是我們將 print 或 putStrLn 之類的函數(shù) map 到串列上。map print [1,2,3,4] 這個動作并不會產(chǎn)生一個 I/O action,而是一串 I/O action,就像是 [print 1, print 2, print 3, print 4]。如果我們將一串 I/O action 變成一個 I/O action,我們必須用 sequence

ghci> sequence (map print [1,2,3,4,5])
1
2
3
4
5
[(),(),(),(),()]

那 [(),(),(),(),()] 是怎么回事?當(dāng)我們在 GHCI 中運(yùn)算 I/O action,他會被執(zhí)行并把結(jié)果打印出來,唯一例外是結(jié)果是 () 的時候不會被打印出。這也是為什么 putStrLn "hehe" 在 GHCI 中只會打印出 hehe(因?yàn)?nbsp;putStrLn "hehe" 的結(jié)果是 ())。但當(dāng)我們使用 getLine 時,由于 getLine 的型態(tài)是 IO String,所以結(jié)果會被打印出來。

由于對一個串列 map 一個回傳 I/O action 的函數(shù),然后再 sequence 他這個動作太常用了。所以有一些函數(shù)在函式庫中 mapM 跟 mapM_。mapM 接受一個函數(shù)跟一個串列,將對串列用函數(shù) map 然后 sequence 結(jié)果。mapM_ 也作同樣的事,只是他把運(yùn)算的結(jié)果丟掉而已。在我們不關(guān)心 I/O action 結(jié)果的情況下,mapM_ 是最常被使用的。

ghci> mapM print [1,2,3]
1
2
3
[(),(),()]
ghci> mapM_ print [1,2,3]
1
2
3

forever 接受一個 I/O action 并回傳一個永遠(yuǎn)作同一件事的 I/O action。你可以在 Control.Monad 中找到他。下面的程序會不斷地要用戶輸入些東西,并把輸入的東西轉(zhuǎn)成大寫輸出到屏幕上。

import Control.Monad
import Data.Char

main = forever $ do
    putStr "Give me some input: "
    l <- getLine
    putStrLn $ map toUpper l

在 Control.Monad 中的 forM 跟 mapM 的作用一樣,只是參數(shù)的順序相反而已。第一個參數(shù)是串列,而第二個則是函數(shù)。這有什么用?在一些有趣的情況下還是有用的:

import Control.Monad

main = do
    colors <- forM [1,2,3,4] (\a -> do
        putStrLn $ "Which color do you associate with the number " ++ show a ++ "?"
        color <- getLine
        return color)
    putStrLn "The colors that you associate with 1, 2, 3 and 4 are: "
    mapM putStrLn colors

(\a -> do ...) 是接受一個數(shù)字并回傳一個 I/O action 的函數(shù)。我們必須用括號括住他,不然 lambda 會貪心 match 的策略會把最后兩個 I/O action 也算進(jìn)去。注意我們在 do block 里面 return color。我們那么作是讓 do block 的結(jié)果是我們選的顏色。實(shí)際上我們并不需那么作,因?yàn)?nbsp;getLine 已經(jīng)達(dá)到我們的目的。先 color <- getLine 再 return color 只不過是把值取出再包起來,其實(shí)是跟 getLine 效果相當(dāng)。forM 產(chǎn)生一個 I/O action,我們把結(jié)果綁定到 colors 這名稱。colors 是一個普通包含字串的串列。最后,我們用 mapM putStrLn colors 打印出所有顏色。

你可以把 forM 的意思想成將串列中的每個元素作成一個 I/O action。至于每個 I/O action 實(shí)際作什么就要看原本的元素是什么。然后,執(zhí)行這些 I/O action 并將結(jié)果綁定到某個名稱上。或是直接將結(jié)果忽略掉。

$ runhaskell from_test.hs
Which color do you associate with the number 1?
white
Which color do you associate with the number 2?
blue
Which color do you associate with the number 3?
red
Which color do you associate with the number 4?
orange
The colors that you associate with 1, 2, 3 and 4 are:
white
blue
red
orange

其實(shí)我們也不是一定要用到 forM,只是用了 forM 程序會比較容易理解。正常來講是我們需要在 map 跟 sequence 的時候定義 I/O action 的時候使用 forM,同樣地,我們也可以將最后一行寫成 forM colors putStrLn。

在這一節(jié),我們學(xué)會了輸入與輸出的基礎(chǔ)。我們也了解了什么是 I/O action,他們是如何幫助我們達(dá)成輸入與輸出的目的。這邊重復(fù)一遍,I/O action 跟其他 Haskell 中的 value 沒有兩樣。我們能夠把他當(dāng)參數(shù)傳給函式,或是函式回傳 I/O action。他們特別之處在于當(dāng)他們是寫在 main 里面或 GHCI 里面的時候,他們會被執(zhí)行,也就是實(shí)際輸出到你屏幕或輸出音效的時候。每個 I/O action 也能包著一個從真實(shí)世界拿回來的值。

不要把像是 putStrLn 的函式想成接受字串并輸出到屏幕。要想成一個函式接受字串并回傳一個 I/O action。當(dāng) I/O action 被執(zhí)行的時候,會漂亮地打印出你想要的東西。

文件與字符流

getChar 是一個讀取單一字符的 I/O action。getLine 是一個讀取一行的 I/O action。這是兩個非常直覺的函式,多數(shù)編程語言也有類似這兩個函式的 statement 或 function。但現(xiàn)在我們來看看 getContents。getContents 是一個從標(biāo)準(zhǔn)輸入讀取直到 end-of-file 字符的 I/O action。他的型態(tài)是 getContents :: IO String。最酷的是 getContents 是惰性 I/O (Lazy I/O)。當(dāng)我們寫了 foo <- getContents,他并不會馬上讀取所有輸入,將他們存在 memory 里面。他只有當(dāng)你真的需要輸入數(shù)據(jù)的時候才會讀取。

當(dāng)我們需要重導(dǎo)一個程序的輸出到另一個程序的輸入時,getContents 非常有用。假設(shè)我們有下面一個文本檔:

I'm a lil' teapot
What's with that airplane food, huh?
It's so small, tasteless

還記得我們介紹 forever 時寫的小程序嗎?會把所有輸入的東西轉(zhuǎn)成大寫的那一個。為了防止你忘記了,這邊再重復(fù)一遍。

import Control.Monad
import Data.Char

main = forever $ do
    putStr "Give me some input: "
    l <- getLine
    putStrLn $ map toUpper l

將我們的程序存成 capslocker.hs 然后編譯他。然后用 Unix 的 Pipe 將文本檔喂給我們的程序。我們使用的是 GNU 的 cat,會將指定的文件輸出到屏幕。

$ ghc --make capslocker
[1 of 1] Compiling Main             ( capslocker.hs, capslocker.o )
Linking capslocker ...
$ cat haiku.txt
I'm a lil' teapot
What's with that airplane food, huh?
It's so small, tasteless
$ cat haiku.txt | ./capslocker
I'M A LIL' TEAPOT
WHAT'S WITH THAT AIRPLANE FOOD, HUH?
IT'S SO SMALL, TASTELESS
capslocker <stdin>: hGetLine: end of file

就如你看到的,我們是用 | 這符號來將某個程序的輸出 piping 到另一個程序的輸入。我們做的事相當(dāng)于 run 我們的 capslocker,然后將 haiku 的內(nèi)容用鍵盤打到終端上,最后再按 Ctrl-D 來代表 end-of-file。這就像執(zhí)行 cat haiku.txt 后大喊,嘿,不要把內(nèi)容打印到終端上,把內(nèi)容塞到 capslocker!

我們用 forever 在做的事基本上就是將輸入經(jīng)過轉(zhuǎn)換后變成輸出。用 getContents 的話可以讓我們的程序更加精煉。

import Data.Char

main = do
    contents <- getContents
    putStr (map toUpper contents)

我們將 getContents 取回的字串綁定到 contents。然后用 toUpper map 到整個字串后打印到終端上。記住字串基本上就是一串惰性的串列 (list),同時 getContents 也是惰性 I/O,他不會一口氣讀入內(nèi)容然后將內(nèi)容存在內(nèi)存中。實(shí)際上,他會一行一行讀入并輸出大寫的版本,這是因?yàn)檩敵霾攀钦娴男枰斎氲臄?shù)據(jù)的時候。

$ cat haiku.txt | ./capslocker
I'M A LIL' TEAPOT
WHAT'S WITH THAT AIRPLAN FOOD, HUH?
IT'S SO SMALL, TASTELESS

很好,程序運(yùn)作正常。假如我們執(zhí)行 capslocker 然后自己打幾行字呢?

$ ./capslocker
hey ho
HEY HO
lets go
LETS GO

按下 Ctrl-D 來離開環(huán)境。就像你看到的,程序是一行一行將我們的輸入打印出來。當(dāng) getContent 的結(jié)果被綁定到 contents 的時候,他不是被表示成在內(nèi)存中的一個字串,反而比較像是他有一天會是字串的一個承諾。當(dāng)我們將 toUpper map 到 contents 的時候,便也是一個函數(shù)被承諾將會被 map 到內(nèi)容上。最后 putStr 則要求先前的承諾說,給我一行大寫的字串吧。實(shí)際上還沒有任何一行被取出,所以便跟 contents 說,不如從終端那邊取出些字串吧。這才是 getContents 真正從終端讀入一行并把這一行交給程序的時候。程序便將這一行用 toUpper 處理并交給 putStr,putStr 則打印出他。之后 putStr 再說:我需要下一行。整個步驟便再重復(fù)一次,直到讀到 end-of-file 為止。

接著我們來寫個程序,讀取輸入,并只打印出少于十個字符的行。

main = do
    contents <- getContents
    putStr (shortLinesOnly contents)

shortLinesOnly :: String -> String
shortLinesOnly input =
    let allLines = lines input
        shortLines = filter (\line -> length line < 10) allLines
        result = unlines shortLines
    in result

我們把 I/O 部份的代碼弄得很短。由于程序的行為是接某些輸入,作些處理然后輸出。我們可以把他想成讀取輸入,調(diào)用一個函數(shù),然后把函數(shù)的結(jié)果輸出。

shortLinesOnly 的行為是這樣:拿到一個字串,像是 "short\nlooooooooooooooong\nshort again"。這字串有三行,前后兩行比較短,中間一行很常。他用 lines 把字串分成 ["short", "looooooooooooooong", "short again"],并把結(jié)果綁定成 allLines。然后過濾這些字串,只有少于十個字符的留下,["short", "short again"],最后用 unlines 把這些字串用換行接起來,形成 "short\nshort again"

i'm short
so am i
i am a loooooooooong line!!!
yeah i'm long so what hahahaha!!!!!!
short line
loooooooooooooooooooooooooooong
short
$ ghc --make shortlinesonly
[1 of 1] Compiling Main             ( shortlinesonly.hs, shortlinesonly.o )
Linking shortlinesonly ...
$ cat shortlines.txt | ./shortlinesonly
i'm short
so am i
short

我們把 shortlines.txt 的內(nèi)容經(jīng)由 pipe 送給 shortlinesonly,結(jié)果就如你看到,我們只有得到比較短的行。

從輸入那一些字串,經(jīng)由一些轉(zhuǎn)換然后輸出這樣的模式實(shí)在太常用了。常用到甚至建立了一個函數(shù)叫 interact。interact 接受一個 String -> String 的函數(shù),并回傳一個 I/O action。那個 I/O action 會讀取一些輸入,調(diào)用提供的函數(shù),然后把函數(shù)的結(jié)果打印出來。所以我們的程序可以改寫成這樣。

main = interact shortLinesOnly

shortLinesOnly :: String -> String
shortLinesOnly input =
    let allLines = lines input
        shortLines = filter (\line -> length line < 10) allLines
        result = unlines shortLines
    in result

我們甚至可以再讓代碼更短一些,像這樣

main = interact $ unlines . filter ((<10) . length) . lines

看吧,我們讓程序縮到只剩一行了,很酷吧!

能應(yīng)用 interact 的情況有幾種,像是從輸入 pipe 讀進(jìn)一些內(nèi)容,然后丟出一些結(jié)果的程序;或是從用戶獲取一行一行的輸入,然后丟回根據(jù)那一行運(yùn)算的結(jié)果,再拿取另一行。這兩者的差別主要是取決于用戶使用他們的方式。

我們再來寫另一個程序,它不斷地讀取一行行并告訴我們那一行字串是不是一個回文本串 (palindrome)。我們當(dāng)然可以用 getLine 讀取一行然后再調(diào)用 main 作同樣的事。不過同樣的事情可以用 interact 更簡潔地達(dá)成。當(dāng)使用 interact 的時候,想像你是將輸入經(jīng)有某些轉(zhuǎn)換成輸出。在這個情況當(dāng)中,我們要將每一行輸入轉(zhuǎn)換成 "palindrome" 或 "not a palindrome"。所以我們必須寫一個函數(shù)將 "elephant\nABCBA\nwhatever" 轉(zhuǎn)換成 not a palindrome\npalindrome\nnot a palindrome"。來動手吧!

respondPalindromes contents = unlines (map (\xs ->
    if isPalindrome xs then "palindrome" else "not a palindrome") (lines contents))
        where isPalindrome xs = xs == reverse xs

再來將程序改寫成 point-free 的形式

respondPalindromes = unlines . map (\xs ->
    if isPalindrome xs then "palindrome" else "not a palindrome") . lines
        where isPalindrome xs = xs == reverse xs

很直覺吧!首先將 "elephant\nABCBA\nwhatever" 變成 ["elephant", "ABCBA", "whatever"] 然后將一個 lambda 函數(shù) map 它,["not a palindrome", "palindrome", "not a palindrome"] 然后用 unlines 變成一行字串。接著

main = interact respondPalindromes

來測試一下吧。

$ runhaskell palindrome.hs
hehe
not a palindrome
ABCBA
palindrome
cookie
not a palindrome

即使我們的程序是把一大把字串轉(zhuǎn)換成另一個,其實(shí)他表現(xiàn)得好像我們是一行一行做的。這是因?yàn)?Haskell 是惰性的,程序想要打印出第一行結(jié)果時,他必須要先有第一行輸入。所以一旦我們給了第一行輸入,他便打印出第一行結(jié)果。我們用 end-of-line 字符來結(jié)束程序。

我們也可以用 pipe 的方式將輸入喂給程序。假設(shè)我們有這樣一個文件。

dogaroo
radar
rotor
madam

將他存為 words.txt,將他喂給程序后得到的結(jié)果

$ cat words.txt | runhaskell palindromes.hs
not a palindrome
palindrome
palindrome
palindrome

再一次地提醒,我們得到的結(jié)果跟我們自己一個一個字打進(jìn)輸入的內(nèi)容是一樣的。我們看不到 palindrome.hs 輸入的內(nèi)容是因?yàn)閮?nèi)容來自于文件。

你應(yīng)該大致了解 Lazy I/O 是如何運(yùn)作,并能善用他的優(yōu)點(diǎn)。他可以從輸入轉(zhuǎn)換成輸出的角度方向思考。由于 Lazy I/O,沒有輸入在被用到之前是真的被讀入。

到目前為止,我們的示范都是從終端讀取某些東西或是打印出某些東西到終端。但如果我們想要讀寫文件呢?其實(shí)從某個角度來說我們已經(jīng)作過這件事了。我們可以把讀寫終端想成讀寫文件。只是把文件命名成 stdout 跟 stdin 而已。他們分別代表標(biāo)準(zhǔn)輸出跟標(biāo)準(zhǔn)輸入。我們即將看到的讀寫文件跟讀寫終端并沒什么不同。

首先來寫一個程序,他會開啟一個叫 girlfriend.txt 的文件,文件里面有 Avril Lavigne 的暢銷名曲 Girlfriend,并將內(nèi)容打印到終端上。接下來是 girlfriend.txt 的內(nèi)容。

Hey! Hey! You! You!
I don't like your girlfriend!
No way! No way!
I think you need a new one!

這則是我們的主程序。

import System.IO

main = do
    handle <- openFile "girlfriend.txt" ReadMode
    contents <- hGetContents handle
    putStr contents
    hClose handle

執(zhí)行他后得到的結(jié)果。

$ runhaskell girlfriend.hs
Hey! Hey! You! You!
I don't like your girlfriend!
No way! No way!
I think you need a new one!

我們來一行行看一下程序。我們的程序用 do 把好幾個 I/O action 綁在一起。在 do block 的第一行,我們注意到有一個新的函數(shù)叫 openFile。他的 type signature 是 openFile :: FilePath -> IOMode -> IO Handle。他說了 openFile 接受一個文件路徑跟一個 IOMode,并回傳一個 I/O action,他會打開一個文件并把文件關(guān)聯(lián)到一個 handle。

FilePath 不過是 String 的 type synonym。

type FilePath = String

IOMode 則是一個定義如下的型態(tài)

data IOMode = ReadMode | WriteMode | AppendMode | ReadWriteMode

就像我們之前定義的型態(tài),分別代表一個星期的七天。這個型態(tài)代表了我們想對打開的文件做什么。很簡單吧。留意到我們的型態(tài)是 IOMode 而不是 IO Mode。IO Mode 代表的是一個 I/O action 包含了一個型態(tài)為 Mode 的值,但 IOMode 不過是一個陽春的 enumeration。

最后,他回傳一個 I/O action 會將指定的文件用指定的模式打開。如果我們將 I/O action 綁定到某個東西,我們會得到一個 Handle。型態(tài)為 Handle 的值代表我們的文件在哪里。有了 handle 我們才知道要從哪個文件讀取內(nèi)容。想讀取文件但不將文件綁定到 handle 上這樣做是很蠢的。所以,我們將一個 handle 綁定到 handle。

接著一行,我們看到一個叫 hGetContents 的函數(shù)。他接了一個 Handle,所以他知道要從哪個文件讀取內(nèi)容并回傳一個 IO String。一個包含了文件內(nèi)容的 I/O action。這函數(shù)跟 getContents 差不多。唯一的差別是 getContents 會自動從標(biāo)準(zhǔn)輸入讀取內(nèi)容(也就是終端),而 hGetContents 接了一個 file handle,這 file handle 告訴他讀取哪個文件。除此之外,他們都是一樣的。就像 getContents,hGetContents 不會把文件一次都拉到內(nèi)存中,而是有必要才會讀取。這非???,因?yàn)槲覀儼?nbsp;contents 當(dāng)作是整個文件般用,但他實(shí)際上不在內(nèi)存中。就算這是個很大的文件,hGetContents 也不會塞爆你的內(nèi)存,而是只有必要的時候才會讀取。

要留意文件的 handle 還有文件的內(nèi)容兩個概念的差異,在我們的程序中他們分別被綁定到 handle 跟 contents 兩個名字。handle 是我們拿來區(qū)分文件的依據(jù)。如果你把整個文件系統(tǒng)想成一本厚厚的書,每個文件分別是其中的一個章節(jié),handle 就像是書簽一般標(biāo)記了你現(xiàn)在正在閱讀(或?qū)懭耄┠囊粋€章節(jié),而內(nèi)容則是章節(jié)本身。

我們使用 putStr contents 打印出內(nèi)容到標(biāo)準(zhǔn)輸出,然后我們用了 hClose。他接受一個 handle 然后回傳一個關(guān)掉文件的 I/O action。在用了 openFile 之后,你必須自己把文件關(guān)掉。

要達(dá)到我們目的的另一種方式是使用 withFile,他的 type signature 是 withFile :: FilePath -> IOMode -> (Handle -> IO a) -> IO a。他接受一個文件路徑,一個 IOMode 以及一個函數(shù),這函數(shù)則接受一個 handle 跟一個 I/O action。withFile 最后回傳一個會打開文件,對文件作某件事然后關(guān)掉文件的 I/O action。處理的結(jié)果是包在最后的 I/O action 中,這結(jié)果跟我們給的函數(shù)的回傳是相同的。這聽起來有些復(fù)雜,但其實(shí)很簡單,特別是我們有 lambda,來看看我們用 withFile 改寫前面程序的一個范例:

import System.IO

main = do
    withFile "girlfriend.txt" ReadMode (\handle -> do
            contents <- hGetContents handle
            putStr contents)

正如你看到的,程序跟之前的看起來很像。(\handle -> ... ) 是一個接受 handle 并回傳 I/O action 的函數(shù),他通常都是用 lambda 來表示。我們需要一個回傳 I/O action 的函數(shù)的理由而不是一個本身作處理并關(guān)掉文件的 I/O action,是因?yàn)檫@樣一來那個 I/O action 不會知道他是對哪個文件在做處理。用 withFile 的話,withFile 會打開文件并把 handle 傳給我們給他的函數(shù),之后他則拿到一個 I/O action,然后作成一個我們描述的 I/O action,最后關(guān)上文件。例如我們可以這樣自己作一個 withFile:

withFile' :: FilePath -> IOMode -> (Handle -> IO a) -> IO a
withFile' path mode f = do
    handle <- openFile path mode
    result <- f handle
    hClose handle
    return result

我們知道要回傳的是一個 I/O action,所以我們先放一個 do。首先我們打開文件,得到一個 handle。然后我們 apply handle 到我們的函數(shù),并得到一個做事的 I/O action。我們綁定那個 I/O action 到 result 這個名字,關(guān)上 handle 并 return result。return 的作用把從 f 得到的結(jié)果包在 I/O action 中,這樣一來 I/O action 中就包含了 f handle 得到的結(jié)果。如果 f handle 回傳一個從標(biāo)準(zhǔn)輸入讀去數(shù)行并寫到文件然后回傳讀入的行數(shù)的 I/O action,在 withFile' 的情形中,最后的 I/O action 就會包含讀入的行數(shù)。

就像 hGetContents 對應(yīng) getContents 一樣,只不過是針對某個文件。我們也有 hGetLine、hPutStr、hPutStrLn、hGetChar 等等。他們分別是少了 h 的那些函數(shù)的對應(yīng)。只不過他們要多拿一個 handle 當(dāng)參數(shù),并且是針對特定文件而不是標(biāo)準(zhǔn)輸出或標(biāo)準(zhǔn)輸入。像是 putStrLn 是一個接受一個字串并回傳一個打印出加了換行字符的字串的 I/O action 的函數(shù)。hPutStrLn 接受一個 handle 跟一個字串,回傳一個打印出加了換行字符的字串到文件的 I/O action。以此類推,hGetLine 接受一個 handle 然后回傳一個從文件讀取一行的 I/O action。

讀取文件并對他們的字串內(nèi)容作些處理實(shí)在太常見了,常見到我們有三個函數(shù)來更進(jìn)一步簡化我們的工作。

readFile 的 type signature 是 readFile :: FilePath -> IO String。記住,F(xiàn)ilePath 不過是 String 的一個別名。readFile 接受一個文件路徑,回傳一個惰性讀取我們文件的 I/O action。然后將文件的內(nèi)容綁定到某個字串。他比起先 openFile,綁定 handle,然后 hGetContents 要好用多了。這邊是一個用 readFile 改寫之前例子的范例:

import System.IO

main = do
    contents <- readFile "girlfriend.txt"
    putStr contents

由于我們拿不到 handle,所以我們也無法關(guān)掉他。這件事 Haskell 的 readFile 在背后幫我們做了。

writeFile 的型態(tài)是 writefile :: FilePath -> String -> IO ()。他接受一個文件路徑,以及一個要寫到文件中的字串,并回傳一個寫入動作的 I/O action。如果這個文件已經(jīng)存在了,他會先把文件內(nèi)容都砍了再寫入。下面示范了如何把 girlfriend.txt 的內(nèi)容轉(zhuǎn)成大寫然后寫入到 girlfriendcaps.txt 中

import System.IO
import Data.Char

main = do
    contents <- readFile "girlfriend.txt"
    writeFile "girlfriendcaps.txt" (map toUpper contents)
$ runhaskell girlfriendtocaps.hs
$ cat girlfriendcaps.txt
HEY! HEY! YOU! YOU!
I DON'T LIKE YOUR GIRLFRIEND!
NO WAY! NO WAY!
I THINK YOU NEED A NEW ONE!

appendFile 的型態(tài)很像 writeFile,只是 appendFile 并不會在文件存在時把文件內(nèi)容砍掉而是接在后面。

假設(shè)我們有一個文件叫 todo.txt``,里面每一行是一件要做的事情?,F(xiàn)在我們寫一個程序,從標(biāo)準(zhǔn)輸入接受一行將他加到我們的 to-do list 中。

import System.IO

main = do
    todoItem <- getLine
    appendFile "todo.txt" (todoItem ++ "\n")
$ runhaskell appendtodo.hs
Iron the dishes
$ runhaskell appendtodo.hs
Dust the dog
$ runhaskell appendtodo.hs
Take salad out of the oven
$ cat todo.txt
Iron the dishes
Dust the dog
Take salad out of the oven

由于 getLine 回傳的值不會有換行字符,我們需要在每一行最后加上 "\n"。

還有一件事,我們提到 contents <- hGetContents handle 是惰性 I/O,不會將文件一次都讀到內(nèi)存中。 所以像這樣寫的話:

main = do
    withFile "something.txt" ReadMode (\handle -> do
        contents <- hGetContents handle
        putStr contents)

實(shí)際上像是用一個 pipe 把文件弄到標(biāo)準(zhǔn)輸出。正如你可以把 list 想成 stream 一樣,你也可以把文件想成 stream。他會每次讀一行然后打印到終端上。你也許會問這個 pipe 究竟一次可以塞多少東西,讀去硬盤的頻率究竟是多少?對于文本檔而言,缺省的 buffer 通常是 line-buffering。這代表一次被讀進(jìn)來的大小是一行。這也是為什么在這個 case 我們是一行一行處理。對于 binary file 而言,缺省的 buffer 是 block-buffering。這代表我們是一個 chunk 一個 chunk 去讀得。而一個 chunk 的大小是根據(jù)操作系統(tǒng)不同而不同。

你能用 hSetBuffering 來控制 buffer 的行為。他接受一個 handle 跟一個 BufferMode,回傳一個會設(shè)置 buffer 行為的 I/O action。BufferMode 是一個 enumeration 型態(tài),他可能的值有:NoBuffering, LineBuffering 或 BlockBuffering (Maybe Int)。其中 Maybe Int 是表示一個 chunck 有幾個 byte。如果他的值是 Nothing,則操作系統(tǒng)會幫你決定 chunk 的大小。NoBuffering 代表我們一次讀一個 character。一般來說 NoBuffering 的表現(xiàn)很差,因?yàn)樗L問硬盤的頻率很高。

接下來是我們把之前的范例改寫成用 2048 bytes 的 chunk 讀取,而不是一行一行讀。

main = do
    withFile "something.txt" ReadMode (\handle -> do
        hSetBuffering handle $ BlockBuffering (Just 2048)
        contents <- hGetContents handle
        putStr contents)

用更大的 chunk 來讀取對于減少訪問硬盤的次數(shù)是有幫助的,特別是我們的文件其實(shí)是透過網(wǎng)絡(luò)來訪問。

我們也可以使用 hFlush,他接受一個 handle 并回傳一個會 flush buffer 到文件的 I/O action。當(dāng)我們使用 line-buffering 的時候,buffer 在每一行都會被 flush 到文件。當(dāng)我們使用 block-buffering 的時候,是在我們讀每一個 chunk 作 flush 的動作。flush 也會發(fā)生在關(guān)閉 handle 的時候。這代表當(dāng)我們碰到換行字符的時候,讀或?qū)懙膭幼鞫紩V共⒒貓笫诌叺臄?shù)據(jù)。但我們能使用 hFlush 來強(qiáng)迫回報所有已經(jīng)在 buffer 中的數(shù)據(jù)。經(jīng)過 flushing 之后,數(shù)據(jù)也就能被其他程序看見。

把 block-buffering 的讀取想成這樣:你的馬桶會在水箱有一加侖的水的時候自動沖水。所以你不斷灌水進(jìn)去直到一加侖,馬桶就會自動沖水,在水里面的數(shù)據(jù)也就會被看到。但你也可以手動地按下沖水鈕來沖水。他會讓現(xiàn)有的水被沖走。沖水這個動作就是 hFlush 這個名字的含意。

我們已經(jīng)寫了一個將 item 加進(jìn) to-do list 里面的程序,現(xiàn)在我們想加進(jìn)移除 item 的功能。我先把代碼粘貼然后講解他。我們會使用一些新面孔像是 System.Directory 以及 System.IO 里面的函數(shù)。

來看一下我們包含移除功能的程序:

import System.IO
import System.Directory
import Data.List

main = do
    handle <- openFile "todo.txt" ReadMode
    (tempName, tempHandle) <- openTempFile "." "temp"
    contents <- hGetContents handle
    let todoTasks = lines contents
    numberedTasks = zipWith (\n line -> show n ++ " - " ++ line) [0..] todoTasks
    putStrLn "These are your TO-DO items:"
    putStr $ unlines numberedTasks
    putStrLn "Which one do you want to delete?"
    numberString <- getLine
    let number = read numberString
    newTodoItems = delete (todoTasks !! number) todoTasks
    hPutStr tempHandle $ unlines newTodoItems
    hClose handle
    hClose tempHandle
    removeFile "todo.txt"
    renameFile tempName "todo.txt"

一開始,我們用 read mode 打開 todo.txt,并把他綁定到 handle。

接著,我們使用了一個之前沒用過在 System.IO 中的函數(shù) openTempFile。他的名字淺顯易懂。他接受一個暫存的文件夾跟一個樣板文件名,然后打開一個暫存盤。我們使用 "." 當(dāng)作我們的暫存文件夾,因?yàn)?nbsp;. 在幾乎任何操作系統(tǒng)中都代表了現(xiàn)在所在的文件夾。我們使用 "temp" 當(dāng)作我們暫存盤的樣板名,他代表暫存盤的名字會是 temp 接上某串隨機(jī)字串。他回傳一個創(chuàng)建暫存盤的 I/O action,然后那個 I/O action 的結(jié)果是一個 pair:暫存盤的名字跟一個 handle。我們當(dāng)然可以隨便開啟一個 todo2.txt 這種名字的文件。但使用 openTempFile 會是比較好的作法,這樣你不會不小心覆寫任何文件。

我們不用 getCurrentDirectory 的來拿到現(xiàn)在所在文件夾而用 "." 的原因是 . 在 unix-like 系統(tǒng)跟 Windows 中都表示現(xiàn)在的文件夾。

然后,我們綁定 todo.txt 的內(nèi)容成 contents。把字串?dāng)喑梢淮执?,每個字串代表一行。todoTasks 就變成 ["Iron the dishes", "Dust the dog", "Take salad out of the oven"]。我們用一個會把 3 跟 "hey" 變成 "3 - hey" 的函數(shù),然后從 0 開始把這個串列 zip 起來。所以 numberedTasks 就是 ["0 - Iron the dishes", "1 - Dust the dog" ...。我們用 unlines 把這個串列變成一行,然后打印到終端上。注意我們也有另一種作法,就是用 mapM putStrLn numberedTasks。

我們問用戶他們想要刪除哪一個并且等著他們輸入一個數(shù)字。假設(shè)他們想要刪除 1 號,那代表 Dust the dog,所以他們輸入 1。于是 numberString 就代表 "1"。由于我們想要一個數(shù)字,而不是一個字串,所以我們用對 1 使用 read,并且綁定到 number。

還記得在 Data.List 中的 delete 跟 !! 嗎?!! 回傳某個 index 的元素,而 delete 刪除在串列中第一個發(fā)現(xiàn)的元素,然后回傳一個新的沒有那個元素的串列。(todoTasks !! number)?。╪umber 代表 1) 回傳 "Dust the dog"。我們把 todoTasks 去掉第一個 "Dust the dog" 后的串列綁定到 newTodoItems,然后用 unlines 變成一行然后寫到我們所打開的暫存盤。舊有的文件并沒有變動,而暫存盤包含砍掉那一行后的所有內(nèi)容。

在我們關(guān)掉源文件跟暫存盤之后我們用 removeFile 來移除原本的文件。他接受一個文件路徑并且刪除文件。刪除舊得 todo.txt 之后,我們用 renameFile 來將暫存盤重命名成 todo.txt。特別留意 removeFile 跟 renameFile(兩個都在 System.Directory 中)接受的是文件路徑,而不是 handle。

這就是我們要的,實(shí)際上我們可以用更少行寫出同樣的程序,但我們很小心地避免覆寫任何文件,并詢問操作系統(tǒng)我們可以把暫存盤擺在哪?讓我們來執(zhí)行看看。

$ runhaskell deletetodo.hs
These are your TO-DO items:
0 - Iron the dishes
1 - Dust the dog
2 - Take salad out of the oven
Which one do you want to delete?
1

$ cat todo.txt
Iron the dishes
Take salad out of the oven

$ runhaskell deletetodo.hs
These are your TO-DO items:
0 - Iron the dishes
1 - Take salad out of the oven
Which one do you want to delete?
0

$ cat todo.txt
Take salad out of the oven

命令行引數(shù)

如果你想要寫一個在終端里運(yùn)行的程序,處理命令行引數(shù)是不可或缺的。幸運(yùn)的是,利用 Haskell 的 Standard Libary 能讓我們有效地處理命令行引數(shù)。

在之前的章節(jié)中,我們寫了一個能將 to-do item 加進(jìn)或移除 to-do list 的一個程序。但我們的寫法有兩個問題。第一個是我們把放 to-do list 的文件名稱給寫死了。我們擅自決定用戶不會有很多個 to-do lists,就把文件命名為 todo.txt。

一種解決的方法是每次都詢問用戶他們想將他們的 to-do list 放進(jìn)哪個文件。我們在用戶要刪除的時候也采用這種方式。這是一種可以運(yùn)作的方式,但不太能被接受,因?yàn)樗枰脩暨\(yùn)行程序,等待程序詢問才能回答。這被稱為交互式的程序,但討厭的地方在當(dāng)你想要自動化執(zhí)行程序的時候,好比說寫成 script,這會讓你的 script 寫起來比較困難。

這也是為什么有時候讓用戶在執(zhí)行的時候就告訴程序他們要什么會比較好,而不是讓程序去問用戶要什么。比較好的方式是讓用戶透過命令行引數(shù)告訴程序他們想要什么。

在 System.Environment 模塊當(dāng)中有兩個很酷的 I/O actions,一個是 getArgs,他的 type 是 getArgs :: IO [String],他是一個拿取命令行引數(shù)的 I/O action,并把結(jié)果放在包含的一個串列中。getProgName 的型態(tài)是 getProgName :: IO String,他則是一個 I/O action 包含了程序的名稱。

我們來看一個展現(xiàn)他們功能的程序。

import System.Environment
import Data.List

main = do
    args <- getArgs
    progName <- getProgName
    putStrLn "The arguments are:"
    mapM putStrLn args
    putStrLn "The program name is:"
    putStrLn progName

我們將 getArgs 跟 progName 分別綁定到 args 跟 progName。我們打印出 The arguments are: 以及在 args 中的每個引數(shù)。最后,我們打印出程序的名字。我們把程序編譯成 arg-test。

$ ./arg-test first second w00t "multi word arg"
The arguments are:
first
second
w00t
multi word arg
The program name is:
arg-test

知道了這些函數(shù)現(xiàn)在你能寫幾個很酷的命令行程序。在之前的章節(jié),我們寫了一個程序來加入待作事項(xiàng),也寫了另一個程序刪除事項(xiàng)。現(xiàn)在我們要把兩個程序合起來,他會根據(jù)命令行引數(shù)來決定該做的事情。我們也會讓程序可以處理不同的文件,而不是只有 todo.txt

我們叫這程序 todo,他會作三件事:

# 查看待作事項(xiàng)
# 加入待作事項(xiàng)
# 刪除待作事項(xiàng)

我們暫不考慮不合法的輸入這件事。

我們的程序要像這樣運(yùn)作:假如我們要加入 Find the magic sword of power,則我們會打 todo add todo.txt "Find the magic sword of power"。要查看事項(xiàng)我們則會打 todo view todo.txt,如果要移除事項(xiàng)二則會打 todo remove todo.txt 2

我們先作一個分發(fā)的 association list。他會把命令行引數(shù)當(dāng)作 key,而對應(yīng)的處理函數(shù)當(dāng)作 value。這些函數(shù)的型態(tài)都是 [String] -> IO ()。他們會接受命令行引數(shù)的串列并回傳對應(yīng)的查看,加入以及刪除的 I/O action。

import System.Environment
import System.Directory
import System.IO
import Data.List

dispatch :: [(String, [String] -> IO ())]
dispatch =  [ ("add", add)
            , ("view", view)
            , ("remove", remove)
            ]

我們定義了 main,add,view 跟 remove,就從 main 開始講吧:

main = do
    (command:args) <- getArgs
    let (Just action) = lookup command dispatch
    action args

首先,我們?nèi)〕鲆龜?shù)并把他們綁定到 (command:args)。如果你還記得 pattern matching,這么做會把第一個引數(shù)綁定到 command,把其他的綁定到 args。如果我們像這樣執(zhí)行程序 todo add todo.txt "Spank the monkey",command 會變成 "add",而 args 會變成 ["todo.txt", "Spank the monkey"]。

在下一行,我們在一個分派的串列中尋到我們的指令是哪個。由于 "add" 指向 add,我們的結(jié)果便是 Just add。我們再度使用了 pattern matching 來把我們的函數(shù)從 Maybe 中取出。但如果我們想要的指令不在分派的串列中呢?那樣 lookup 就會回傳 Nothing,但我們這邊并不特別處理失敗的情況,所以 pattern matching 會失敗然后我們的程序就會當(dāng)?shù)簟?/p>

最后,我們用剩下的引數(shù)調(diào)用 action 這個函數(shù)。他會還傳一個加入 item,顯示所有 items 或者刪除 item 的 I/O action。由于這個 I/O action 是在 main 的 do block 中,他最后會被執(zhí)行。如果我們的 action 函數(shù)是 add,他就會被喂 args 然后回傳一個加入 Spank the monkey 到 todo.txt 中的 I/O action。

我們剩下要做的就是實(shí)作 add,view 跟 remove,我們從 add 開始:

add :: [String] -> IO ()
add [fileName, todoItem] = appendFile fileName (todoItem ++ "\n")

如果我們這樣執(zhí)行程序 todo add todo.txt "Spank the monkey",則 "add" 會被綁定到 command,而 ["todo.txt", "Spank the monkey"] 會被帶到從 dispatch list 中拿到的函數(shù)。

由于我們不處理不合法的輸入,我們只針對這兩項(xiàng)作 pattern matching,然后回傳一個附加一行到文件末尾的 I/O action。

接著,我們來實(shí)作查看串列。如果我們想要查看所有 items,我們會 todo view todo.txt。所以 command 會是 "view",而 args 會是 ["todo.txt"]。

view :: [String] -> IO ()
view [fileName] = do
    contents <- readFile fileName
    let todoTasks = lines contents
    numberedTasks = zipWith (\n line -> show n ++ " - " ++ line) [0..] todoTasks
    putStr $ unlines numberedTasks

這跟我們之前刪除文件的程序差不多,只是我們是在顯示內(nèi)容而已,

最后,我們要來實(shí)作 remove。他基本上跟之前寫的只有刪除功能的程序很像,所以如果你不知道刪除是怎么做的,可以去看之前的解釋。主要的差別是我們不寫死 todo.txt,而是從參數(shù)取得。我們也不會提示用戶要刪除哪一號的 item,而是從參數(shù)取得。

remove :: [String] -> IO ()
remove [fileName, numberString] = do
    handle <- openFile fileName ReadMode
    (tempName, tempHandle) <- openTempFile "." "temp"
    contents <- hGetContents handle
    let number = read numberString
        todoTasks = lines contents
        newTodoItems = delete (todoTasks !! number) todoTasks
    hPutStr tempHandle $ unlines newTodoItems
    hClose handle
    hClose tempHandle
    removeFile fileName
    renameFile tempName fileName

我們打開 fileName 的文件以及一個暫存。刪除用戶要我們刪的那一行后,把文件內(nèi)容寫到暫存盤??车粼镜奈募缓蟀褧捍姹P重命名成 fileName。

來看看完整的程序。

import System.Environment
import System.Directory
import System.IO
import Data.List

dispatch :: [(String, [String] -> IO ())]
dispatch =  [ ("add", add)
            , ("view", view)
            , ("remove", remove)
            ]

main = do
    (command:args) <- getArgs
    let (Just action) = lookup command dispatch
    action args

add :: [String] -> IO ()
add [fileName, todoItem] = appendFile fileName (todoItem ++ "\n")

view :: [String] -> IO ()
view [fileName] = do
    contents <- readFile fileName
    let todoTasks = lines contents
        numberedTasks = zipWith (\n line -> show n ++ " - " ++ line) [0..] todoTasks
    putStr $ unlines numberedTasks

remove :: [String] -> IO ()
remove [fileName, numberString] = do
    handle <- openFile fileName ReadMode
    (tempName, tempHandle) <- openTempFile "." "temp"
    contents <- hGetContents handle
    let number = read numberString
        todoTasks = lines contents
        newTodoItems = delete (todoTasks !! number) todoTasks
    hPutStr tempHandle $ unlines newTodoItems
    hClose handle
    hClose tempHandle
    removeFile fileName
    renameFile tempName fileName

總結(jié)我們的程序:我們做了一個 dispatch association,將指令對應(yīng)到一些會接受命令行引數(shù)并回傳 I/O action 的函數(shù)。我們知道用戶下了什么命令,并根據(jù)那個命令從 dispatch list 取出對影的函數(shù)。我們用剩下的命令行引數(shù)調(diào)用哪些函數(shù)而得到一些作相對應(yīng)事情的 I/O action。然后便執(zhí)行那些 I/O action。

在其他編程語言,我們可能會用一個大的 switch case 來實(shí)作,但使用高端函數(shù)讓我們可以要 dispatch list 給我們要的函數(shù),并要那些函數(shù)給我們適當(dāng)?shù)?I/O action。

讓我們看看執(zhí)行結(jié)果。

$ ./todo view todo.txt
0 - Iron the dishes
1 - Dust the dog
2 - Take salad out of the oven

$ ./todo add todo.txt "Pick up children from drycleaners"

$ ./todo view todo.txt
0 - Iron the dishes
1 - Dust the dog
2 - Take salad out of the oven
3 - Pick up children from drycleaners

$ ./todo remove todo.txt 2

$ ./todo view todo.txt
0 - Iron the dishes
1 - Dust the dog
2 - Pick up children from drycleaners

要再另外加新的選項(xiàng)也是很容易。只要在 dispatch list 加入新的會作你要的事情函數(shù)。你可以試試實(shí)作一個 bump 函數(shù),接受一個文件跟一個 task number,他會回傳一個把那個 task 搬到 to-do list 頂端的 I/O action。

對于不合法的輸入你也可以讓程序結(jié)束地漂亮一點(diǎn)。(例如用戶輸入了 todo UP YOURS HAHAHAHA)可以作一個回報錯誤的 I/O action (例如 ``errorExist :: IO ())檢查有沒有不合法的輸入,如果有便執(zhí)行這個回報錯誤的 I/O action。我們之后會談另一個可能,就是用 exception。

亂數(shù)

在許多情況下,你寫程序會需要些隨機(jī)的數(shù)據(jù)?;蛟S你在制作一個游戲,在游戲中你需要擲骰子?;蚴悄阈枰獪y試程序的測試數(shù)據(jù)。精準(zhǔn)一點(diǎn)地說,我們需要 pseudo-random 的數(shù)據(jù),我們知道真正的隨機(jī)數(shù)據(jù)好比是一只猴子拿著起司跟奶油騎在單輪車上,任何事情都會發(fā)生。在這個章節(jié),我們要看看如何讓 Haskell 產(chǎn)生些 pseudo-random 的數(shù)據(jù)。

在大多數(shù)其他的編程語言中,會給你一些函數(shù)能讓你拿到些隨機(jī)亂數(shù)。每調(diào)用一次他就會拿到一個不同的數(shù)字。那在 Haskell 中是如何?要記住 Haskell 是一個純粹函數(shù)式語言。代表任何東西都具有 referential transparency。那代表你喂給一個函數(shù)相同的參數(shù),不管怎么調(diào)用都是回傳相同的結(jié)果。這很新奇的原因是因?yàn)樗屛覀兝斫獬绦虻姆绞讲煌?,而且可以讓我們延遲計算,直到我們真正需要他。如果我調(diào)用一個函數(shù),我可以確定他不會亂來。我真正在乎的是他的結(jié)果。然而,這會造成在亂數(shù)的情況有點(diǎn)復(fù)雜。如果我有一個函數(shù)像這樣:

randomNumber :: (Num a) => a
randomNumber = 4

由于他永遠(yuǎn)回傳 4,所以對于亂數(shù)的情形而言是沒什么意義。就算 4 這個結(jié)果是擲骰子來的也沒有意義。

其他的編程語言是怎么產(chǎn)生亂數(shù)的呢?他們可能隨便拿取一些電腦的信息,像是現(xiàn)在的時間,你怎么移動你的鼠標(biāo),以及周圍的聲音。根據(jù)這些算出一個數(shù)值讓他看起來好像隨機(jī)的。那些要素算出來的結(jié)果可能在每個時間都不同,所以你會拿到不同的隨機(jī)數(shù)字。

所以說在 Haskell 中,假如我們能作一個函數(shù),他會接受一個具隨機(jī)性的參數(shù),然后根據(jù)那些信息還傳一個數(shù)值。

在 System.Random 模塊中。他包含所有滿足我們需求的函數(shù)。讓我們先來看其中一個,就是 random。他的型態(tài)是 random :: (RandomGen g, Random a) => g -> (a, g)。哇,出現(xiàn)了新的 typeclass。RandomGen typeclass 是指那些可以當(dāng)作亂源的型態(tài)。而Random typeclass 則是可以裝亂數(shù)的型態(tài)。一個布林值可以是隨機(jī)值,不是 True 就是 False。一個整數(shù)可以是隨機(jī)的好多不同值。那你會問,函數(shù)可以是一個隨機(jī)值嗎?我不這么認(rèn)為。如果我們試著翻譯 random 的型態(tài)宣告,大概會是這樣:他接受一個 random generator (亂源所在),然后回傳一個隨機(jī)值以及一個新的 random generator。為什么他要回傳一個新的 random generator 呢?就是下面我們要講的。

要使用 random 函數(shù), 我們必須要了解 random generator。 在 System.Random 中有一個很酷的型態(tài),叫做 StdGen, 他是 RandomGen 的一個 instance。 我們可以自己手動作一個 StdGen 也可以告訴系統(tǒng)給我們一個現(xiàn)成的。

要自己做一個 random generator,要使用 mkStdGen 這個函數(shù)。他的型態(tài)是 mkStdGen :: Int -> StdGen。他接受一個整數(shù),然后根據(jù)這個整數(shù)會給一個 random generator。讓我們來試一下 random 以及 mkStdGen,用他們產(chǎn)生一個亂數(shù)吧。

ghci> random (mkStdGen 100)
<interactive>:1:0:
    Ambiguous type variable `a' in the constraint:
        `Random a' arising from a use of `random' at <interactive>:1:0-20
    Probable fix: add a type signature that fixes these type variable(s)  `

這是什么?由于 random 函數(shù)會回傳 Random typeclass 中任何一種型態(tài),所以我們必須告訴 Haskell 我們是要哪一種型態(tài)。不要忘了我們是回傳 random value 跟 random generator 的一個 pair

ghci> random (mkStdGen 100) :: (Int, StdGen)
(-1352021624,651872571 1655838864)

我們終于有了一個看起來像亂數(shù)的數(shù)字。tuple 的第一個部份是我們的亂數(shù),而第二個部份是一個新的 random generator 的文本表示。如果我們用相同的 random generator 再調(diào)用 random 一遍呢?

ghci> random (mkStdGen 100) :: (Int, StdGen)
(-1352021624,651872571 1655838864)

不易外地我們得到相同的結(jié)果。所以我們試試用不同的 random generator 作為我們的參數(shù)。

ghci> random (mkStdGen 949494) :: (Int, StdGen)
(539963926,466647808 1655838864)

很好,我們拿到了不同的數(shù)字。我們可以用不同的型態(tài)標(biāo)志來拿到不同型態(tài)的亂數(shù)

ghci> random (mkStdGen 949488) :: (Float, StdGen)
(0.8938442,1597344447 1655838864)
ghci> random (mkStdGen 949488) :: (Bool, StdGen)
(False,1485632275 40692)
ghci> random (mkStdGen 949488) :: (Integer, StdGen)
(1691547873,1597344447 1655838864)

讓我們寫一個仿真丟三次銅板的函數(shù)。假如 random 不同時回傳一個亂數(shù)以及一個新的 random generator,我們就必須讓這函數(shù)接受三個 random generators 讓他們每個回傳一個擲銅板的結(jié)果。但那樣聽起來怪怪的,加入一個 generator 可以產(chǎn)生一個型態(tài)是 Int 的亂數(shù),他應(yīng)該可以產(chǎn)生擲三次銅板的結(jié)果(總共才八個組合)。這就是 random 為什么要回傳一個新的 generator 的關(guān)鍵了。

我們將一個銅板表示成 Bool。True 代表反面,F(xiàn)alse 代表正面。

threeCoins :: StdGen -> (Bool, Bool, Bool)
threeCoins gen =
    let (firstCoin, newGen) = random gen
    (secondCoin, newGen') = random newGen
    (thirdCoin, newGen') = random newGen'
    in  (firstCoin, secondCoin, thirdCoin)  )

我們用我們拿來當(dāng)參數(shù)的 generator 調(diào)用 random 并得到一個擲銅板的結(jié)果跟一個新的 generator。然后我們再用新的 generator 調(diào)用他一遍,來得到第二個擲銅板的結(jié)果。對于第三個擲銅板的結(jié)果也是如法炮制。如果我們一直都用同樣的 generator,那所有的結(jié)果都會是相同的值。也就是不是 (False, False, False) 就是 (True, True, True)。

ghci> threeCoins (mkStdGen 21)
(True,True,True)
ghci> threeCoins (mkStdGen 22)
(True,False,True)
ghci> threeCoins (mkStdGen 943)
(True,False,True)
ghci> threeCoins (mkStdGen 944)
(True,True,True)

留意我們不需要寫 random gen :: (Bool, StdGen)。那是因?yàn)槲覀円呀?jīng)在函數(shù)的型態(tài)宣告那邊就表明我們要的是布林。而 Haskell 可以推敲出我們要的是布林值。

假如我們要的是擲四次?甚至五次呢?有一個函數(shù)叫 randoms,他接受一個 generator 并回傳一個無窮串行。

ghci> take 5 $ randoms (mkStdGen 11) :: [Int]
[-1807975507,545074951,-1015194702,-1622477312,-502893664]
ghci> take 5 $ randoms (mkStdGen 11) :: [Bool]
[True,True,True,True,False]
ghci> take 5 $ randoms (mkStdGen 11) :: [Float]
[7.904789e-2,0.62691015,0.26363158,0.12223756,0.38291094]

為什么 randoms 不另外多回傳一個新的 generator 呢?我們可以這樣地實(shí)作 randoms

randoms' :: (RandomGen g, Random a) => g -> [a]
randoms' gen = let (value, newGen) = random gen in value:randoms' newGen

一個遞歸的定義。我們由現(xiàn)在的 generator 拿到一個亂數(shù)跟一個新的 generator,然后制作一個 list,list 的第一個值是那個亂數(shù),而 list 的其余部份是根據(jù)新的 generator 產(chǎn)生出的其余亂數(shù)們。由于我們可能產(chǎn)生出無限的亂數(shù),所以不可能回傳一個新的 generator。

我們可以寫一個函數(shù),他會回傳有限個亂數(shù)跟一個新的 generator

finiteRandoms :: (RandomGen g, Random a, Num n, Eq n) => n -> g -> ([a], g)
finiteRandoms 0 gen = ([], gen)
finiteRandoms n gen =
    let (value, newGen) = random gen
        (restOfList, finalGen) = finiteRandoms (n-1) newGen
    in  (value:restOfList, finalGen)

又是一個遞歸的定義。我們說如果我們要 0 個亂數(shù),我們便回傳一個空的 list 跟原本給我們的 generator。對于其他數(shù)量的亂數(shù),我們先拿一個亂數(shù)跟一個新的 generator。這一個亂數(shù)便是 list 的第一個數(shù)字。然后 list 中剩下的便是 n-1 個由新的 generator 產(chǎn)生出的亂數(shù)。然后我們回傳整個 list 跟最后一個產(chǎn)生完 n-1 個亂數(shù)后 generator。

如果我們要的是在某個范圍內(nèi)的亂數(shù)呢?現(xiàn)在拿到的亂數(shù)要不是太大就是太小。如果我們想要的是骰子上的數(shù)字呢?randomR 能滿足我們的需求。他的型態(tài)是 randomR :: (RandomGen g, Random a) :: (a, a) -> g -> (a, g),代表他有點(diǎn)類似 random。只不過他的第一個參數(shù)是一對數(shù)目,定義了最后產(chǎn)生亂數(shù)的上界以及下界。

ghci> randomR (1,6) (mkStdGen 359353)
(6,1494289578 40692)
ghci> randomR (1,6) (mkStdGen 35935335)
(3,1250031057 40692)

另外也有一個 randomRs 的函數(shù),他會產(chǎn)生一連串在給定范圍內(nèi)的亂數(shù):

ghci> take 10 $ randomRs ('a','z') (mkStdGen 3) :: [Char]
"ndkxbvmomg"

這結(jié)果看起來像是一個安全性很好的密碼。

你會問你自己,這一單元跟 I/O 有關(guān)系嗎?到現(xiàn)在為止還沒出現(xiàn)任何跟 I/O 有關(guān)的東西。到現(xiàn)在為止我們都是手動地做我們的 random generator。但那樣的問題是,程序永遠(yuǎn)都會回傳同樣的亂數(shù)。這在真實(shí)世界中的程序是不能接受的。這也是為什么 System.Random 要提供 getStdGen 這個 I/O action,他的型態(tài)是 IO StdGen。當(dāng)你的程序執(zhí)行時,他會跟系統(tǒng)要一個 random generator,并存成一個 global generator。getStdGen 會替你拿那個 global random generator 并把他綁定到某個名稱上。

這里有一個簡單的產(chǎn)生隨機(jī)字串的程序。

import System.Random

main = do
    gen <- getStdGen
    putStr $ take 20 (randomRs ('a','z') gen)
$ runhaskell random_string.hs
pybphhzzhuepknbykxhe
$ runhaskell random_string.hs
eiqgcxykivpudlsvvjpg
$ runhaskell random_string.hs
nzdceoconysdgcyqjruo
$ runhaskell random_string.hs
bakzhnnuzrkgvesqplrx

要當(dāng)心當(dāng)我們連續(xù)兩次調(diào)用 getStdGent 的時候,實(shí)際上都會回傳同樣的 global generator。像這樣:

import System.Random

main = do
    gen <- getStdGen
    putStrLn $ take 20 (randomRs ('a','z') gen)
    gen2 <- getStdGen
    putStr $ take 20 (randomRs ('a','z') gen2)

你會打印出兩次同樣的字串。要能得到兩個不同的字串是建立一個無限的 stream,然后拿前 20 個字當(dāng)作第一個字串,拿下 20 個字當(dāng)作第二個字串。要這么做,我們需要在 Data.List 中的 splitAt 函數(shù)。他會把一個 list 根據(jù)給定的 index 切成一個 tuple,tuple 的第一部份就是切斷的前半,第二個部份就是切斷的后半。

import System.Random
import Data.List

main = do
    gen <- getStdGen
    let randomChars = randomRs ('a','z') gen
        (first20, rest) = splitAt 20 randomChars
        (second20, _) = splitAt 20 rest
    putStrLn first20
    putStr second20

另一種方法是用 newStdGen 這個 I/O action,他會把現(xiàn)有的 random generator 分成兩個新的 generators。然后會把其中一個指定成 global generator,并回傳另一個。

import System.Random

main = do
    gen <- getStdGen
    putStrLn $ take 20 (randomRs ('a','z') gen)
    gen' <- newStdGen
    putStr $ take 20 (randomRs ('a','z') gen')

當(dāng)我們綁定 newStdGen 的時候我們不只是會拿到一個新的 generator,global generator 也會被重新指定。所以再調(diào)用一次 getStdGen 并綁定到某個名稱的話,我們就會拿到跟 gen 不一樣的 generator。

這邊有一個小程序會讓用戶猜數(shù)字:

import System.Random
import Control.Monad(when)

main = do
    gen <- getStdGen
    askForNumber gen

askForNumber :: StdGen -> IO ()
askForNumber gen = do
    let (randNumber, newGen) = randomR (1,10) gen :: (Int, StdGen)
    putStr "Which number in the range from 1 to 10 am I thinking of? "
    numberString <- getLine
    when (not $ null numberString) $ do
        let number = read numberString
        if randNumber == number
            then putStrLn "You are correct!"
            else putStrLn $ "Sorry, it was " ++ show randNumber
            askForNumber newGen

我們寫了一個 askForNumber 的函數(shù),他接受一個 random generator 并回傳一個問用戶要數(shù)字并回答是否正確的 I/O action。在那個函數(shù)里面,我們先根據(jù)從參數(shù)拿到的 generator 產(chǎn)生一個亂數(shù)以及一個新的 generator,分別叫他們?yōu)?nbsp;randomNumber 跟 newGen。假設(shè)那個產(chǎn)生的數(shù)字是 7。則我們要求用戶猜我們握有的數(shù)字是什么。我們用 getLine 來將結(jié)果綁定到 numberString 上。當(dāng)用戶輸入 7,numberString 就會是 "7"。接下來,我們用 when 來檢查用戶輸入的是否是空字串。如果是,那一個空的 I/O action return () 就會被回傳。基本上就等于是結(jié)束程序的意思。如果不是,那 I/O action 就會被執(zhí)行。我們用 read 來把 numberString 轉(zhuǎn)成一個數(shù)字,所以 number 便會是 7。

如果用戶給我們一些 ``read`` 沒辦法讀取的輸入(像是 ``"haha"``),我們的程序便會當(dāng)?shù)舨⒋蛴〕鲥e誤消息。 如果你不希望你的程序當(dāng)?shù)?,就?**reads**,當(dāng)讀取失敗的時候他會回傳一個空的 list。當(dāng)成功的時候他就回傳一個 tuple,第一個部份是我們想要的數(shù)字,第二個部份是讀取失敗的字串。

我們檢查如果輸入的數(shù)字跟我們隨機(jī)產(chǎn)生的數(shù)字一樣,便提示用戶恰當(dāng)?shù)南?。然后再遞歸地調(diào)用 askForNumber,只是會拿到一個新的 generator。就像之前的 generator 一樣,他會給我們一個新的 I/O action。

main 的組成很簡單,就是由拿取一個 random generator 跟調(diào)用 askForNumber 組成罷了。

來看看我們的程序:

$ runhaskell guess_the_number.hs
Which number in the range from 1 to 10 am I thinking of? 4
Sorry, it was 3
Which number in the range from 1 to 10 am I thinking of? 10
You are correct!
Which number in the range from 1 to 10 am I thinking of? 2
Sorry, it was 4
Which number in the range from 1 to 10 am I thinking of? 5
Sorry, it was 10
Which number in the range from 1 to 10 am I thinking of?

用另一種方式寫的話像這樣:

import System.Random
import Control.Monad(when)

main = do
    gen <- getStdGen
    let (randNumber, _) = randomR (1,10) gen :: (Int, StdGen)
    putStr "Which number in the range from 1 to 10 am I thinking of? "
    numberString <- getLine
    when (not $ null numberString) $ do
        let number = read numberString
        if randNumber == number
            then putStrLn "You are correct!"
            else putStrLn $ "Sorry, it was " ++ show randNumber
        newStdGen
        main

他非常類似我們之前的版本,只是不是遞歸地調(diào)用,而是把所有的工作都在 main 里面做掉。在告訴用戶他們猜得是否正確之后,便更新 global generator 然后再一次調(diào)用 main。兩種策略都是有效但我比較喜歡第一種方式。因?yàn)樗?nbsp;main 里面做的事比較少,并提供我們一個可以重復(fù)使用的函數(shù)。

Bytestrings

List 是一種有用又酷的數(shù)據(jù)結(jié)構(gòu)。到目前為止,我們幾乎無處不使用他。有好幾個函數(shù)是專門處理 List 的,而 Haskell 惰性的性質(zhì)又讓我們可以用 filter 跟 map 來替換其他語言中的 for loop 跟 while loop。也由于 evaluation 只會發(fā)生在需要的時候,像 infinite list 也對于 Haskell 不成問題(甚至是 infinite list of infinite list)。這也是為什么 list 能被用來表達(dá) stream,像是讀取標(biāo)準(zhǔn)輸入或是讀取文件。我們可以打開文件然后讀取內(nèi)容成字串,即便實(shí)際上我們是需要的時候才會真正取讀取。

然而,用字串來處理文件有一個缺點(diǎn):就是他很慢。就像你所知道的,String 是一個 [Char] 的 type synonym。Char 沒有一個固定的大小,因?yàn)樗赡苡珊脦讉€ byte 組成,好比說 Unicode。再加上 list 是惰性的。如果你有一個 list 像 [1,2,3,4],他只會在需要的時候被 evaluate。所以整個 list 其實(shí)比較像是一個"保證"你會有一個 list。要記住 [1,2,3,4] 不過是 1:2:3:4:[] 的一個 syntactic sugar。當(dāng) list 的第一個元素被 evaluated 的時候,剩余的部份 2:3:4:[] 一樣也只是一個"保證"你會有一個 list,以此類推。以此類推。以此類推。所以你可以想像成 list 是保證在你需要的時候會給你第一個元素,以及保證你會有剩下的部份當(dāng)你還需要更多的時候。其實(shí)不難說服你這樣做并不是一個最有效率的作法。

這樣額外的負(fù)擔(dān)在大多數(shù)時候不會造成困擾,但當(dāng)我們要讀取一個很大的文件的時候就是個問題了。這也是為什么 Haskell 要有 bytestrings。Bytestrings 有點(diǎn)像 list,但他每一個元素都是一個 byte (8 bits),而且他們惰性的程度也是不同。

Bytestrings 有兩種:strict 跟 lazy。Strict bytestrings 放在 Data.ByteString,他們把惰性的性質(zhì)完全拿掉。不會有所謂任何的「保證」,一個 strict bytestring 就代表一連串的 bytes。因此你不會有一個無限長的 strict bytestrings。如果你 evaluate 第一個 byte,你就必須 evalute 整個 bytestring。這么做的優(yōu)點(diǎn)是他會比較少 overhaed,因?yàn)樗麤]有 "Thunk"(也就是用 Haskell 術(shù)語來說的「保證」)。缺點(diǎn)就是他可能會快速消耗你的內(nèi)存,因?yàn)槟惆阉麄円淮味甲x進(jìn)了內(nèi)存。

另一種 bytestring 是放在 Data.ByteString.Lazy 中。他們具有惰性,但又不像 list 那么極端。就像我們之前說的,List 的 thunk 個數(shù)是跟 list 中有幾個元素一模一樣。這也是為什么他們速度沒辦法滿足一些特殊需求。Lazy bytestrings 則用另一種作法,他們被存在 chunks 中(不要跟 Thunk 搞混),每一個 chunk 的大小是 64K。所以如果你 evaluate lazy bytestring 中的 byte,則前 64K 會被 evaluated。在那個 chunck 之后,就是一些「保證」會有剩余的 chunk。lazy bytestrings 有點(diǎn)像裝了一堆大小為 64K 的 strict bytestrings 的 list。當(dāng)你用 lazy bytestring 處理一個文件的時候,他是一個 chunk 一個 chunk 去讀。這很棒是因?yàn)樗粫屛覀円幌率褂么罅康膬?nèi)存,而且 64K 有很高的可能性能夠裝進(jìn)你 CPU 的 L2 Cache。

如果你大概看過 Data.ByteString.Lazy 的文檔,你會看到到他有一堆函數(shù)的名稱跟 Data.List 中的函數(shù)名稱相同,只是出現(xiàn)的 type signature 是 ByteString 而不是 [a],是 Word8 而不是 a。同樣名稱的函數(shù)基本上表現(xiàn)的行為跟 list 中的差不多。因?yàn)槊Q是一樣的,所以必須用 qualified import 才不會在裝載進(jìn) GHCI 的時候造成沖突。

import qualified Data.ByteString.Lazy as B
import qualified Data.ByteString as S

B 中有 lazy bytestrings 跟對應(yīng)的函數(shù),而 S 中則有 strict 的版本。大多數(shù)時候我們是用 lazy 的版本。

pack 函數(shù)的 type signature 是 pack :: [Word8] -> ByteString。代表他接受一串型態(tài)為 Word8 的 bytes,并回傳一個 ByteString。你能想像一個 lazy 的 list,要讓他稍微不 lazy 一些,所以讓他對于 64K lazy。

那 Word8 型態(tài)又是怎么一回事?。他就像 Int,只是他的范圍比較小,介于 0-255 之間。他代表一個 8-bit 的數(shù)字。就像 Int 一樣,他是屬于 Num 這個 typeclass。例如我們知道 5 是 polymorphic 的,他能夠表現(xiàn)成任何數(shù)值型態(tài)。其實(shí) Word8 他也能表示。

ghci> B.pack [99,97,110]
Chunk "can" Empty
ghci> B.pack [98..120]
Chunk "bcdefghijklmnopqrstuvwx" Empty

正如你看到的,你其實(shí)不必特別在意 Word8,因?yàn)樾蛻B(tài)系統(tǒng)會選擇正確的型態(tài)。如果你試著用比較大的數(shù)字,像是 336。那對于 Word8 他就會變成 80。

我們把一些數(shù)值打包成 ByteString,使他們可以塞進(jìn)一個 chunk 里面。Empty 之于 ByteString 就像 [] 之于 list 一樣。

unpack 是 pack 的相反,他把一個 bytestring 變成一個 byte list。

fromChunks 接受一串 strict 的 bytestrings 并把他變成一串 lazy bytestring。toChunks 接受一個 lazy bytestrings 并將他變成一串 strict bytestrings。

ghci> B.fromChunks [S.pack [40,41,42], S.pack [43,44,45], S.pack [46,47,48]]
Chunk "()*" (Chunk "+,-" (Chunk "./0" Empty))

如果你有很多小的 strict bytestrings 而且不想先將他們 join 起來(會耗損 memory)這樣的作法是不錯的。

bytestring 版本的 : 叫做 cons。他接受一個 byte 跟一個 bytestring,并把這個 byte 放到 bytestring 的前端。他是 lazy 的操作,即使 bytestring 的第一個 chunk 不是滿的,他也會添加一個 chunk。這也是為什么當(dāng)你要插入很多 bytes 的時候最好用 strict 版本的 cons,也就是 cons'。

ghci> B.cons 85 $ B.pack [80,81,82,84]
Chunk "U" (Chunk "PQRT" Empty)
ghci> B.cons' 85 $ B.pack [80,81,82,84]
Chunk "UPQRT" Empty
ghci> foldr B.cons B.empty [50..60]
Chunk "2" (Chunk "3" (Chunk "4" (Chunk "5" (Chunk "6" (Chunk "7" (Chunk "8" (Chunk "9" (Chunk ":" (Chunk ";" (Chunk "<"
Empty))))))))))
ghci> foldr B.cons' B.empty [50..60]
Chunk "23456789:;<" Empty

你可以看到 empty 制造了一個空的 bytestring。也注意到 cons 跟 cons' 的差異了嗎?有了 foldr,我們逐步地把一串?dāng)?shù)字從右邊開始,一個個放到 bytestring 的前頭。當(dāng)我們用 cons,我們則得到一個 byte 一個 chunk 的結(jié)果,并不是我們要的。

bytestring 模塊有一大票很像 Data.List 中的函數(shù)。包括了 head,tail,init,null,length,map,reverse,foldl,foldr,concat,takeWhile,filter,等等。

他也有表現(xiàn)得跟 System.IO 中一樣的函數(shù),只有 Strings 被換成了 ByteString 而已。像是 System.IO 中的 readFile,他的型態(tài)是 readFile :: FilePath -> IO String,而 bytestring 模塊中的 readFile 則是 readFile :: FilePath -> IO ByteString。小心,如果你用了 strict bytestring 來讀取一個文件,他會把文件內(nèi)容都讀進(jìn)內(nèi)存中。而使用 lazy bytestring,他則會讀取 chunks。

讓我們來寫一個簡單的程序,他從命令行接受兩個文件名,然后拷貝第一個文件內(nèi)容成第二個文件。雖然 System.Directory 中已經(jīng)有一個函數(shù)叫 copyFile,但我們想要實(shí)作自己的版本。

import System.Environment
import qualified Data.ByteString.Lazy as B

main = do
    (fileName1:fileName2:_) <- getArgs
    copyFile fileName1 fileName2

copyFile :: FilePath -> FilePath -> IO ()
copyFile source dest = do
    contents <- B.readFile source
    B.writeFile dest contents

我們寫了自己的函數(shù),他接受兩個 FilePath(記住 FilePath 不過是 String 的同義詞。)并回傳一個 I/O action,他會用 bytestring 拷貝第一個文件至另一個。在 main 函數(shù)中,我們做的只是拿到命令行引數(shù)然后調(diào)用那個函數(shù)來拿到一個 I/O action。

$ runhaskell bytestringcopy.hs something.txt ../../something.txt

就算我們不用 bytestring 來寫,程序最后也會長得像這樣。差別在于我們會用 B.readFile 跟 B.writeFile 而不是 readFile 跟 writeFile。有很大的可能性,就是你只要 import 文件并在函數(shù)前加上 qualified 模塊名,就可以把一個用正常 String 的程序改成用 ByteString。也有可能你是要反過來做,但那也不難。

當(dāng)你需要更好的性能來讀取許多數(shù)據(jù),嘗試用 bytestring,有很大的機(jī)會你會用很小的力氣改進(jìn)很多性能。我通常用正常 String 來寫程序,然后在性能不好的時候把他們改成 ByteString。

Exceptions (例外)

所有的編程語言都有要處理失敗的情形。這就是人生。不同的語言有不同的處理方式。在 C 里面,我們通常用非正常范圍的回傳值(像是 -1 或 null)來回傳錯誤。Java 跟 C#則傾向于使用 exception 來處理失敗的情況。當(dāng)一個 exception 被丟出的時候,控制流程就會跳到我們做一些清理動作的地方,做完清理后 exception 被重新丟出,這樣一些處理錯誤的代碼可以完成他們的工作。

Haskell 有一個很棒的型態(tài)系統(tǒng)。Algebraic data types 允許像是 Maybe 或 Either 這種型態(tài),我們能用這些型態(tài)來代表一些可能有或沒有的結(jié)果。在 C 里面,在失敗的時候回傳 -1 是很常見的事。但他只對寫程序的人有意義。如果我們不小心,我們有可能把這些錯誤碼當(dāng)作正常值來處理,便造成一些混亂。Haskell 的型態(tài)系統(tǒng)賦予我們更安全的環(huán)境。一個 a -> Maybe b 的函數(shù)指出了他會產(chǎn)生一個包含 b 的 Just,或是回傳 Nothing。這型態(tài)跟 a -> b 是不同的,如果我們試著將兩個函數(shù)混用,compiler 便會警告我們。

盡管有表達(dá)力夠強(qiáng)的型態(tài)來輔助失敗的情形,Haskell 仍然支持 exception,因?yàn)?exception 在 I/O 的 contexts 下是比較合理的。在處理 I/O 的時候會有一堆奇奇怪怪的事情發(fā)生,環(huán)境是很不能被信賴的。像是打開文件。文件有可能被 lock 起來,也有可能文件被移除了,或是整個硬盤都被拔掉。所以直接跳到處理錯誤的代碼是很合理的。

我們了解到 I/O code 會丟出 exception 是件合理的事。至于 pure code 呢?其實(shí)他也能丟出 Exception。想想看 div 跟 head 兩個案例。他們的型態(tài)是 (Integral a) => a -> a -> a 以及 [a] -> a。Maybe 跟 Either 都沒有在他們的回傳型態(tài)中,但他們都有可能失敗。div 有可能除以零,而 head 有可能你傳給他一個空的 list。

ghci> 4 `div` 0
*** Exception: divide by zero
ghci> head []
*** Exception: Prelude.head: empty list

pure code 能丟出 Exception,但 Exception 只能在 I/O section 中被接到(也就是在 main 的 do block 中)這是因?yàn)樵?pure code 中你不知道什么東西什么時候會被 evaluate。因?yàn)?lazy 特性的緣故,程序沒有一個特定的執(zhí)行順序,但 I/O code 有。

先前我們談過為什么在 I/O 部份的程序要越少越好。程序的邏輯部份盡量都放在 pure 的部份,因?yàn)?pure 的特性就是他們的結(jié)果只會根據(jù)函數(shù)的參數(shù)不同而改變。當(dāng)思考 pure function 的時候,你只需要考慮他回傳什么,因?yàn)槌酥馑粫腥魏胃弊饔谩_@會讓事情簡單許多。盡管 I/O 的部份是難以避免的(像是打開文件之類),但最好是把 I/O 部份降到最低。Pure functions 缺省是 lazy,那代表我們不知道他什么時候會被 evaluate,不過我們也不該知道。然而,一旦 pure functions 需要丟出 Exception,他們何時被 evaluate 就很重要了。那是因?yàn)槲覀冎挥性?I/O 的部份才能接到 Exception。這很糟糕,因?yàn)槲覀冋f過希望 I/O 的部份越少越好。但如果我們不接 Exception,我們的程序就會當(dāng)?shù)?。這問題有解決辦法嗎?答案是不要在 pure code 里面使用 Exception。利用 Haskell 的型態(tài)系統(tǒng),盡量使用 Either 或 Maybe 之類的型態(tài)來表示可能失敗的計算。

這也是為什么我們要來看看怎么使用 I/O Excetion。I/O Exception 是當(dāng)我們在 main 里面跟外界溝通失敗而丟出的 Exception。例如我們嘗試打開一個文件,結(jié)果發(fā)現(xiàn)他已經(jīng)被刪掉或是其他狀況。來看看一個嘗試打開命令行引數(shù)所指定文件名稱,并計算里面有多少行的程序。

import System.Environment
import System.IO

main = do (fileName:_) <- getArgs
            contents <- readFile fileName
            putStrLn $ "The file has " ++ show (length (lines contents)) ++ " lines!"

一個很簡單的程序。我們使用 getArgs I/O action,并綁定第一個 string 到 fileName。然后我們綁定文件內(nèi)容到 contents。最后,我們用 lines 來取得 line 的 list,并計算 list 的長度,并用 show 來轉(zhuǎn)換數(shù)字成 string。他如我們想像的工作,但當(dāng)我們給的文件名稱不存在的時候呢?

$ runhaskell linecount.hs i_dont_exist.txt
linecount.hs: i_dont_exist.txt: openFile: does not exist (No such file or directory)

GHC 丟了錯誤消息給我們,告訴我們文件不存在。然后程序就掛掉了。假如我們希望打印出比較好一些的錯誤消息呢?一種方式就是在打開文件前檢查他存不存在。用 System.Directory 中的 doesFileExist。

import System.Environment
import System.IO
import System.Directory

main = do (fileName:_) <- getArgs
            fileExists <- doesFileExist fileName
            if fileExists
                then do contents <- readFile fileName
                    putStrLn $ "The file has " ++ show (length (lines contents)) ++ " lines!"
                else do putStrLn "The file doesn't exist!"

由于 doesFileExist 的型態(tài)是 doesFileExist :: FilePath -> IO Bool,所以我們要寫成 fileExists <- doesFileExist fileName。那代表他回傳含有一個布林值告訴我們文件存不存在的 I/O action。doesFileExist 是不能直接在 if expression 中使用的。

另一個解法是使用 Exception。在這個情境下使用 Exception 是沒問題的。文件不存在這個 Exception 是在 I/O 中被丟出,所以在 I/O 中接起來也沒什么不對。

要這樣使用 Exception,我們必須使用 System.IO.Error 中的 catch 函數(shù)。他的型態(tài)是 catch :: IO a -> (IOError -> IO a) -> IO a。他接受兩個參數(shù),第一個是一個 I/O action。像是他可以接受一個打開文件的 I/O action。第二個是 handler。如果第一個參數(shù)的 I/O action 丟出了 Exception,則他會被傳給 handler,他會決定要作些什么。所以整個 I/O action 的結(jié)果不是如預(yù)期中做完第一個參數(shù)的 I/O action,就是 handler 處理的結(jié)果。

如果你對其他語言像是 Java, Python 中 try-catch 的形式很熟,那 catch 其實(shí)跟他們很像。第一個參數(shù)就是其他語言中的 try block。第二個參數(shù)就是其他語言中的 catch block。其中 handler 只有在 exception 被丟出時才會被執(zhí)行。

handler 接受一個 IOError 型態(tài)的值,他代表的是一個 I/O exception 已經(jīng)發(fā)生了。他也帶有一些 exception 本身的信息。至于這型態(tài)在語言中使如何被實(shí)作則是要看編譯器。這代表我們沒辦法用 pattern matching 的方式來查看 IOError。就像我們不能用 pattern matching 來查看 IO something 的內(nèi)容。但我們能用一些 predicate 來查看他們。

我們來看看一個展示 catch 的程序

import System.Environment
import System.IO
import System.IO.Error

main = toTry `catch` handler

toTry :: IO ()
toTry = do (fileName:_) <- getArgs
            contents <- readFile fileName
            putStrLn $ "The file has " ++ show (length (lines contents)) ++ " lines!"

handler :: IOError -> IO ()
handler e = putStrLn "Whoops, had some trouble!"

首先你看到我們可以在關(guān)鍵字周圍加上 backticks 來把 catch 當(dāng)作 infix function 用,因?yàn)樗麆偤媒邮軆蓚€參數(shù)。這樣使用讓可讀性變好。toTry `catch` handler 跟 catch toTry handler 是一模一樣的。toTry 是一個 I/O action,而 handler 接受一個 IOError,并回傳一個當(dāng) exception 發(fā)生時被執(zhí)行的 I/O action。

來看看執(zhí)行的結(jié)果。

$ runhaskell count_lines.hs i_exist.txt
The file has 3 lines!

$ runhaskell count_lines.hs i_dont_exist.txt
Whoops, had some trouble!

在 handler 里面我們并沒有檢查我們拿到的是什么樣的 IOError,我們只是打印出 "Whoops, had some trouble!"。接住任何種類的 Exception 就跟其他語言一樣,在 Haskell 中也不是一個好的習(xí)慣。假如其他種類的 Exception 發(fā)生了,好比說我們送一個中斷指令,而我們沒有接到的話會發(fā)生什么事?這就是為什么我們要做跟其他語言一樣的事:就是檢查我們拿到的是什么樣的 Exception。如果說是我們要的 Exception,那就做對應(yīng)的處理。如果不是,我們再重新丟出 Exception。我們把我們的程序這樣修改,只接住文件不存在的 Exception。

import System.Environment
import System.IO
import System.IO.Error

main = toTry `catch` handler

toTry :: IO ()
toTry = do (fileName:_) <- getArgs
            contents <- readFile fileName
            putStrLn $ "The file has " ++ show (length (lines contents)) ++ " lines!"

handler :: IOError -> IO ()
handler e
    | isDoesNotExistError e = putStrLn "The file doesn't exist!"
    | otherwise = ioError e

除了 handler 以外其他東西都沒變,我們只接住我們想要的 I/O exception。這邊使用了 System.IO.Error 中的函數(shù) isDoesNotExistError 跟 ioError。isDoesNotExistError 是一個運(yùn)作在 IOError 上的 predicate ,他代表他接受一個 IOError 然后回傳 True 或 False,他的型態(tài)是 isDoesNotExistError :: IOError -> Bool。我們用他來判斷是否這個錯誤是文件不存在所造成的。我們這邊使用 guard,但其實(shí)也可以用 if else。如果 exception 不是由于文件不存在所造成的,我們就用 ioEroror 重新丟出接到的 exception。他的型態(tài)是 ioError :: IOException -> IO a,所以他接受一個 IOError 然后產(chǎn)生一個會丟出 exception 的 I/O action。那個 I/O action 的型態(tài)是 IO a,但他其實(shí)不會產(chǎn)生任何結(jié)果,所以他可以被當(dāng)作是 IO anything。

所以有可能在 toTry 里面丟出的 exception 并不是文件不存在造成的,而 toTry `catch` handler 會接住再丟出來,很酷吧。

程序里面有好幾個運(yùn)作在 IOError 上的 I/O action,當(dāng)其中一個沒有被 evaluate 成 True 時,就會掉到下一個 guard。這些 predicate 分別為:

* **isAlreadyExistsError**
* **isDoesNotExistError**
* **isFullError**
* **isEOFError**
* **isIllegalOperation**
* **isPermissionError**
* **isUserError**

大部分的意思都是顯而易見的。當(dāng)我們用了 userError 來丟出 exception 的時候,isUserError 被 evaluate 成 True。例如說,你可以寫 ioError $ userError "remote computer unplugged!",盡管用 Either 或 Maybe 來表示可能的錯誤會比自己丟出 exception 更好。

所以你可能寫一個像這樣的 handler

handler :: IOError -> IO ()
handler e
    | isDoesNotExistError e = putStrLn "The file doesn't exist!"
    | isFullError e = freeSomeSpace
    | isIllegalOperation e = notifyCops
    | otherwise = ioError e

其中 notifyCops 跟 freeSomeSpace 是一些你定義的 I/O action。如果 exception 不是你要的,記得要把他們重新丟出,不然你的程序可能只會安靜地當(dāng)?shù)簟?/p>

System.IO.Error 也提供了一些能詢問 exception 性質(zhì)的函數(shù),像是哪些 handle 造成錯誤,或哪些文件名造成錯誤。這些函數(shù)都是 ioe 當(dāng)開頭。而且你可以在文檔中看到一整串詳細(xì)數(shù)據(jù)。假設(shè)我們想要打印出造成錯誤的文件名。我們不能直接打印出從 getArgs 那邊拿到的 fileName,因?yàn)橹挥?nbsp;IOError 被傳進(jìn) handler 中,而 handler 并不知道其他事情。一個函數(shù)只依賴于他所被調(diào)用時的參數(shù)。這也是為什么我們會用 ioeGetFileName 這函數(shù),他的型態(tài)是 ioeGetFileName :: IOError -> Maybe FilePath。他接受一個 IOError 并回傳一個 FilePath(他是 String 的同義詞。)基本上他做的事就是從 IOError 中抽出文件路徑。我們來修改一下我們的程序。

import System.Environment
import System.IO
import System.IO.Error

main = toTry `catch` handler

toTry :: IO ()
toTry = do (fileName:_) <- getArgs
    contents <- readFile fileName
    putStrLn $ "The file has " ++ show (length (lines contents)) ++ " lines!"

handler :: IOError -> IO ()
handler e
    | isDoesNotExistError e =
        case ioeGetFileName e of Just path -> putStrLn $ "Whoops! File does not exist at: " ++ path
                                 Nothing -> putStrLn "Whoops! File does not exist at unknown location!"
    | otherwise = ioError e

在 isDoesNotExistError 是 True 的 guard 里面,我們在 case expression 中用 e 來調(diào)用 ioeGetFileName,然后用 pattern matching 拆出 Maybe 中的值。當(dāng)你想要用 pattern matching 卻又不想要寫一個新的函數(shù)的時候,case expression 是你的好朋友。

你不想只用一個 catch 來接你 I/O part 中的所有 exception。你可以只在特定地方用 catch 接 exception,或你可以用不同的 handler。像這樣:

main = do toTry `catch` handler1
          thenTryThis `catch` handler2
          launchRockets

這邊 toTry 使用 handler1 當(dāng)作 handler,而 thenTryThis 用了 handler2。launchRockets 并不是 catch 的參數(shù),所以如果有任何一個 exception 被丟出都會讓我們的程序當(dāng)?shù)?,除?nbsp;launchRockets 使用 catch 來處理 exception。當(dāng)然 toTry,thenTryThis 跟 launchRockets 都是 I/O actions,而且被 do syntax 綁在一起。這很像其他語言中的 try-catch blocks,你可以把一小段程序用 try-catch 包住,你可以自己調(diào)整該包多少進(jìn)去。

現(xiàn)在你知道如何處理 I/O exception 了。我們并沒有提到如何從 pure code 中丟出 exception,這是因?yàn)檎缥覀兿惹疤岬降模琀askell 提供了更好的辦法來處理錯誤。就算是在可能會失敗的 I/O action 中,我也傾向用 IO (Either a b),代表他們是 I/O action,但當(dāng)他們被執(zhí)行,他們結(jié)果的型態(tài)是 Either a b,意思是不是 Left a 就是 Right b。


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號