我們已經(jīng)說(shuō)明了 Haskell 是一個(gè)純粹函數(shù)式語(yǔ)言。雖說(shuō)在命令式語(yǔ)言中我們習(xí)慣給電腦執(zhí)行一連串指令,在函數(shù)式語(yǔ)言中我們是用定義東西的方式進(jìn)行。在 Haskell 中,一個(gè)函數(shù)不能改變狀態(tài),像是改變一個(gè)變量的內(nèi)容。(當(dāng)一個(gè)函數(shù)會(huì)改變狀態(tài),我們說(shuō)這函數(shù)是有副作用的。)在 Haskell 中函數(shù)唯一可以做的事是根據(jù)我們給定的參數(shù)來(lái)算出結(jié)果。如果我們用同樣的參數(shù)調(diào)用兩次同一個(gè)函數(shù),它會(huì)回傳相同的結(jié)果。盡管這從命令式語(yǔ)言的角度來(lái)看是蠻大的限制,我們已經(jīng)看過(guò)它可以達(dá)成多么酷的效果。在一個(gè)命令式語(yǔ)言中,編程語(yǔ)言沒(méi)辦法給你任何保證在一個(gè)簡(jiǎn)單如打印出幾個(gè)數(shù)字的函數(shù)不會(huì)同時(shí)燒掉你的房子,綁架你的狗并刮傷你車(chē)子的烤漆。例如,當(dāng)我們要建立一棵二元樹(shù)的時(shí)候,我們并不插入一個(gè)節(jié)點(diǎn)來(lái)改變?cè)械臉?shù)。由于我們無(wú)法改變狀態(tài),我們的函數(shù)實(shí)際上回傳了一棵新的二元樹(shù)。
函數(shù)無(wú)法改變狀態(tài)的好處是它讓我們促進(jìn)了我們理解程序的容易度,但同時(shí)也造成了一個(gè)問(wèn)題。假如說(shuō)一個(gè)函數(shù)無(wú)法改變現(xiàn)實(shí)世界的狀態(tài),那它要如何打印出它所計(jì)算的結(jié)果?畢竟要告訴我們結(jié)果的話,它必須要改變輸出設(shè)備的狀態(tài)(譬如說(shuō)屏幕),然后從屏幕傳達(dá)到我們的腦,并改變我們心智的狀態(tài)。
不要太早下結(jié)論,Haskell 實(shí)際上設(shè)計(jì)了一個(gè)非常聰明的系統(tǒng)來(lái)處理有副作用的函數(shù),它漂亮地將我們的程序區(qū)分成純粹跟非純粹兩部分。非純粹的部分負(fù)責(zé)跟鍵盤(pán)還有屏幕溝通。有了這區(qū)分的機(jī)制,在跟外界溝通的同時(shí),我們還是能夠有效運(yùn)用純粹所帶來(lái)的好處,像是惰性求值、容錯(cuò)性跟模塊性。
到目前為止我們都是將函數(shù)加載 GHCi 中來(lái)測(cè)試,像是標(biāo)準(zhǔn)函式庫(kù)中的一些函式。但現(xiàn)在我們要做些不一樣的,寫(xiě)一個(gè)真實(shí)跟世界交互的 Haskell 程序。當(dāng)然不例外,我們會(huì)來(lái)寫(xiě)個(gè) "hello world"。
現(xiàn)在,我們把下一行打到你熟悉的編輯器中
main = putStrLn "hello, world"
我們定義了一個(gè) main,并在里面以 "hello, world" 為參數(shù)調(diào)用了 putStrLn??雌饋?lái)沒(méi)什么大不了,但不久你就會(huì)發(fā)現(xiàn)它的奧妙。把這程序存成 helloworld.hs。
現(xiàn)在我們將做一件之前沒(méi)做過(guò)的事:編譯你的程序。打開(kāi)你的終端并切換到包含 helloworld.hs 的目錄,并輸入下列指令。
$ ghc --make helloworld
[1 of 1] Compiling Main ( helloworld.hs, hellowowlrd.o )
Linking helloworld ...
順利的話你就會(huì)得到如上的消息,接著你便可以執(zhí)行你的程序 ./helloworld
$ ./helloworld
hello, world
這就是我們第一個(gè)編譯成功并打印出字串到屏幕的程序。很簡(jiǎn)單吧。
讓我們來(lái)看一下我們究竟做了些什么,首先來(lái)看一下 putStrLn 函數(shù)的型態(tài):
ghci> :t putStrLn
putStrLn :: String -> IO ()
ghci> :t putStrLn "hello, world"
putStrLn "hello, world" :: IO ()
我們可以這么解讀 putStrLn 的型態(tài):putStrLn 接受一個(gè)字串并回傳一個(gè) I/O action,這 I/O action 包含了 () 的型態(tài)。(即空的 tuple,或者是 unit 型態(tài))。一個(gè) I/O action 是一個(gè)會(huì)造成副作用的動(dòng)作,常是指讀取輸入或輸出到屏幕,同時(shí)也代表會(huì)回傳某些值。在屏幕打印出幾個(gè)字串并沒(méi)有什么有意義的回傳值可言,所以這邊用一個(gè) () 來(lái)代表。
那究竟 I/O action 會(huì)在什么時(shí)候被觸發(fā)呢?這就是 main 的功用所在。一個(gè) I/O action 會(huì)在我們把它綁定到 main 這個(gè)名字并且執(zhí)行程序的時(shí)候觸發(fā)。
把整個(gè)程序限制在只能有一個(gè) I/O action 看似是個(gè)極大的限制。這就是為什么我們需要 do 表示法來(lái)將所有 I/O action 綁成一個(gè)。來(lái)看看下面這個(gè)例子。
main = do
putStrLn "Hello, what's your name?"
name <- getLine
putStrLn ("Hey " ++ name ++ ", you rock!")
新的語(yǔ)法,有趣吧!它看起來(lái)就像一個(gè)命令式的程序。如果你編譯并執(zhí)行它,它便會(huì)照你預(yù)期的方式執(zhí)行。我們寫(xiě)了一個(gè) do 并且接著一連串指令,就像寫(xiě)個(gè)命令式程序一般,每一步都是一個(gè) I/O action。將所有 I/O action 用 do 綁在一起變成了一個(gè)大的 I/O action。這個(gè)大的 I/O action 的型態(tài)是 IO (),這完全是由最后一個(gè) I/O action 所決定的。
這就是為什么 main 的型態(tài)永遠(yuǎn)都是 main :: IO something,其中 something 是某個(gè)具體的型態(tài)。按照慣例,我們通常不會(huì)把 main 的型態(tài)在程序中寫(xiě)出來(lái)。
另一個(gè)有趣的事情是第三行 name <- getLine。它看起來(lái)像是從輸入讀取一行并存到一個(gè)變量 name 之中。真的是這樣嗎?我們來(lái)看看 getLine 的型態(tài)吧
ghci> :t getLine
getLine :: IO String
我們可以看到 getLine 是一個(gè)回傳 String 的 I/O action。因?yàn)樗鼤?huì)等用戶輸入某些字串,這很合理。那 name <- getLine 又是如何?你能這樣解讀它:執(zhí)行一個(gè) I/O action getLine 并將它的結(jié)果綁定到 name 這個(gè)名字。getLine 的型態(tài)是 IO String,所以 name 的型態(tài)會(huì)是 String。你能把 I/O action 想成是一個(gè)長(zhǎng)了腳的盒子,它會(huì)跑到真實(shí)世界中替你做某些事,像是在墻壁上涂鴉,然后帶回來(lái)某些數(shù)據(jù)。一旦它帶了某些數(shù)據(jù)給你,打開(kāi)盒子的唯一辦法就是用 <-。而且如果我們要從 I/O action 拿出某些數(shù)據(jù),就一定同時(shí)要在另一個(gè) I/O action 中。這就是 Haskell 如何漂亮地分開(kāi)純粹跟不純粹的程序的方法。getLine 在這樣的意義下是不純粹的,因?yàn)閳?zhí)行兩次的時(shí)候它沒(méi)辦法保證會(huì)回傳一樣的值。這也是為什么它需要在一個(gè) IO 的型態(tài)建構(gòu)子中,那樣我們才能在 I/O action 中取出數(shù)據(jù)。而且任何一段程序一旦依賴著 I/O 數(shù)據(jù)的話,那段程序也會(huì)被視為 I/O code。
但這不表示我們不能在純粹的代碼中使用 I/O action 回傳的數(shù)據(jù)。只要我們綁定它到一個(gè)名字,我們便可以暫時(shí)地使用它。像在 name <- getLine 中 name 不過(guò)是一個(gè)普通字串,代表在盒子中的內(nèi)容。我們能將這個(gè)普通的字串傳給一個(gè)極度復(fù)雜的函數(shù),并回傳你一生會(huì)有多少財(cái)富。像是這樣:
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)只不過(guò)是 String -> String。
再來(lái)看看這段代碼吧,他是合法的嗎?
nameTag = "Hello, my name is " ++ getLine
如果你回答不是,恭喜你。如果你說(shuō)是,你答錯(cuò)了。這么做不對(duì)的理由是 ++ 要求兩個(gè)參數(shù)都必須是串列。他左邊的參數(shù)是 String,也就是 [Char]。然而 getLine 的型態(tài)是 IO String。你不能串接一個(gè)字串跟 I/O action。我們必須先把 String 的值從 I/O action 中取出,而唯一可行的方法就是在 I/O action 中使用 name <- getLine。如果我們需要處理一些非純粹的數(shù)據(jù),那我們就要在非純粹的環(huán)境中做。所以我們最好把 I/O 的部分縮減到最小的比例。
每個(gè) I/O action 都有一個(gè)值封裝在里面。這也是為什么我們之前的程序可以這么寫(xiě):
main = do
foo <- putStrLn "Hello, what's your name?"
name <- getLine
putStrLn ("Hey " ++ name ++ ", you rock!")
然而,foo 只會(huì)有一個(gè) () 的值,所以綁定到 foo 這個(gè)名字似乎是多余的。另外注意到我們并沒(méi)有綁定最后一行的 putStrLn 給任何名字。那是因?yàn)樵谝粋€(gè) do block 中,最后一個(gè) action 不能綁定任何名字。我們?cè)谥笾v解 Monad 的時(shí)候會(huì)說(shuō)明為什么。現(xiàn)在你可以先想成 do block 會(huì)自動(dòng)從最后一個(gè) action 取出值并綁定給他的結(jié)果。
除了最后一行之外,其他在 do 中沒(méi)有綁定名字的其實(shí)也可以寫(xiě)成綁定的形式。所以 putStrLn "BLAH" 可以寫(xiě)成 _ <- putStrLn "BLAH"。但這沒(méi)什么實(shí)際的意義,所以我們寧愿寫(xiě)成 putStrLn something。
初學(xué)者有時(shí)候會(huì)想錯(cuò)
name = getLine
以為這行會(huì)讀取輸入并給他綁定一個(gè)名字叫 name 但其實(shí)只是把 getLine 這個(gè) I/O action 指定一個(gè)名字叫 name 罷了。記住,要從一個(gè) I/O action 中取出值,你必須要在另一個(gè) I/O action 中將他用 <- 綁定給一個(gè)名字。
I/O actions 只會(huì)在綁定給 main 的時(shí)候或是在另一個(gè)用 do 串起來(lái)的 I/O action 才會(huì)執(zhí)行。你可以用 do 來(lái)串接 I/O actions,再用 do 來(lái)串接這些串接起來(lái)的 I/O actions。不過(guò)只有最外面的 I/O action 被指定給 main 才會(huì)觸發(fā)執(zhí)行。
喔對(duì),其實(shí)還有另外一個(gè)情況。就是在 GHCi 中輸入一個(gè) I/O action 并按下 Enter 鍵,那也會(huì)被執(zhí)行
ghci> putStrLn "HEEY"
HEEY
就算我們只是在 GHCi 中打幾個(gè)數(shù)字或是調(diào)用一個(gè)函數(shù),按下 Enter 就會(huì)計(jì)算它并調(diào)用 show,再用 putStrLn 將字串打印出在終端上。
還記得 let binding 嗎?如果不記得,回去溫習(xí)一下這個(gè)章節(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 跟其中的名字的,由于對(duì)齊在 Haskell 中并不會(huì)被無(wú)視,這么編排才是好的習(xí)慣。我們的程序用 map toUpper firstName 將 "John" 轉(zhuǎn)成大寫(xiě)的 "JOHN",并將大寫(xiě)的結(jié)果綁定到一個(gè)名字上,之后在輸出的時(shí)候參考到了這個(gè)名字。
你也許會(huì)問(wèn)究竟什么時(shí)候要用 <-,什么時(shí)候用 let bindings?記住,<- 是用來(lái)運(yùn)算 I/O actions 并將他的結(jié)果綁定到名稱。而 map toUpper firstName 并不是一個(gè) I/O action。他只是一個(gè)純粹的 expression。所以總結(jié)來(lái)說(shuō),當(dāng)你要綁定 I/O actions 的結(jié)果時(shí)用 <-,而對(duì)于純粹的 expression 使用 let bindings。對(duì)于錯(cuò)誤的 let firstName = getLine,我們只不過(guò)是把 getLine 這個(gè) I/O actions 給了一個(gè)不同的名字罷了。最后還是要用 <- 將結(jié)果取出。
現(xiàn)在我們來(lái)寫(xiě)一個(gè)會(huì)一行一行不斷地讀取輸入,并將讀進(jìn)來(lái)的字反過(guò)來(lái)輸出到屏幕上的程序。程序會(huì)在輸入空白行的時(shí)候停止。
main = do
line <- getLine
if null line
then return ()
else do
putStrLn $ reverseWords line
main
reverseWords :: String -> String
reverseWords = unwords . map reverse . words
在分析這段程序前,你可以執(zhí)行看看來(lái)感受一下程序的運(yùn)行。
首先,我們來(lái)看一下 reverseWords。他不過(guò)是一個(gè)普通的函數(shù),假如接受了個(gè)字串 "hey there man",他會(huì)先調(diào)用 words 來(lái)產(chǎn)生一個(gè)字的串列 ["hey", "there", "man"]。然后用 reverse 來(lái) map 整個(gè)串列,得到 ["yeh", "ereht", "nam"],接著用 unwords 來(lái)得到最終的結(jié)果 "yeh ereht nam"。這些用函數(shù)合成來(lái)簡(jiǎn)潔的表達(dá)。如果沒(méi)有用函數(shù)合成,那就會(huì)寫(xiě)成丑丑的樣子 reverseWords st = unwords (map reverse (words st))
那 main 又是怎么一回事呢?首先,我們用 getLine 從終端讀取了一行,并把這行輸入取名叫 line。然后接著一個(gè)條件式 expression。記住,在 Haskell 中 if 永遠(yuǎn)要伴隨一個(gè) else,這樣每個(gè) expression 才會(huì)有值。當(dāng) if 的條件是 true (也就是輸入了一個(gè)空白行),我們便執(zhí)行一個(gè) I/O action,如果 if 的條件是 false,那 else 底下的 I/O action 被執(zhí)行。這也就是說(shuō)當(dāng) if 在一個(gè) I/O do block 中的時(shí)候,長(zhǎng)的樣子是 if condition then I/O action else I/O action。
我們首先來(lái)看一下在 else 中發(fā)生了什么事。由于我們?cè)?else 中只能有一個(gè) I/O action,所以我們用 do 來(lái)將兩個(gè) I/O actions 綁成一個(gè),你可以寫(xiě)成這樣:
else (do
putStrLn $ reverseWords line
main)
這樣可以明顯看到整個(gè) do block 可以看作一個(gè) I/O action,只是比較丑。但總之,在 do block 里面,我們依序調(diào)用了 getLine 以及 reverseWords,在那之后,我們遞歸調(diào)用了 main。由于 main 也是一個(gè) I/O action,所以這不會(huì)造成任何問(wèn)題。調(diào)用 main 也就代表我們回到程序的起點(diǎn)。
那假如 null line 的結(jié)果是 true 呢?也就是說(shuō) then 的區(qū)塊被執(zhí)行。我們看一下區(qū)塊里面有 then return ()。如果你是從 C、Java 或 Python 過(guò)來(lái)的,你可能會(huì)認(rèn)為 return 不過(guò)是作一樣的事情便跳過(guò)這一段。但很重要的: return 在 Hakell 里面的意義跟其他語(yǔ)言的 return 完全不同!他們有相同的樣貌,造成了許多人搞錯(cuò),但確實(shí)他們是不一樣的。在命令式語(yǔ)言中,return 通常結(jié)束 method 或 subroutine 的執(zhí)行,并且回傳某個(gè)值給調(diào)用者。在 Haskell 中,他的意義則是利用某個(gè) pure value 造出 I/O action。用之前盒子的比喻來(lái)說(shuō),就是將一個(gè) value 裝進(jìn)箱子里面。產(chǎn)生出的 I/O action 并沒(méi)有作任何事,只不過(guò)將 value 包起來(lái)而已。所以在 I/O 的情況下來(lái)說(shuō),return "haha" 的型態(tài)是 IO String。將 pure value 包成 I/O action 有什么實(shí)質(zhì)意義呢?為什么要弄成 IO 包起來(lái)的值?這是因?yàn)槲覀円欢ㄒ?else 中擺上某些 I/O action,所以我們才用 return () 做了一個(gè)沒(méi)作什么事情的 I/O action。
在 I/O do block 中放一個(gè) return 并不會(huì)結(jié)束執(zhí)行。像下面這個(gè)程序會(huì)執(zhí)行到底。
main = do
return ()
return "HAHAHA"
line <- getLine
return "BLAH BLAH BLAH"
return 4
putStrLn line
所有在程序中的 return 都是將 value 包成 I/O actions,而且由于我們沒(méi)有將他們綁定名稱,所以這些結(jié)果都被忽略。我們能用 <- 與 return 來(lái)達(dá)到綁定名稱的目的。
main = do
a <- return "hell"
b <- return "yeah!"
putStrLn $ a ++ " " ++ b
可以看到 return 與 <- 作用相反。return 把 value 裝進(jìn)盒子中,而 <- 將 value 從盒子拿出來(lái),并綁定一個(gè)名稱。不過(guò)這么做是有些多余,因?yàn)槟憧梢杂?let bindings 來(lái)綁定
main = do
let a = "hell"
b = "yeah"
putStrLn $ a ++ " " ++ b
在 I/O do block 中需要 return 的原因大致上有兩個(gè):一個(gè)是我們需要一個(gè)什么事都不做的 I/O action,或是我們不希望這個(gè) do block 形成的 I/O action 的結(jié)果值是這個(gè) block 中的最后一個(gè) I/O action,我們希望有一個(gè)不同的結(jié)果值,所以我們用 return 來(lái)作一個(gè) I/O action 包了我們想要的結(jié)果放在 do block 的最后。
在我們接下去講文件之前,讓我們來(lái)看看有哪些實(shí)用的函數(shù)可以處理 I/O。
putStr 跟 putStrLn 幾乎一模一樣,都是接受一個(gè)字串當(dāng)作參數(shù),并回傳一個(gè) I/O action 打印出字串到終端上,只差在 putStrLn 會(huì)換行而 putStr 不會(huì)罷了。
main = do putStr "Hey, "
putStr "I'm "
putStrLn "Andy!"
$ runhaskell putstr_test.hs
Hey, I'm Andy!
他的 type signature 是 putStr :: String -> IO (),所以是一個(gè)包在 I/O action 中的 unit。也就是空值,沒(méi)有辦法綁定他。
putChar 接受一個(gè)字符,并回傳一個(gè) I/O action 將他打印到終端上。
main = do putChar 't'
putChar 'e'
putChar 'h'
$ runhaskell putchar_test.hs
teh
putStr 實(shí)際上就是 putChar 遞歸定義出來(lái)的。putStr 的邊界條件是空字串,所以假設(shè)我們打印一個(gè)空字串,那他只是回傳一個(gè)什么都不做的 I/O action,像 return ()。如果打印的不是空字串,那就先用 putChar 打印出字串的第一個(gè)字符,然后再用 putStr 打印出字串剩下部份。
putStr :: String -> IO ()
putStr [] = return ()
putStr (x:xs) = do
putChar x
putStr xs
看看我們?nèi)绾卧?I/O 中使用遞歸,就像我們?cè)?pure code 中所做的一樣。先定義一個(gè)邊界條件,然后再思考剩下如何作。
print 接受任何是 Show typeclass 的 instance 的型態(tài)的值,這代表我們知道如何用字串表示他,調(diào)用 show 來(lái)將值變成字串然后將其輸出到終端上?;旧希褪?nbsp;putStrLn . show。首先調(diào)用 show 然后把結(jié)果喂給 putStrLn,回傳一個(gè) 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]
就像你看到的,這是個(gè)很方便的函數(shù)。還記得我們提到 I/O actions 只有在 main 中才會(huì)被執(zhí)行以及在 GHCI 中運(yùn)算的事情嗎?當(dāng)我們用鍵盤(pán)打了些值,像 3 或 [1,2,3] 并按下 Enter,GHCI 實(shí)際上就是用了 print 來(lái)將這些值輸出到終端。
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)我們需要打印出字串,我們會(huì)用 putStrLn,因?yàn)槲覀儾幌胍車(chē)幸?hào),但對(duì)于輸出值來(lái)說(shuō),print 才是最常用的。
getChar 是一個(gè)從輸入讀進(jìn)一個(gè)字符的 I/O action,因此他的 type signature 是 getChar :: IO Char,代表一個(gè) I/O action 的結(jié)果是 Char。注意由于緩沖區(qū)的關(guān)系,只有當(dāng) Enter 被按下的時(shí)候才會(huì)觸發(fā)讀取字符的行為。
main = do
c <- getChar
if c /= ' '
then do
putChar c
main
else return ()
這程序看起來(lái)像是讀取一個(gè)字符并檢查他是否為一個(gè)空白。如果是的話便停止,如果不是的話便打印到終端上并重復(fù)之前的行為。在某種程度上來(lái)說(shuō)也不能說(shuō)錯(cuò),只是結(jié)果不如你預(yù)期而已。來(lái)看看結(jié)果吧。
$ runhaskell getchar_test.hs
hello sir
hello
上面的第二行是輸入。我們輸入了 hello sir 并按下了 Enter。由于緩沖區(qū)的關(guān)系,程序是在我們按了 Enter 后才執(zhí)行而不是在某個(gè)輸入字符的時(shí)候。一旦我們按下了 Enter,那他就把我們直到目前輸入的一次做完。
when 這函數(shù)可以在 Control.Monad 中找到他 (你必須 import Contorl.Monad 才能使用他)。他在一個(gè) do block 中看起來(lái)就像一個(gè)控制流程的 statement,但實(shí)際上他的確是一個(gè)普通的函數(shù)。他接受一個(gè) boolean 值跟一個(gè) I/O action。如果 boolean 值是 True,便回傳我們傳給他的 I/O action。如果 boolean 值是 False,便回傳 return (),即什么都不做的 I/O action。我們接下來(lái)用 when 來(lái)改寫(xiě)我們之前的程序。
import Control.Monad
main = do
c <- getChar
when (c /= ' ') $ do
putChar c
main
就像你看到的,他可以將 if something then do some I/O action else return () 這樣的模式封裝起來(lái)。
sequence 接受一串 I/O action,并回傳一個(gè)會(huì)依序執(zhí)行他們的 I/O action。運(yùn)算的結(jié)果是包在一個(gè) 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í)可以寫(xiě)成
main = do
rs <- sequence [getLine, getLine, getLine]
print rs
所以 sequence [getLine, getLine, getLine] 作成了一個(gè)執(zhí)行 getLine 三次的 I/O action。如果我們對(duì)他綁定一個(gè)名字,結(jié)果便是這串結(jié)果的串列。也就是說(shuō),三個(gè)用戶輸入的東西組成的串列。
一個(gè)常見(jiàn)的使用方式是我們將 print 或 putStrLn 之類(lèi)的函數(shù) map 到串列上。map print [1,2,3,4] 這個(gè)動(dòng)作并不會(huì)產(chǎn)生一個(gè) I/O action,而是一串 I/O action,就像是 [print 1, print 2, print 3, print 4]。如果我們將一串 I/O action 變成一個(gè) I/O action,我們必須用 sequence
ghci> sequence (map print [1,2,3,4,5])
1
2
3
4
5
[(),(),(),(),()]
那 [(),(),(),(),()] 是怎么回事?當(dāng)我們?cè)?GHCI 中運(yùn)算 I/O action,他會(huì)被執(zhí)行并把結(jié)果打印出來(lái),唯一例外是結(jié)果是 () 的時(shí)候不會(huì)被打印出。這也是為什么 putStrLn "hehe" 在 GHCI 中只會(huì)打印出 hehe(因?yàn)?nbsp;putStrLn "hehe" 的結(jié)果是 ())。但當(dāng)我們使用 getLine 時(shí),由于 getLine 的型態(tài)是 IO String,所以結(jié)果會(huì)被打印出來(lái)。
由于對(duì)一個(gè)串列 map 一個(gè)回傳 I/O action 的函數(shù),然后再 sequence 他這個(gè)動(dòng)作太常用了。所以有一些函數(shù)在函式庫(kù)中 mapM 跟 mapM_。mapM 接受一個(gè)函數(shù)跟一個(gè)串列,將對(duì)串列用函數(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 接受一個(gè) I/O action 并回傳一個(gè)永遠(yuǎn)作同一件事的 I/O action。你可以在 Control.Monad 中找到他。下面的程序會(huì)不斷地要用戶輸入些東西,并把輸入的東西轉(zhuǎn)成大寫(xiě)輸出到屏幕上。
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ù)的順序相反而已。第一個(gè)參數(shù)是串列,而第二個(gè)則是函數(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 ...) 是接受一個(gè)數(shù)字并回傳一個(gè) I/O action 的函數(shù)。我們必須用括號(hào)括住他,不然 lambda 會(huì)貪心 match 的策略會(huì)把最后兩個(gè) I/O action 也算進(jìn)去。注意我們?cè)?do block 里面 return color。我們那么作是讓 do block 的結(jié)果是我們選的顏色。實(shí)際上我們并不需那么作,因?yàn)?nbsp;getLine 已經(jīng)達(dá)到我們的目的。先 color <- getLine 再 return color 只不過(guò)是把值取出再包起來(lái),其實(shí)是跟 getLine 效果相當(dāng)。forM 產(chǎn)生一個(gè) I/O action,我們把結(jié)果綁定到 colors 這名稱。colors 是一個(gè)普通包含字串的串列。最后,我們用 mapM putStrLn colors 打印出所有顏色。
你可以把 forM 的意思想成將串列中的每個(gè)元素作成一個(gè) I/O action。至于每個(gè) I/O action 實(shí)際作什么就要看原本的元素是什么。然后,執(zhí)行這些 I/O action 并將結(jié)果綁定到某個(gè)名稱上?;蚴侵苯訉⒔Y(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 程序會(huì)比較容易理解。正常來(lái)講是我們需要在 map 跟 sequence 的時(shí)候定義 I/O action 的時(shí)候使用 forM,同樣地,我們也可以將最后一行寫(xiě)成 forM colors putStrLn。
在這一節(jié),我們學(xué)會(huì)了輸入與輸出的基礎(chǔ)。我們也了解了什么是 I/O action,他們是如何幫助我們達(dá)成輸入與輸出的目的。這邊重復(fù)一遍,I/O action 跟其他 Haskell 中的 value 沒(méi)有兩樣。我們能夠把他當(dāng)參數(shù)傳給函式,或是函式回傳 I/O action。他們特別之處在于當(dāng)他們是寫(xiě)在 main 里面或 GHCI 里面的時(shí)候,他們會(huì)被執(zhí)行,也就是實(shí)際輸出到你屏幕或輸出音效的時(shí)候。每個(gè) I/O action 也能包著一個(gè)從真實(shí)世界拿回來(lái)的值。
不要把像是 putStrLn 的函式想成接受字串并輸出到屏幕。要想成一個(gè)函式接受字串并回傳一個(gè) I/O action。當(dāng) I/O action 被執(zhí)行的時(shí)候,會(huì)漂亮地打印出你想要的東西。
getChar 是一個(gè)讀取單一字符的 I/O action。getLine 是一個(gè)讀取一行的 I/O action。這是兩個(gè)非常直覺(jué)的函式,多數(shù)編程語(yǔ)言也有類(lèi)似這兩個(gè)函式的 statement 或 function。但現(xiàn)在我們來(lái)看看 getContents。getContents 是一個(gè)從標(biāo)準(zhǔn)輸入讀取直到 end-of-file 字符的 I/O action。他的型態(tài)是 getContents :: IO String。最酷的是 getContents 是惰性 I/O (Lazy I/O)。當(dāng)我們寫(xiě)了 foo <- getContents,他并不會(huì)馬上讀取所有輸入,將他們存在 memory 里面。他只有當(dāng)你真的需要輸入數(shù)據(jù)的時(shí)候才會(huì)讀取。
當(dāng)我們需要重導(dǎo)一個(gè)程序的輸出到另一個(gè)程序的輸入時(shí),getContents 非常有用。假設(shè)我們有下面一個(gè)文本檔:
I'm a lil' teapot
What's with that airplane food, huh?
It's so small, tasteless
還記得我們介紹 forever 時(shí)寫(xiě)的小程序嗎?會(huì)把所有輸入的東西轉(zhuǎn)成大寫(xiě)的那一個(gè)。為了防止你忘記了,這邊再重復(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,會(huì)將指定的文件輸出到屏幕。
$ 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
就如你看到的,我們是用 | 這符號(hào)來(lái)將某個(gè)程序的輸出 piping 到另一個(gè)程序的輸入。我們做的事相當(dāng)于 run 我們的 capslocker,然后將 haiku 的內(nèi)容用鍵盤(pán)打到終端上,最后再按 Ctrl-D 來(lái)代表 end-of-file。這就像執(zhí)行 cat haiku.txt 后大喊,嘿,不要把內(nèi)容打印到終端上,把內(nèi)容塞到 capslocker!
我們用 forever 在做的事基本上就是將輸入經(jīng)過(guò)轉(zhuǎn)換后變成輸出。用 getContents 的話可以讓我們的程序更加精煉。
import Data.Char
main = do
contents <- getContents
putStr (map toUpper contents)
我們將 getContents 取回的字串綁定到 contents。然后用 toUpper map 到整個(gè)字串后打印到終端上。記住字串基本上就是一串惰性的串列 (list),同時(shí) getContents 也是惰性 I/O,他不會(huì)一口氣讀入內(nèi)容然后將內(nèi)容存在內(nèi)存中。實(shí)際上,他會(huì)一行一行讀入并輸出大寫(xiě)的版本,這是因?yàn)檩敵霾攀钦娴男枰斎氲臄?shù)據(jù)的時(shí)候。
$ 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 來(lái)離開(kāi)環(huán)境。就像你看到的,程序是一行一行將我們的輸入打印出來(lái)。當(dāng) getContent 的結(jié)果被綁定到 contents 的時(shí)候,他不是被表示成在內(nèi)存中的一個(gè)字串,反而比較像是他有一天會(huì)是字串的一個(gè)承諾。當(dāng)我們將 toUpper map 到 contents 的時(shí)候,便也是一個(gè)函數(shù)被承諾將會(huì)被 map 到內(nèi)容上。最后 putStr 則要求先前的承諾說(shuō),給我一行大寫(xiě)的字串吧。實(shí)際上還沒(méi)有任何一行被取出,所以便跟 contents 說(shuō),不如從終端那邊取出些字串吧。這才是 getContents 真正從終端讀入一行并把這一行交給程序的時(shí)候。程序便將這一行用 toUpper 處理并交給 putStr,putStr 則打印出他。之后 putStr 再說(shuō):我需要下一行。整個(gè)步驟便再重復(fù)一次,直到讀到 end-of-file 為止。
接著我們來(lái)寫(xiě)個(gè)程序,讀取輸入,并只打印出少于十個(gè)字符的行。
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)用一個(gè)函數(shù),然后把函數(shù)的結(jié)果輸出。
shortLinesOnly 的行為是這樣:拿到一個(gè)字串,像是 "short\nlooooooooooooooong\nshort again"。這字串有三行,前后兩行比較短,中間一行很常。他用 lines 把字串分成 ["short", "looooooooooooooong", "short again"],并把結(jié)果綁定成 allLines。然后過(guò)濾這些字串,只有少于十個(gè)字符的留下,["short", "short again"],最后用 unlines 把這些字串用換行接起來(lái),形成 "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í)在太常用了。常用到甚至建立了一個(gè)函數(shù)叫 interact。interact 接受一個(gè) String -> String 的函數(shù),并回傳一個(gè) I/O action。那個(gè) I/O action 會(huì)讀取一些輸入,調(diào)用提供的函數(shù),然后把函數(shù)的結(jié)果打印出來(lái)。所以我們的程序可以改寫(xiě)成這樣。
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é)果,再拿取另一行。這兩者的差別主要是取決于用戶使用他們的方式。
我們?cè)賮?lái)寫(xiě)另一個(gè)程序,它不斷地讀取一行行并告訴我們那一行字串是不是一個(gè)回文本串 (palindrome)。我們當(dāng)然可以用 getLine 讀取一行然后再調(diào)用 main 作同樣的事。不過(guò)同樣的事情可以用 interact 更簡(jiǎn)潔地達(dá)成。當(dāng)使用 interact 的時(shí)候,想像你是將輸入經(jīng)有某些轉(zhuǎn)換成輸出。在這個(gè)情況當(dāng)中,我們要將每一行輸入轉(zhuǎn)換成 "palindrome" 或 "not a palindrome"。所以我們必須寫(xiě)一個(gè)函數(shù)將 "elephant\nABCBA\nwhatever" 轉(zhuǎn)換成 not a palindrome\npalindrome\nnot a palindrome"。來(lái)動(dòng)手吧!
respondPalindromes contents = unlines (map (\xs ->
if isPalindrome xs then "palindrome" else "not a palindrome") (lines contents))
where isPalindrome xs = xs == reverse xs
再來(lái)將程序改寫(xiě)成 point-free 的形式
respondPalindromes = unlines . map (\xs ->
if isPalindrome xs then "palindrome" else "not a palindrome") . lines
where isPalindrome xs = xs == reverse xs
很直覺(jué)吧!首先將 "elephant\nABCBA\nwhatever" 變成 ["elephant", "ABCBA", "whatever"] 然后將一個(gè) lambda 函數(shù) map 它,["not a palindrome", "palindrome", "not a palindrome"] 然后用 unlines 變成一行字串。接著
main = interact respondPalindromes
來(lái)測(cè)試一下吧。
$ runhaskell palindrome.hs
hehe
not a palindrome
ABCBA
palindrome
cookie
not a palindrome
即使我們的程序是把一大把字串轉(zhuǎn)換成另一個(gè),其實(shí)他表現(xiàn)得好像我們是一行一行做的。這是因?yàn)?Haskell 是惰性的,程序想要打印出第一行結(jié)果時(shí),他必須要先有第一行輸入。所以一旦我們給了第一行輸入,他便打印出第一行結(jié)果。我們用 end-of-line 字符來(lái)結(jié)束程序。
我們也可以用 pipe 的方式將輸入喂給程序。假設(shè)我們有這樣一個(gè)文件。
dogaroo
radar
rotor
madam
將他存為 words.txt,將他喂給程序后得到的結(jié)果
$ cat words.txt | runhaskell palindromes.hs
not a palindrome
palindrome
palindrome
palindrome
再一次地提醒,我們得到的結(jié)果跟我們自己一個(gè)一個(gè)字打進(jìn)輸入的內(nèi)容是一樣的。我們看不到 palindrome.hs 輸入的內(nèi)容是因?yàn)閮?nèi)容來(lái)自于文件。
你應(yīng)該大致了解 Lazy I/O 是如何運(yùn)作,并能善用他的優(yōu)點(diǎn)。他可以從輸入轉(zhuǎn)換成輸出的角度方向思考。由于 Lazy I/O,沒(méi)有輸入在被用到之前是真的被讀入。
到目前為止,我們的示范都是從終端讀取某些東西或是打印出某些東西到終端。但如果我們想要讀寫(xiě)文件呢?其實(shí)從某個(gè)角度來(lái)說(shuō)我們已經(jīng)作過(guò)這件事了。我們可以把讀寫(xiě)終端想成讀寫(xiě)文件。只是把文件命名成 stdout 跟 stdin 而已。他們分別代表標(biāo)準(zhǔn)輸出跟標(biāo)準(zhǔn)輸入。我們即將看到的讀寫(xiě)文件跟讀寫(xiě)終端并沒(méi)什么不同。
首先來(lái)寫(xiě)一個(gè)程序,他會(huì)開(kāi)啟一個(gè)叫 girlfriend.txt 的文件,文件里面有 Avril Lavigne 的暢銷(xiāo)名曲 Girlfriend,并將內(nèi)容打印到終端上。接下來(lá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!
我們來(lái)一行行看一下程序。我們的程序用 do 把好幾個(gè) I/O action 綁在一起。在 do block 的第一行,我們注意到有一個(gè)新的函數(shù)叫 openFile。他的 type signature 是 openFile :: FilePath -> IOMode -> IO Handle。他說(shuō)了 openFile 接受一個(gè)文件路徑跟一個(gè) IOMode,并回傳一個(gè) I/O action,他會(huì)打開(kāi)一個(gè)文件并把文件關(guān)聯(lián)到一個(gè) handle。
FilePath 不過(guò)是 String 的 type synonym。
type FilePath = String
IOMode 則是一個(gè)定義如下的型態(tài)
data IOMode = ReadMode | WriteMode | AppendMode | ReadWriteMode
就像我們之前定義的型態(tài),分別代表一個(gè)星期的七天。這個(gè)型態(tài)代表了我們想對(duì)打開(kāi)的文件做什么。很簡(jiǎn)單吧。留意到我們的型態(tài)是 IOMode 而不是 IO Mode。IO Mode 代表的是一個(gè) I/O action 包含了一個(gè)型態(tài)為 Mode 的值,但 IOMode 不過(guò)是一個(gè)陽(yáng)春的 enumeration。
最后,他回傳一個(gè) I/O action 會(huì)將指定的文件用指定的模式打開(kāi)。如果我們將 I/O action 綁定到某個(gè)東西,我們會(huì)得到一個(gè) Handle。型態(tài)為 Handle 的值代表我們的文件在哪里。有了 handle 我們才知道要從哪個(gè)文件讀取內(nèi)容。想讀取文件但不將文件綁定到 handle 上這樣做是很蠢的。所以,我們將一個(gè) handle 綁定到 handle。
接著一行,我們看到一個(gè)叫 hGetContents 的函數(shù)。他接了一個(gè) Handle,所以他知道要從哪個(gè)文件讀取內(nèi)容并回傳一個(gè) IO String。一個(gè)包含了文件內(nèi)容的 I/O action。這函數(shù)跟 getContents 差不多。唯一的差別是 getContents 會(huì)自動(dòng)從標(biāo)準(zhǔn)輸入讀取內(nèi)容(也就是終端),而 hGetContents 接了一個(gè) file handle,這 file handle 告訴他讀取哪個(gè)文件。除此之外,他們都是一樣的。就像 getContents,hGetContents 不會(huì)把文件一次都拉到內(nèi)存中,而是有必要才會(huì)讀取。這非???,因?yàn)槲覀儼?nbsp;contents 當(dāng)作是整個(gè)文件般用,但他實(shí)際上不在內(nèi)存中。就算這是個(gè)很大的文件,hGetContents 也不會(huì)塞爆你的內(nèi)存,而是只有必要的時(shí)候才會(huì)讀取。
要留意文件的 handle 還有文件的內(nèi)容兩個(gè)概念的差異,在我們的程序中他們分別被綁定到 handle 跟 contents 兩個(gè)名字。handle 是我們拿來(lái)區(qū)分文件的依據(jù)。如果你把整個(gè)文件系統(tǒng)想成一本厚厚的書(shū),每個(gè)文件分別是其中的一個(gè)章節(jié),handle 就像是書(shū)簽一般標(biāo)記了你現(xiàn)在正在閱讀(或?qū)懭耄┠囊粋€(gè)章節(jié),而內(nèi)容則是章節(jié)本身。
我們使用 putStr contents 打印出內(nèi)容到標(biāo)準(zhǔn)輸出,然后我們用了 hClose。他接受一個(gè) handle 然后回傳一個(gè)關(guān)掉文件的 I/O action。在用了 openFile 之后,你必須自己把文件關(guān)掉。
要達(dá)到我們目的的另一種方式是使用 withFile,他的 type signature 是 withFile :: FilePath -> IOMode -> (Handle -> IO a) -> IO a。他接受一個(gè)文件路徑,一個(gè) IOMode 以及一個(gè)函數(shù),這函數(shù)則接受一個(gè) handle 跟一個(gè) I/O action。withFile 最后回傳一個(gè)會(huì)打開(kāi)文件,對(duì)文件作某件事然后關(guān)掉文件的 I/O action。處理的結(jié)果是包在最后的 I/O action 中,這結(jié)果跟我們給的函數(shù)的回傳是相同的。這聽(tīng)起來(lái)有些復(fù)雜,但其實(shí)很簡(jiǎn)單,特別是我們有 lambda,來(lái)看看我們用 withFile 改寫(xiě)前面程序的一個(gè)范例:
import System.IO
main = do
withFile "girlfriend.txt" ReadMode (\handle -> do
contents <- hGetContents handle
putStr contents)
正如你看到的,程序跟之前的看起來(lái)很像。(\handle -> ... ) 是一個(gè)接受 handle 并回傳 I/O action 的函數(shù),他通常都是用 lambda 來(lái)表示。我們需要一個(gè)回傳 I/O action 的函數(shù)的理由而不是一個(gè)本身作處理并關(guān)掉文件的 I/O action,是因?yàn)檫@樣一來(lái)那個(gè) I/O action 不會(huì)知道他是對(duì)哪個(gè)文件在做處理。用 withFile 的話,withFile 會(huì)打開(kāi)文件并把 handle 傳給我們給他的函數(shù),之后他則拿到一個(gè) I/O action,然后作成一個(gè)我們描述的 I/O action,最后關(guān)上文件。例如我們可以這樣自己作一個(gè) 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
我們知道要回傳的是一個(gè) I/O action,所以我們先放一個(gè) do。首先我們打開(kāi)文件,得到一個(gè) handle。然后我們 apply handle 到我們的函數(shù),并得到一個(gè)做事的 I/O action。我們綁定那個(gè) I/O action 到 result 這個(gè)名字,關(guān)上 handle 并 return result。return 的作用把從 f 得到的結(jié)果包在 I/O action 中,這樣一來(lái) I/O action 中就包含了 f handle 得到的結(jié)果。如果 f handle 回傳一個(gè)從標(biāo)準(zhǔn)輸入讀去數(shù)行并寫(xiě)到文件然后回傳讀入的行數(shù)的 I/O action,在 withFile' 的情形中,最后的 I/O action 就會(huì)包含讀入的行數(shù)。
就像 hGetContents 對(duì)應(yīng) getContents 一樣,只不過(guò)是針對(duì)某個(gè)文件。我們也有 hGetLine、hPutStr、hPutStrLn、hGetChar 等等。他們分別是少了 h 的那些函數(shù)的對(duì)應(yīng)。只不過(guò)他們要多拿一個(gè) handle 當(dāng)參數(shù),并且是針對(duì)特定文件而不是標(biāo)準(zhǔn)輸出或標(biāo)準(zhǔn)輸入。像是 putStrLn 是一個(gè)接受一個(gè)字串并回傳一個(gè)打印出加了換行字符的字串的 I/O action 的函數(shù)。hPutStrLn 接受一個(gè) handle 跟一個(gè)字串,回傳一個(gè)打印出加了換行字符的字串到文件的 I/O action。以此類(lèi)推,hGetLine 接受一個(gè) handle 然后回傳一個(gè)從文件讀取一行的 I/O action。
讀取文件并對(duì)他們的字串內(nèi)容作些處理實(shí)在太常見(jiàn)了,常見(jiàn)到我們有三個(gè)函數(shù)來(lái)更進(jìn)一步簡(jiǎn)化我們的工作。
readFile 的 type signature 是 readFile :: FilePath -> IO String。記住,F(xiàn)ilePath 不過(guò)是 String 的一個(gè)別名。readFile 接受一個(gè)文件路徑,回傳一個(gè)惰性讀取我們文件的 I/O action。然后將文件的內(nèi)容綁定到某個(gè)字串。他比起先 openFile,綁定 handle,然后 hGetContents 要好用多了。這邊是一個(gè)用 readFile 改寫(xiě)之前例子的范例:
import System.IO
main = do
contents <- readFile "girlfriend.txt"
putStr contents
由于我們拿不到 handle,所以我們也無(wú)法關(guān)掉他。這件事 Haskell 的 readFile 在背后幫我們做了。
writeFile 的型態(tài)是 writefile :: FilePath -> String -> IO ()。他接受一個(gè)文件路徑,以及一個(gè)要寫(xiě)到文件中的字串,并回傳一個(gè)寫(xiě)入動(dòng)作的 I/O action。如果這個(gè)文件已經(jīng)存在了,他會(huì)先把文件內(nèi)容都砍了再寫(xiě)入。下面示范了如何把 girlfriend.txt 的內(nèi)容轉(zhuǎn)成大寫(xiě)然后寫(xiě)入到 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 并不會(huì)在文件存在時(shí)把文件內(nèi)容砍掉而是接在后面。
假設(shè)我們有一個(gè)文件叫 todo.txt``,里面每一行是一件要做的事情?,F(xiàn)在我們寫(xiě)一個(gè)程序,從標(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 回傳的值不會(huì)有換行字符,我們需要在每一行最后加上 "\n"。
還有一件事,我們提到 contents <- hGetContents handle 是惰性 I/O,不會(huì)將文件一次都讀到內(nèi)存中。 所以像這樣寫(xiě)的話:
main = do
withFile "something.txt" ReadMode (\handle -> do
contents <- hGetContents handle
putStr contents)
實(shí)際上像是用一個(gè) pipe 把文件弄到標(biāo)準(zhǔn)輸出。正如你可以把 list 想成 stream 一樣,你也可以把文件想成 stream。他會(huì)每次讀一行然后打印到終端上。你也許會(huì)問(wèn)這個(gè) pipe 究竟一次可以塞多少東西,讀去硬盤(pán)的頻率究竟是多少?對(duì)于文本檔而言,缺省的 buffer 通常是 line-buffering。這代表一次被讀進(jìn)來(lái)的大小是一行。這也是為什么在這個(gè) case 我們是一行一行處理。對(duì)于 binary file 而言,缺省的 buffer 是 block-buffering。這代表我們是一個(gè) chunk 一個(gè) chunk 去讀得。而一個(gè) chunk 的大小是根據(jù)操作系統(tǒng)不同而不同。
你能用 hSetBuffering 來(lái)控制 buffer 的行為。他接受一個(gè) handle 跟一個(gè) BufferMode,回傳一個(gè)會(huì)設(shè)置 buffer 行為的 I/O action。BufferMode 是一個(gè) enumeration 型態(tài),他可能的值有:NoBuffering, LineBuffering 或 BlockBuffering (Maybe Int)。其中 Maybe Int 是表示一個(gè) chunck 有幾個(gè) byte。如果他的值是 Nothing,則操作系統(tǒng)會(huì)幫你決定 chunk 的大小。NoBuffering 代表我們一次讀一個(gè) character。一般來(lái)說(shuō) NoBuffering 的表現(xiàn)很差,因?yàn)樗L問(wèn)硬盤(pán)的頻率很高。
接下來(lái)是我們把之前的范例改寫(xiě)成用 2048 bytes 的 chunk 讀取,而不是一行一行讀。
main = do
withFile "something.txt" ReadMode (\handle -> do
hSetBuffering handle $ BlockBuffering (Just 2048)
contents <- hGetContents handle
putStr contents)
用更大的 chunk 來(lái)讀取對(duì)于減少訪問(wèn)硬盤(pán)的次數(shù)是有幫助的,特別是我們的文件其實(shí)是透過(guò)網(wǎng)絡(luò)來(lái)訪問(wèn)。
我們也可以使用 hFlush,他接受一個(gè) handle 并回傳一個(gè)會(huì) flush buffer 到文件的 I/O action。當(dāng)我們使用 line-buffering 的時(shí)候,buffer 在每一行都會(huì)被 flush 到文件。當(dāng)我們使用 block-buffering 的時(shí)候,是在我們讀每一個(gè) chunk 作 flush 的動(dòng)作。flush 也會(huì)發(fā)生在關(guān)閉 handle 的時(shí)候。這代表當(dāng)我們碰到換行字符的時(shí)候,讀或?qū)懙膭?dòng)作都會(huì)停止并回報(bào)手邊的數(shù)據(jù)。但我們能使用 hFlush 來(lái)強(qiáng)迫回報(bào)所有已經(jīng)在 buffer 中的數(shù)據(jù)。經(jīng)過(guò) flushing 之后,數(shù)據(jù)也就能被其他程序看見(jiàn)。
把 block-buffering 的讀取想成這樣:你的馬桶會(huì)在水箱有一加侖的水的時(shí)候自動(dòng)沖水。所以你不斷灌水進(jìn)去直到一加侖,馬桶就會(huì)自動(dòng)沖水,在水里面的數(shù)據(jù)也就會(huì)被看到。但你也可以手動(dòng)地按下沖水鈕來(lái)沖水。他會(huì)讓現(xiàn)有的水被沖走。沖水這個(gè)動(dòng)作就是 hFlush 這個(gè)名字的含意。
我們已經(jīng)寫(xiě)了一個(gè)將 item 加進(jìn) to-do list 里面的程序,現(xiàn)在我們想加進(jìn)移除 item 的功能。我先把代碼粘貼然后講解他。我們會(huì)使用一些新面孔像是 System.Directory 以及 System.IO 里面的函數(shù)。
來(lái)看一下我們包含移除功能的程序:
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"
一開(kāi)始,我們用 read mode 打開(kāi) todo.txt,并把他綁定到 handle。
接著,我們使用了一個(gè)之前沒(méi)用過(guò)在 System.IO 中的函數(shù) openTempFile。他的名字淺顯易懂。他接受一個(gè)暫存的文件夾跟一個(gè)樣板文件名,然后打開(kāi)一個(gè)暫存盤(pán)。我們使用 "." 當(dāng)作我們的暫存文件夾,因?yàn)?nbsp;. 在幾乎任何操作系統(tǒng)中都代表了現(xiàn)在所在的文件夾。我們使用 "temp" 當(dāng)作我們暫存盤(pán)的樣板名,他代表暫存盤(pán)的名字會(huì)是 temp 接上某串隨機(jī)字串。他回傳一個(gè)創(chuàng)建暫存盤(pán)的 I/O action,然后那個(gè) I/O action 的結(jié)果是一個(gè) pair:暫存盤(pán)的名字跟一個(gè) handle。我們當(dāng)然可以隨便開(kāi)啟一個(gè) todo2.txt 這種名字的文件。但使用 openTempFile 會(huì)是比較好的作法,這樣你不會(huì)不小心覆寫(xiě)任何文件。
我們不用 getCurrentDirectory 的來(lái)拿到現(xiàn)在所在文件夾而用 "." 的原因是 . 在 unix-like 系統(tǒng)跟 Windows 中都表示現(xiàn)在的文件夾。
然后,我們綁定 todo.txt 的內(nèi)容成 contents。把字串?dāng)喑梢淮执總€(gè)字串代表一行。todoTasks 就變成 ["Iron the dishes", "Dust the dog", "Take salad out of the oven"]。我們用一個(gè)會(huì)把 3 跟 "hey" 變成 "3 - hey" 的函數(shù),然后從 0 開(kāi)始把這個(gè)串列 zip 起來(lái)。所以 numberedTasks 就是 ["0 - Iron the dishes", "1 - Dust the dog" ...。我們用 unlines 把這個(gè)串列變成一行,然后打印到終端上。注意我們也有另一種作法,就是用 mapM putStrLn numberedTasks。
我們問(wèn)用戶他們想要?jiǎng)h除哪一個(gè)并且等著他們輸入一個(gè)數(shù)字。假設(shè)他們想要?jiǎng)h除 1 號(hào),那代表 Dust the dog,所以他們輸入 1。于是 numberString 就代表 "1"。由于我們想要一個(gè)數(shù)字,而不是一個(gè)字串,所以我們用對(duì) 1 使用 read,并且綁定到 number。
還記得在 Data.List 中的 delete 跟 !! 嗎?!! 回傳某個(gè) index 的元素,而 delete 刪除在串列中第一個(gè)發(fā)現(xiàn)的元素,然后回傳一個(gè)新的沒(méi)有那個(gè)元素的串列。(todoTasks !! number) (number 代表 1) 回傳 "Dust the dog"。我們把 todoTasks 去掉第一個(gè) "Dust the dog" 后的串列綁定到 newTodoItems,然后用 unlines 變成一行然后寫(xiě)到我們所打開(kāi)的暫存盤(pán)。舊有的文件并沒(méi)有變動(dòng),而暫存盤(pán)包含砍掉那一行后的所有內(nèi)容。
在我們關(guān)掉源文件跟暫存盤(pán)之后我們用 removeFile 來(lái)移除原本的文件。他接受一個(gè)文件路徑并且刪除文件。刪除舊得 todo.txt 之后,我們用 renameFile 來(lái)將暫存盤(pán)重命名成 todo.txt。特別留意 removeFile 跟 renameFile(兩個(gè)都在 System.Directory 中)接受的是文件路徑,而不是 handle。
這就是我們要的,實(shí)際上我們可以用更少行寫(xiě)出同樣的程序,但我們很小心地避免覆寫(xiě)任何文件,并詢問(wèn)操作系統(tǒng)我們可以把暫存盤(pán)擺在哪?讓我們來(lái)執(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
如果你想要寫(xiě)一個(gè)在終端里運(yùn)行的程序,處理命令行引數(shù)是不可或缺的。幸運(yùn)的是,利用 Haskell 的 Standard Libary 能讓我們有效地處理命令行引數(shù)。
在之前的章節(jié)中,我們寫(xiě)了一個(gè)能將 to-do item 加進(jìn)或移除 to-do list 的一個(gè)程序。但我們的寫(xiě)法有兩個(gè)問(wèn)題。第一個(gè)是我們把放 to-do list 的文件名稱給寫(xiě)死了。我們擅自決定用戶不會(huì)有很多個(gè) to-do lists,就把文件命名為 todo.txt。
一種解決的方法是每次都詢問(wèn)用戶他們想將他們的 to-do list 放進(jìn)哪個(gè)文件。我們?cè)谟脩粢獎(jiǎng)h除的時(shí)候也采用這種方式。這是一種可以運(yùn)作的方式,但不太能被接受,因?yàn)樗枰脩暨\(yùn)行程序,等待程序詢問(wèn)才能回答。這被稱為交互式的程序,但討厭的地方在當(dāng)你想要自動(dòng)化執(zhí)行程序的時(shí)候,好比說(shuō)寫(xiě)成 script,這會(huì)讓你的 script 寫(xiě)起來(lái)比較困難。
這也是為什么有時(shí)候讓用戶在執(zhí)行的時(shí)候就告訴程序他們要什么會(huì)比較好,而不是讓程序去問(wèn)用戶要什么。比較好的方式是讓用戶透過(guò)命令行引數(shù)告訴程序他們想要什么。
在 System.Environment 模塊當(dāng)中有兩個(gè)很酷的 I/O actions,一個(gè)是 getArgs,他的 type 是 getArgs :: IO [String],他是一個(gè)拿取命令行引數(shù)的 I/O action,并把結(jié)果放在包含的一個(gè)串列中。getProgName 的型態(tài)是 getProgName :: IO String,他則是一個(gè) I/O action 包含了程序的名稱。
我們來(lái)看一個(gè)展現(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 中的每個(gè)引數(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)在你能寫(xiě)幾個(gè)很酷的命令行程序。在之前的章節(jié),我們寫(xiě)了一個(gè)程序來(lái)加入待作事項(xiàng),也寫(xiě)了另一個(gè)程序刪除事項(xiàng)?,F(xiàn)在我們要把兩個(gè)程序合起來(lái),他會(huì)根據(jù)命令行引數(shù)來(lái)決定該做的事情。我們也會(huì)讓程序可以處理不同的文件,而不是只有 todo.txt
我們叫這程序 todo,他會(huì)作三件事:
# 查看待作事項(xiàng)
# 加入待作事項(xiàng)
# 刪除待作事項(xiàng)
我們暫不考慮不合法的輸入這件事。
我們的程序要像這樣運(yùn)作:假如我們要加入 Find the magic sword of power,則我們會(huì)打 todo add todo.txt "Find the magic sword of power"。要查看事項(xiàng)我們則會(huì)打 todo view todo.txt,如果要移除事項(xiàng)二則會(huì)打 todo remove todo.txt 2
我們先作一個(gè)分發(fā)的 association list。他會(huì)把命令行引數(shù)當(dāng)作 key,而對(duì)應(yīng)的處理函數(shù)當(dāng)作 value。這些函數(shù)的型態(tài)都是 [String] -> IO ()。他們會(huì)接受命令行引數(shù)的串列并回傳對(duì)應(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 開(kāi)始講吧:
main = do
(command:args) <- getArgs
let (Just action) = lookup command dispatch
action args
首先,我們?nèi)〕鲆龜?shù)并把他們綁定到 (command:args)。如果你還記得 pattern matching,這么做會(huì)把第一個(gè)引數(shù)綁定到 command,把其他的綁定到 args。如果我們像這樣執(zhí)行程序 todo add todo.txt "Spank the monkey",command 會(huì)變成 "add",而 args 會(huì)變成 ["todo.txt", "Spank the monkey"]。
在下一行,我們?cè)谝粋€(gè)分派的串列中尋到我們的指令是哪個(gè)。由于 "add" 指向 add,我們的結(jié)果便是 Just add。我們?cè)俣仁褂昧?pattern matching 來(lái)把我們的函數(shù)從 Maybe 中取出。但如果我們想要的指令不在分派的串列中呢?那樣 lookup 就會(huì)回傳 Nothing,但我們這邊并不特別處理失敗的情況,所以 pattern matching 會(huì)失敗然后我們的程序就會(huì)當(dāng)?shù)簟?/p>
最后,我們用剩下的引數(shù)調(diào)用 action 這個(gè)函數(shù)。他會(huì)還傳一個(gè)加入 item,顯示所有 items 或者刪除 item 的 I/O action。由于這個(gè) I/O action 是在 main 的 do block 中,他最后會(huì)被執(zhí)行。如果我們的 action 函數(shù)是 add,他就會(huì)被喂 args 然后回傳一個(gè)加入 Spank the monkey 到 todo.txt 中的 I/O action。
我們剩下要做的就是實(shí)作 add,view 跟 remove,我們從 add 開(kāi)始:
add :: [String] -> IO ()
add [fileName, todoItem] = appendFile fileName (todoItem ++ "\n")
如果我們這樣執(zhí)行程序 todo add todo.txt "Spank the monkey",則 "add" 會(huì)被綁定到 command,而 ["todo.txt", "Spank the monkey"] 會(huì)被帶到從 dispatch list 中拿到的函數(shù)。
由于我們不處理不合法的輸入,我們只針對(duì)這兩項(xiàng)作 pattern matching,然后回傳一個(gè)附加一行到文件末尾的 I/O action。
接著,我們來(lái)實(shí)作查看串列。如果我們想要查看所有 items,我們會(huì) todo view todo.txt。所以 command 會(huì)是 "view",而 args 會(huì)是 ["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)容而已,
最后,我們要來(lái)實(shí)作 remove。他基本上跟之前寫(xiě)的只有刪除功能的程序很像,所以如果你不知道刪除是怎么做的,可以去看之前的解釋。主要的差別是我們不寫(xiě)死 todo.txt,而是從參數(shù)取得。我們也不會(huì)提示用戶要?jiǎng)h除哪一號(hào)的 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
我們打開(kāi) fileName 的文件以及一個(gè)暫存。刪除用戶要我們刪的那一行后,把文件內(nèi)容寫(xiě)到暫存盤(pán)??车粼镜奈募缓蟀褧捍姹P(pán)重命名成 fileName。
來(lái)看看完整的程序。
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é)我們的程序:我們做了一個(gè) dispatch association,將指令對(duì)應(yīng)到一些會(huì)接受命令行引數(shù)并回傳 I/O action 的函數(shù)。我們知道用戶下了什么命令,并根據(jù)那個(gè)命令從 dispatch list 取出對(duì)影的函數(shù)。我們用剩下的命令行引數(shù)調(diào)用哪些函數(shù)而得到一些作相對(duì)應(yīng)事情的 I/O action。然后便執(zhí)行那些 I/O action。
在其他編程語(yǔ)言,我們可能會(huì)用一個(gè)大的 switch case 來(lái)實(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 加入新的會(huì)作你要的事情函數(shù)。你可以試試實(shí)作一個(gè) bump 函數(shù),接受一個(gè)文件跟一個(gè) task number,他會(huì)回傳一個(gè)把那個(gè) task 搬到 to-do list 頂端的 I/O action。
對(duì)于不合法的輸入你也可以讓程序結(jié)束地漂亮一點(diǎn)。(例如用戶輸入了 todo UP YOURS HAHAHAHA)可以作一個(gè)回報(bào)錯(cuò)誤的 I/O action (例如 ``errorExist :: IO ())檢查有沒(méi)有不合法的輸入,如果有便執(zhí)行這個(gè)回報(bào)錯(cuò)誤的 I/O action。我們之后會(huì)談另一個(gè)可能,就是用 exception。
在許多情況下,你寫(xiě)程序會(huì)需要些隨機(jī)的數(shù)據(jù)。或許你在制作一個(gè)游戲,在游戲中你需要擲骰子。或是你需要測(cè)試程序的測(cè)試數(shù)據(jù)。精準(zhǔn)一點(diǎn)地說(shuō),我們需要 pseudo-random 的數(shù)據(jù),我們知道真正的隨機(jī)數(shù)據(jù)好比是一只猴子拿著起司跟奶油騎在單輪車(chē)上,任何事情都會(huì)發(fā)生。在這個(gè)章節(jié),我們要看看如何讓 Haskell 產(chǎn)生些 pseudo-random 的數(shù)據(jù)。
在大多數(shù)其他的編程語(yǔ)言中,會(huì)給你一些函數(shù)能讓你拿到些隨機(jī)亂數(shù)。每調(diào)用一次他就會(huì)拿到一個(gè)不同的數(shù)字。那在 Haskell 中是如何?要記住 Haskell 是一個(gè)純粹函數(shù)式語(yǔ)言。代表任何東西都具有 referential transparency。那代表你喂給一個(gè)函數(shù)相同的參數(shù),不管怎么調(diào)用都是回傳相同的結(jié)果。這很新奇的原因是因?yàn)樗屛覀兝斫獬绦虻姆绞讲煌?,而且可以讓我們延遲計(jì)算,直到我們真正需要他。如果我調(diào)用一個(gè)函數(shù),我可以確定他不會(huì)亂來(lái)。我真正在乎的是他的結(jié)果。然而,這會(huì)造成在亂數(shù)的情況有點(diǎn)復(fù)雜。如果我有一個(gè)函數(shù)像這樣:
randomNumber :: (Num a) => a
randomNumber = 4
由于他永遠(yuǎn)回傳 4,所以對(duì)于亂數(shù)的情形而言是沒(méi)什么意義。就算 4 這個(gè)結(jié)果是擲骰子來(lái)的也沒(méi)有意義。
其他的編程語(yǔ)言是怎么產(chǎn)生亂數(shù)的呢?他們可能隨便拿取一些電腦的信息,像是現(xiàn)在的時(shí)間,你怎么移動(dòng)你的鼠標(biāo),以及周?chē)穆曇?。根?jù)這些算出一個(gè)數(shù)值讓他看起來(lái)好像隨機(jī)的。那些要素算出來(lái)的結(jié)果可能在每個(gè)時(shí)間都不同,所以你會(huì)拿到不同的隨機(jī)數(shù)字。
所以說(shuō)在 Haskell 中,假如我們能作一個(gè)函數(shù),他會(huì)接受一個(gè)具隨機(jī)性的參數(shù),然后根據(jù)那些信息還傳一個(gè)數(shù)值。
在 System.Random 模塊中。他包含所有滿足我們需求的函數(shù)。讓我們先來(lái)看其中一個(gè),就是 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)。一個(gè)布林值可以是隨機(jī)值,不是 True 就是 False。一個(gè)整數(shù)可以是隨機(jī)的好多不同值。那你會(huì)問(wèn),函數(shù)可以是一個(gè)隨機(jī)值嗎?我不這么認(rèn)為。如果我們?cè)囍g random 的型態(tài)宣告,大概會(huì)是這樣:他接受一個(gè) random generator (亂源所在),然后回傳一個(gè)隨機(jī)值以及一個(gè)新的 random generator。為什么他要回傳一個(gè)新的 random generator 呢?就是下面我們要講的。
要使用 random 函數(shù), 我們必須要了解 random generator。 在 System.Random 中有一個(gè)很酷的型態(tài),叫做 StdGen, 他是 RandomGen 的一個(gè) instance。 我們可以自己手動(dòng)作一個(gè) StdGen 也可以告訴系統(tǒng)給我們一個(gè)現(xiàn)成的。
要自己做一個(gè) random generator,要使用 mkStdGen 這個(gè)函數(shù)。他的型態(tài)是 mkStdGen :: Int -> StdGen。他接受一個(gè)整數(shù),然后根據(jù)這個(gè)整數(shù)會(huì)給一個(gè) random generator。讓我們來(lái)試一下 random 以及 mkStdGen,用他們產(chǎn)生一個(gè)亂數(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ù)會(huì)回傳 Random typeclass 中任何一種型態(tài),所以我們必須告訴 Haskell 我們是要哪一種型態(tài)。不要忘了我們是回傳 random value 跟 random generator 的一個(gè) pair
ghci> random (mkStdGen 100) :: (Int, StdGen)
(-1352021624,651872571 1655838864)
我們終于有了一個(gè)看起來(lái)像亂數(shù)的數(shù)字。tuple 的第一個(gè)部份是我們的亂數(shù),而第二個(gè)部份是一個(gè)新的 random generator 的文本表示。如果我們用相同的 random generator 再調(diào)用 random 一遍呢?
ghci> random (mkStdGen 100) :: (Int, StdGen)
(-1352021624,651872571 1655838864)
不易外地我們得到相同的結(jié)果。所以我們?cè)囋囉貌煌?random generator 作為我們的參數(shù)。
ghci> random (mkStdGen 949494) :: (Int, StdGen)
(539963926,466647808 1655838864)
很好,我們拿到了不同的數(shù)字。我們可以用不同的型態(tài)標(biāo)志來(lái)拿到不同型態(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)
讓我們寫(xiě)一個(gè)仿真丟三次銅板的函數(shù)。假如 random 不同時(shí)回傳一個(gè)亂數(shù)以及一個(gè)新的 random generator,我們就必須讓這函數(shù)接受三個(gè) random generators 讓他們每個(gè)回傳一個(gè)擲銅板的結(jié)果。但那樣聽(tīng)起來(lái)怪怪的,加入一個(gè) generator 可以產(chǎn)生一個(gè)型態(tài)是 Int 的亂數(shù),他應(yīng)該可以產(chǎn)生擲三次銅板的結(jié)果(總共才八個(gè)組合)。這就是 random 為什么要回傳一個(gè)新的 generator 的關(guān)鍵了。
我們將一個(gè)銅板表示成 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) )
我們用我們拿來(lái)當(dāng)參數(shù)的 generator 調(diào)用 random 并得到一個(gè)擲銅板的結(jié)果跟一個(gè)新的 generator。然后我們?cè)儆眯碌?generator 調(diào)用他一遍,來(lái)得到第二個(gè)擲銅板的結(jié)果。對(duì)于第三個(gè)擲銅板的結(jié)果也是如法炮制。如果我們一直都用同樣的 generator,那所有的結(jié)果都會(huì)是相同的值。也就是不是 (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)
留意我們不需要寫(xiě) random gen :: (Bool, StdGen)。那是因?yàn)槲覀円呀?jīng)在函數(shù)的型態(tài)宣告那邊就表明我們要的是布林。而 Haskell 可以推敲出我們要的是布林值。
假如我們要的是擲四次?甚至五次呢?有一個(gè)函數(shù)叫 randoms,他接受一個(gè) generator 并回傳一個(gè)無(wú)窮串行。
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 不另外多回傳一個(gè)新的 generator 呢?我們可以這樣地實(shí)作 randoms
randoms' :: (RandomGen g, Random a) => g -> [a]
randoms' gen = let (value, newGen) = random gen in value:randoms' newGen
一個(gè)遞歸的定義。我們由現(xiàn)在的 generator 拿到一個(gè)亂數(shù)跟一個(gè)新的 generator,然后制作一個(gè) list,list 的第一個(gè)值是那個(gè)亂數(shù),而 list 的其余部份是根據(jù)新的 generator 產(chǎn)生出的其余亂數(shù)們。由于我們可能產(chǎn)生出無(wú)限的亂數(shù),所以不可能回傳一個(gè)新的 generator。
我們可以寫(xiě)一個(gè)函數(shù),他會(huì)回傳有限個(gè)亂數(shù)跟一個(gè)新的 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)
又是一個(gè)遞歸的定義。我們說(shuō)如果我們要 0 個(gè)亂數(shù),我們便回傳一個(gè)空的 list 跟原本給我們的 generator。對(duì)于其他數(shù)量的亂數(shù),我們先拿一個(gè)亂數(shù)跟一個(gè)新的 generator。這一個(gè)亂數(shù)便是 list 的第一個(gè)數(shù)字。然后 list 中剩下的便是 n-1 個(gè)由新的 generator 產(chǎn)生出的亂數(shù)。然后我們回傳整個(gè) list 跟最后一個(gè)產(chǎn)生完 n-1 個(gè)亂數(shù)后 generator。
如果我們要的是在某個(gè)范圍內(nèi)的亂數(shù)呢?現(xiàn)在拿到的亂數(shù)要不是太大就是太小。如果我們想要的是骰子上的數(shù)字呢?randomR 能滿足我們的需求。他的型態(tài)是 randomR :: (RandomGen g, Random a) :: (a, a) -> g -> (a, g),代表他有點(diǎn)類(lèi)似 random。只不過(guò)他的第一個(gè)參數(shù)是一對(duì)數(shù)目,定義了最后產(chǎn)生亂數(shù)的上界以及下界。
ghci> randomR (1,6) (mkStdGen 359353)
(6,1494289578 40692)
ghci> randomR (1,6) (mkStdGen 35935335)
(3,1250031057 40692)
另外也有一個(gè) randomRs 的函數(shù),他會(huì)產(chǎn)生一連串在給定范圍內(nèi)的亂數(shù):
ghci> take 10 $ randomRs ('a','z') (mkStdGen 3) :: [Char]
"ndkxbvmomg"
這結(jié)果看起來(lái)像是一個(gè)安全性很好的密碼。
你會(huì)問(wèn)你自己,這一單元跟 I/O 有關(guān)系嗎?到現(xiàn)在為止還沒(méi)出現(xiàn)任何跟 I/O 有關(guān)的東西。到現(xiàn)在為止我們都是手動(dòng)地做我們的 random generator。但那樣的問(wèn)題是,程序永遠(yuǎn)都會(huì)回傳同樣的亂數(shù)。這在真實(shí)世界中的程序是不能接受的。這也是為什么 System.Random 要提供 getStdGen 這個(gè) I/O action,他的型態(tài)是 IO StdGen。當(dāng)你的程序執(zhí)行時(shí),他會(huì)跟系統(tǒng)要一個(gè) random generator,并存成一個(gè) global generator。getStdGen 會(huì)替你拿那個(gè) global random generator 并把他綁定到某個(gè)名稱上。
這里有一個(gè)簡(jiǎn)單的產(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í)候,實(shí)際上都會(huì)回傳同樣的 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)
你會(huì)打印出兩次同樣的字串。要能得到兩個(gè)不同的字串是建立一個(gè)無(wú)限的 stream,然后拿前 20 個(gè)字當(dāng)作第一個(gè)字串,拿下 20 個(gè)字當(dāng)作第二個(gè)字串。要這么做,我們需要在 Data.List 中的 splitAt 函數(shù)。他會(huì)把一個(gè) list 根據(jù)給定的 index 切成一個(gè) tuple,tuple 的第一部份就是切斷的前半,第二個(gè)部份就是切斷的后半。
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 這個(gè) I/O action,他會(huì)把現(xiàn)有的 random generator 分成兩個(gè)新的 generators。然后會(huì)把其中一個(gè)指定成 global generator,并回傳另一個(gè)。
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 的時(shí)候我們不只是會(huì)拿到一個(gè)新的 generator,global generator 也會(huì)被重新指定。所以再調(diào)用一次 getStdGen 并綁定到某個(gè)名稱的話,我們就會(huì)拿到跟 gen 不一樣的 generator。
這邊有一個(gè)小程序會(huì)讓用戶猜數(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
我們寫(xiě)了一個(gè) askForNumber 的函數(shù),他接受一個(gè) random generator 并回傳一個(gè)問(wèn)用戶要數(shù)字并回答是否正確的 I/O action。在那個(gè)函數(shù)里面,我們先根據(jù)從參數(shù)拿到的 generator 產(chǎn)生一個(gè)亂數(shù)以及一個(gè)新的 generator,分別叫他們?yōu)?nbsp;randomNumber 跟 newGen。假設(shè)那個(gè)產(chǎn)生的數(shù)字是 7。則我們要求用戶猜我們握有的數(shù)字是什么。我們用 getLine 來(lái)將結(jié)果綁定到 numberString 上。當(dāng)用戶輸入 7,numberString 就會(huì)是 "7"。接下來(lái),我們用 when 來(lái)檢查用戶輸入的是否是空字串。如果是,那一個(gè)空的 I/O action return () 就會(huì)被回傳?;旧暇偷扔谑墙Y(jié)束程序的意思。如果不是,那 I/O action 就會(huì)被執(zhí)行。我們用 read 來(lái)把 numberString 轉(zhuǎn)成一個(gè)數(shù)字,所以 number 便會(huì)是 7。
如果用戶給我們一些 ``read`` 沒(méi)辦法讀取的輸入(像是 ``"haha"``),我們的程序便會(huì)當(dāng)?shù)舨⒋蛴〕鲥e(cuò)誤消息。 如果你不希望你的程序當(dāng)?shù)?,就?**reads**,當(dāng)讀取失敗的時(shí)候他會(huì)回傳一個(gè)空的 list。當(dāng)成功的時(shí)候他就回傳一個(gè) tuple,第一個(gè)部份是我們想要的數(shù)字,第二個(gè)部份是讀取失敗的字串。
我們檢查如果輸入的數(shù)字跟我們隨機(jī)產(chǎn)生的數(shù)字一樣,便提示用戶恰當(dāng)?shù)南?。然后再遞歸地調(diào)用 askForNumber,只是會(huì)拿到一個(gè)新的 generator。就像之前的 generator 一樣,他會(huì)給我們一個(gè)新的 I/O action。
main 的組成很簡(jiǎn)單,就是由拿取一個(gè) random generator 跟調(diào)用 askForNumber 組成罷了。
來(lái)看看我們的程序:
$ 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?
用另一種方式寫(xiě)的話像這樣:
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
他非常類(lèi)似我們之前的版本,只是不是遞歸地調(diào)用,而是把所有的工作都在 main 里面做掉。在告訴用戶他們猜得是否正確之后,便更新 global generator 然后再一次調(diào)用 main。兩種策略都是有效但我比較喜歡第一種方式。因?yàn)樗?nbsp;main 里面做的事比較少,并提供我們一個(gè)可以重復(fù)使用的函數(shù)。
List 是一種有用又酷的數(shù)據(jù)結(jié)構(gòu)。到目前為止,我們幾乎無(wú)處不使用他。有好幾個(gè)函數(shù)是專門(mén)處理 List 的,而 Haskell 惰性的性質(zhì)又讓我們可以用 filter 跟 map 來(lái)替換其他語(yǔ)言中的 for loop 跟 while loop。也由于 evaluation 只會(huì)發(fā)生在需要的時(shí)候,像 infinite list 也對(duì)于 Haskell 不成問(wèn)題(甚至是 infinite list of infinite list)。這也是為什么 list 能被用來(lái)表達(dá) stream,像是讀取標(biāo)準(zhǔn)輸入或是讀取文件。我們可以打開(kāi)文件然后讀取內(nèi)容成字串,即便實(shí)際上我們是需要的時(shí)候才會(huì)真正取讀取。
然而,用字串來(lái)處理文件有一個(gè)缺點(diǎn):就是他很慢。就像你所知道的,String 是一個(gè) [Char] 的 type synonym。Char 沒(méi)有一個(gè)固定的大小,因?yàn)樗赡苡珊脦讉€(gè) byte 組成,好比說(shuō) Unicode。再加上 list 是惰性的。如果你有一個(gè) list 像 [1,2,3,4],他只會(huì)在需要的時(shí)候被 evaluate。所以整個(gè) list 其實(shí)比較像是一個(gè)"保證"你會(huì)有一個(gè) list。要記住 [1,2,3,4] 不過(guò)是 1:2:3:4:[] 的一個(gè) syntactic sugar。當(dāng) list 的第一個(gè)元素被 evaluated 的時(shí)候,剩余的部份 2:3:4:[] 一樣也只是一個(gè)"保證"你會(huì)有一個(gè) list,以此類(lèi)推。以此類(lèi)推。以此類(lèi)推。所以你可以想像成 list 是保證在你需要的時(shí)候會(huì)給你第一個(gè)元素,以及保證你會(huì)有剩下的部份當(dāng)你還需要更多的時(shí)候。其實(shí)不難說(shuō)服你這樣做并不是一個(gè)最有效率的作法。
這樣額外的負(fù)擔(dān)在大多數(shù)時(shí)候不會(huì)造成困擾,但當(dāng)我們要讀取一個(gè)很大的文件的時(shí)候就是個(gè)問(wèn)題了。這也是為什么 Haskell 要有 bytestrings。Bytestrings 有點(diǎn)像 list,但他每一個(gè)元素都是一個(gè) byte (8 bits),而且他們惰性的程度也是不同。
Bytestrings 有兩種:strict 跟 lazy。Strict bytestrings 放在 Data.ByteString,他們把惰性的性質(zhì)完全拿掉。不會(huì)有所謂任何的「保證」,一個(gè) strict bytestring 就代表一連串的 bytes。因此你不會(huì)有一個(gè)無(wú)限長(zhǎng)的 strict bytestrings。如果你 evaluate 第一個(gè) byte,你就必須 evalute 整個(gè) bytestring。這么做的優(yōu)點(diǎn)是他會(huì)比較少 overhaed,因?yàn)樗麤](méi)有 "Thunk"(也就是用 Haskell 術(shù)語(yǔ)來(lái)說(shuō)的「保證」)。缺點(diǎn)就是他可能會(huì)快速消耗你的內(nèi)存,因?yàn)槟惆阉麄円淮味甲x進(jìn)了內(nèi)存。
另一種 bytestring 是放在 Data.ByteString.Lazy 中。他們具有惰性,但又不像 list 那么極端。就像我們之前說(shuō)的,List 的 thunk 個(gè)數(shù)是跟 list 中有幾個(gè)元素一模一樣。這也是為什么他們速度沒(méi)辦法滿足一些特殊需求。Lazy bytestrings 則用另一種作法,他們被存在 chunks 中(不要跟 Thunk 搞混),每一個(gè) chunk 的大小是 64K。所以如果你 evaluate lazy bytestring 中的 byte,則前 64K 會(huì)被 evaluated。在那個(gè) chunck 之后,就是一些「保證」會(huì)有剩余的 chunk。lazy bytestrings 有點(diǎn)像裝了一堆大小為 64K 的 strict bytestrings 的 list。當(dāng)你用 lazy bytestring 處理一個(gè)文件的時(shí)候,他是一個(gè) chunk 一個(gè) chunk 去讀。這很棒是因?yàn)樗粫?huì)讓我們一下使用大量的內(nèi)存,而且 64K 有很高的可能性能夠裝進(jìn)你 CPU 的 L2 Cache。
如果你大概看過(guò) Data.ByteString.Lazy 的文檔,你會(huì)看到到他有一堆函數(shù)的名稱跟 Data.List 中的函數(shù)名稱相同,只是出現(xiàn)的 type signature 是 ByteString 而不是 [a],是 Word8 而不是 a。同樣名稱的函數(shù)基本上表現(xiàn)的行為跟 list 中的差不多。因?yàn)槊Q是一樣的,所以必須用 qualified import 才不會(huì)在裝載進(jìn) GHCI 的時(shí)候造成沖突。
import qualified Data.ByteString.Lazy as B
import qualified Data.ByteString as S
B 中有 lazy bytestrings 跟對(duì)應(yīng)的函數(shù),而 S 中則有 strict 的版本。大多數(shù)時(shí)候我們是用 lazy 的版本。
pack 函數(shù)的 type signature 是 pack :: [Word8] -> ByteString。代表他接受一串型態(tài)為 Word8 的 bytes,并回傳一個(gè) ByteString。你能想像一個(gè) lazy 的 list,要讓他稍微不 lazy 一些,所以讓他對(duì)于 64K lazy。
那 Word8 型態(tài)又是怎么一回事?。他就像 Int,只是他的范圍比較小,介于 0-255 之間。他代表一個(gè) 8-bit 的數(shù)字。就像 Int 一樣,他是屬于 Num 這個(gè) 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)會(huì)選擇正確的型態(tài)。如果你試著用比較大的數(shù)字,像是 336。那對(duì)于 Word8 他就會(huì)變成 80。
我們把一些數(shù)值打包成 ByteString,使他們可以塞進(jìn)一個(gè) chunk 里面。Empty 之于 ByteString 就像 [] 之于 list 一樣。
unpack 是 pack 的相反,他把一個(gè) bytestring 變成一個(gè) byte list。
fromChunks 接受一串 strict 的 bytestrings 并把他變成一串 lazy bytestring。toChunks 接受一個(gè) 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 起來(lái)(會(huì)耗損 memory)這樣的作法是不錯(cuò)的。
bytestring 版本的 : 叫做 cons。他接受一個(gè) byte 跟一個(gè) bytestring,并把這個(gè) byte 放到 bytestring 的前端。他是 lazy 的操作,即使 bytestring 的第一個(gè) chunk 不是滿的,他也會(huì)添加一個(gè) chunk。這也是為什么當(dāng)你要插入很多 bytes 的時(shí)候最好用 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 制造了一個(gè)空的 bytestring。也注意到 cons 跟 cons' 的差異了嗎?有了 foldr,我們逐步地把一串?dāng)?shù)字從右邊開(kāi)始,一個(gè)個(gè)放到 bytestring 的前頭。當(dāng)我們用 cons,我們則得到一個(gè) byte 一個(gè) 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 來(lái)讀取一個(gè)文件,他會(huì)把文件內(nèi)容都讀進(jìn)內(nèi)存中。而使用 lazy bytestring,他則會(huì)讀取 chunks。
讓我們來(lái)寫(xiě)一個(gè)簡(jiǎn)單的程序,他從命令行接受兩個(gè)文件名,然后拷貝第一個(gè)文件內(nèi)容成第二個(gè)文件。雖然 System.Directory 中已經(jīng)有一個(gè)函數(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
我們寫(xiě)了自己的函數(shù),他接受兩個(gè) FilePath(記住 FilePath 不過(guò)是 String 的同義詞。)并回傳一個(gè) I/O action,他會(huì)用 bytestring 拷貝第一個(gè)文件至另一個(gè)。在 main 函數(shù)中,我們做的只是拿到命令行引數(shù)然后調(diào)用那個(gè)函數(shù)來(lái)拿到一個(gè) I/O action。
$ runhaskell bytestringcopy.hs something.txt ../../something.txt
就算我們不用 bytestring 來(lái)寫(xiě),程序最后也會(huì)長(zhǎng)得像這樣。差別在于我們會(huì)用 B.readFile 跟 B.writeFile 而不是 readFile 跟 writeFile。有很大的可能性,就是你只要 import 文件并在函數(shù)前加上 qualified 模塊名,就可以把一個(gè)用正常 String 的程序改成用 ByteString。也有可能你是要反過(guò)來(lái)做,但那也不難。
當(dāng)你需要更好的性能來(lái)讀取許多數(shù)據(jù),嘗試用 bytestring,有很大的機(jī)會(huì)你會(huì)用很小的力氣改進(jìn)很多性能。我通常用正常 String 來(lái)寫(xiě)程序,然后在性能不好的時(shí)候把他們改成 ByteString。
所有的編程語(yǔ)言都有要處理失敗的情形。這就是人生。不同的語(yǔ)言有不同的處理方式。在 C 里面,我們通常用非正常范圍的回傳值(像是 -1 或 null)來(lái)回傳錯(cuò)誤。Java 跟 C#則傾向于使用 exception 來(lái)處理失敗的情況。當(dāng)一個(gè) exception 被丟出的時(shí)候,控制流程就會(huì)跳到我們做一些清理動(dòng)作的地方,做完清理后 exception 被重新丟出,這樣一些處理錯(cuò)誤的代碼可以完成他們的工作。
Haskell 有一個(gè)很棒的型態(tài)系統(tǒng)。Algebraic data types 允許像是 Maybe 或 Either 這種型態(tài),我們能用這些型態(tài)來(lái)代表一些可能有或沒(méi)有的結(jié)果。在 C 里面,在失敗的時(shí)候回傳 -1 是很常見(jiàn)的事。但他只對(duì)寫(xiě)程序的人有意義。如果我們不小心,我們有可能把這些錯(cuò)誤碼當(dāng)作正常值來(lái)處理,便造成一些混亂。Haskell 的型態(tài)系統(tǒng)賦予我們更安全的環(huán)境。一個(gè) a -> Maybe b 的函數(shù)指出了他會(huì)產(chǎn)生一個(gè)包含 b 的 Just,或是回傳 Nothing。這型態(tài)跟 a -> b 是不同的,如果我們?cè)囍鴮蓚€(gè)函數(shù)混用,compiler 便會(huì)警告我們。
盡管有表達(dá)力夠強(qiáng)的型態(tài)來(lái)輔助失敗的情形,Haskell 仍然支持 exception,因?yàn)?exception 在 I/O 的 contexts 下是比較合理的。在處理 I/O 的時(shí)候會(huì)有一堆奇奇怪怪的事情發(fā)生,環(huán)境是很不能被信賴的。像是打開(kāi)文件。文件有可能被 lock 起來(lái),也有可能文件被移除了,或是整個(gè)硬盤(pán)都被拔掉。所以直接跳到處理錯(cuò)誤的代碼是很合理的。
我們了解到 I/O code 會(huì)丟出 exception 是件合理的事。至于 pure code 呢?其實(shí)他也能丟出 Exception。想想看 div 跟 head 兩個(gè)案例。他們的型態(tài)是 (Integral a) => a -> a -> a 以及 [a] -> a。Maybe 跟 Either 都沒(méi)有在他們的回傳型態(tài)中,但他們都有可能失敗。div 有可能除以零,而 head 有可能你傳給他一個(gè)空的 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 中你不知道什么東西什么時(shí)候會(huì)被 evaluate。因?yàn)?lazy 特性的緣故,程序沒(méi)有一個(gè)特定的執(zhí)行順序,但 I/O code 有。
先前我們談過(guò)為什么在 I/O 部份的程序要越少越好。程序的邏輯部份盡量都放在 pure 的部份,因?yàn)?pure 的特性就是他們的結(jié)果只會(huì)根據(jù)函數(shù)的參數(shù)不同而改變。當(dāng)思考 pure function 的時(shí)候,你只需要考慮他回傳什么,因?yàn)槌酥馑粫?huì)有任何副作用。這會(huì)讓事情簡(jiǎn)單許多。盡管 I/O 的部份是難以避免的(像是打開(kāi)文件之類(lèi)),但最好是把 I/O 部份降到最低。Pure functions 缺省是 lazy,那代表我們不知道他什么時(shí)候會(huì)被 evaluate,不過(guò)我們也不該知道。然而,一旦 pure functions 需要丟出 Exception,他們何時(shí)被 evaluate 就很重要了。那是因?yàn)槲覀冎挥性?I/O 的部份才能接到 Exception。這很糟糕,因?yàn)槲覀冋f(shuō)過(guò)希望 I/O 的部份越少越好。但如果我們不接 Exception,我們的程序就會(huì)當(dāng)?shù)?。這問(wèn)題有解決辦法嗎?答案是不要在 pure code 里面使用 Exception。利用 Haskell 的型態(tài)系統(tǒng),盡量使用 Either 或 Maybe 之類(lèi)的型態(tài)來(lái)表示可能失敗的計(jì)算。
這也是為什么我們要來(lái)看看怎么使用 I/O Excetion。I/O Exception 是當(dāng)我們?cè)?nbsp;main 里面跟外界溝通失敗而丟出的 Exception。例如我們嘗試打開(kāi)一個(gè)文件,結(jié)果發(fā)現(xiàn)他已經(jīng)被刪掉或是其他狀況。來(lái)看看一個(gè)嘗試打開(kāi)命令行引數(shù)所指定文件名稱,并計(jì)算里面有多少行的程序。
import System.Environment
import System.IO
main = do (fileName:_) <- getArgs
contents <- readFile fileName
putStrLn $ "The file has " ++ show (length (lines contents)) ++ " lines!"
一個(gè)很簡(jiǎn)單的程序。我們使用 getArgs I/O action,并綁定第一個(gè) string 到 fileName。然后我們綁定文件內(nèi)容到 contents。最后,我們用 lines 來(lái)取得 line 的 list,并計(jì)算 list 的長(zhǎng)度,并用 show 來(lái)轉(zhuǎn)換數(shù)字成 string。他如我們想像的工作,但當(dāng)我們給的文件名稱不存在的時(shí)候呢?
$ runhaskell linecount.hs i_dont_exist.txt
linecount.hs: i_dont_exist.txt: openFile: does not exist (No such file or directory)
GHC 丟了錯(cuò)誤消息給我們,告訴我們文件不存在。然后程序就掛掉了。假如我們希望打印出比較好一些的錯(cuò)誤消息呢?一種方式就是在打開(kāi)文件前檢查他存不存在。用 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,所以我們要寫(xiě)成 fileExists <- doesFileExist fileName。那代表他回傳含有一個(gè)布林值告訴我們文件存不存在的 I/O action。doesFileExist 是不能直接在 if expression 中使用的。
另一個(gè)解法是使用 Exception。在這個(gè)情境下使用 Exception 是沒(méi)問(wèn)題的。文件不存在這個(gè) Exception 是在 I/O 中被丟出,所以在 I/O 中接起來(lái)也沒(méi)什么不對(duì)。
要這樣使用 Exception,我們必須使用 System.IO.Error 中的 catch 函數(shù)。他的型態(tài)是 catch :: IO a -> (IOError -> IO a) -> IO a。他接受兩個(gè)參數(shù),第一個(gè)是一個(gè) I/O action。像是他可以接受一個(gè)打開(kāi)文件的 I/O action。第二個(gè)是 handler。如果第一個(gè)參數(shù)的 I/O action 丟出了 Exception,則他會(huì)被傳給 handler,他會(huì)決定要作些什么。所以整個(gè) I/O action 的結(jié)果不是如預(yù)期中做完第一個(gè)參數(shù)的 I/O action,就是 handler 處理的結(jié)果。
如果你對(duì)其他語(yǔ)言像是 Java, Python 中 try-catch 的形式很熟,那 catch 其實(shí)跟他們很像。第一個(gè)參數(shù)就是其他語(yǔ)言中的 try block。第二個(gè)參數(shù)就是其他語(yǔ)言中的 catch block。其中 handler 只有在 exception 被丟出時(shí)才會(huì)被執(zhí)行。
handler 接受一個(gè) IOError 型態(tài)的值,他代表的是一個(gè) I/O exception 已經(jīng)發(fā)生了。他也帶有一些 exception 本身的信息。至于這型態(tài)在語(yǔ)言中使如何被實(shí)作則是要看編譯器。這代表我們沒(méi)辦法用 pattern matching 的方式來(lái)查看 IOError。就像我們不能用 pattern matching 來(lái)查看 IO something 的內(nèi)容。但我們能用一些 predicate 來(lái)查看他們。
我們來(lái)看看一個(gè)展示 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)鍵字周?chē)由?backticks 來(lái)把 catch 當(dāng)作 infix function 用,因?yàn)樗麆偤媒邮軆蓚€(gè)參數(shù)。這樣使用讓可讀性變好。toTry `catch` handler 跟 catch toTry handler 是一模一樣的。toTry 是一個(gè) I/O action,而 handler 接受一個(gè) IOError,并回傳一個(gè)當(dāng) exception 發(fā)生時(shí)被執(zhí)行的 I/O action。
來(lái)看看執(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 里面我們并沒(méi)有檢查我們拿到的是什么樣的 IOError,我們只是打印出 "Whoops, had some trouble!"。接住任何種類(lèi)的 Exception 就跟其他語(yǔ)言一樣,在 Haskell 中也不是一個(gè)好的習(xí)慣。假如其他種類(lèi)的 Exception 發(fā)生了,好比說(shuō)我們送一個(gè)中斷指令,而我們沒(méi)有接到的話會(huì)發(fā)生什么事?這就是為什么我們要做跟其他語(yǔ)言一樣的事:就是檢查我們拿到的是什么樣的 Exception。如果說(shuō)是我們要的 Exception,那就做對(duì)應(yīng)的處理。如果不是,我們?cè)僦匦聛G出 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 以外其他東西都沒(méi)變,我們只接住我們想要的 I/O exception。這邊使用了 System.IO.Error 中的函數(shù) isDoesNotExistError 跟 ioError。isDoesNotExistError 是一個(gè)運(yùn)作在 IOError 上的 predicate ,他代表他接受一個(gè) IOError 然后回傳 True 或 False,他的型態(tài)是 isDoesNotExistError :: IOError -> Bool。我們用他來(lái)判斷是否這個(gè)錯(cuò)誤是文件不存在所造成的。我們這邊使用 guard,但其實(shí)也可以用 if else。如果 exception 不是由于文件不存在所造成的,我們就用 ioEroror 重新丟出接到的 exception。他的型態(tài)是 ioError :: IOException -> IO a,所以他接受一個(gè) IOError 然后產(chǎn)生一個(gè)會(huì)丟出 exception 的 I/O action。那個(gè) I/O action 的型態(tài)是 IO a,但他其實(shí)不會(huì)產(chǎn)生任何結(jié)果,所以他可以被當(dāng)作是 IO anything。
所以有可能在 toTry 里面丟出的 exception 并不是文件不存在造成的,而 toTry `catch` handler 會(huì)接住再丟出來(lái),很酷吧。
程序里面有好幾個(gè)運(yùn)作在 IOError 上的 I/O action,當(dāng)其中一個(gè)沒(méi)有被 evaluate 成 True 時(shí),就會(huì)掉到下一個(gè) guard。這些 predicate 分別為:
* **isAlreadyExistsError**
* **isDoesNotExistError**
* **isFullError**
* **isEOFError**
* **isIllegalOperation**
* **isPermissionError**
* **isUserError**
大部分的意思都是顯而易見(jiàn)的。當(dāng)我們用了 userError 來(lái)丟出 exception 的時(shí)候,isUserError 被 evaluate 成 True。例如說(shuō),你可以寫(xiě) ioError $ userError "remote computer unplugged!",盡管用 Either 或 Maybe 來(lái)表示可能的錯(cuò)誤會(huì)比自己丟出 exception 更好。
所以你可能寫(xiě)一個(gè)像這樣的 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 不是你要的,記得要把他們重新丟出,不然你的程序可能只會(huì)安靜地當(dāng)?shù)簟?/p>
System.IO.Error 也提供了一些能詢問(wèn) exception 性質(zhì)的函數(shù),像是哪些 handle 造成錯(cuò)誤,或哪些文件名造成錯(cuò)誤。這些函數(shù)都是 ioe 當(dāng)開(kāi)頭。而且你可以在文檔中看到一整串詳細(xì)數(shù)據(jù)。假設(shè)我們想要打印出造成錯(cuò)誤的文件名。我們不能直接打印出從 getArgs 那邊拿到的 fileName,因?yàn)橹挥?nbsp;IOError 被傳進(jìn) handler 中,而 handler 并不知道其他事情。一個(gè)函數(shù)只依賴于他所被調(diào)用時(shí)的參數(shù)。這也是為什么我們會(huì)用 ioeGetFileName 這函數(shù),他的型態(tài)是 ioeGetFileName :: IOError -> Maybe FilePath。他接受一個(gè) IOError 并回傳一個(gè) FilePath(他是 String 的同義詞。)基本上他做的事就是從 IOError 中抽出文件路徑。我們來(lái)修改一下我們的程序。
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 里面,我們?cè)?case expression 中用 e 來(lái)調(diào)用 ioeGetFileName,然后用 pattern matching 拆出 Maybe 中的值。當(dāng)你想要用 pattern matching 卻又不想要寫(xiě)一個(gè)新的函數(shù)的時(shí)候,case expression 是你的好朋友。
你不想只用一個(gè) catch 來(lái)接你 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ù),所以如果有任何一個(gè) exception 被丟出都會(huì)讓我們的程序當(dāng)?shù)?,除?nbsp;launchRockets 使用 catch 來(lái)處理 exception。當(dāng)然 toTry,thenTryThis 跟 launchRockets 都是 I/O actions,而且被 do syntax 綁在一起。這很像其他語(yǔ)言中的 try-catch blocks,你可以把一小段程序用 try-catch 包住,你可以自己調(diào)整該包多少進(jìn)去。
現(xiàn)在你知道如何處理 I/O exception 了。我們并沒(méi)有提到如何從 pure code 中丟出 exception,這是因?yàn)檎缥覀兿惹疤岬降模琀askell 提供了更好的辦法來(lái)處理錯(cuò)誤。就算是在可能會(huì)失敗的 I/O action 中,我也傾向用 IO (Either a b),代表他們是 I/O action,但當(dāng)他們被執(zhí)行,他們結(jié)果的型態(tài)是 Either a b,意思是不是 Left a 就是 Right b。
更多建議: