第七章:I/O

2018-02-24 15:49 更新

第七章:I/O

就算不是全部,絕大多數(shù)的程序員顯然還是致力于從外界收集數(shù)據(jù),處理這些數(shù)據(jù),然后把結(jié)果傳回外界。也就是說,關(guān)鍵就是輸入輸出。

Haskell的I/O系統(tǒng)是很強(qiáng)大和富有表現(xiàn)力的。它易于使用,也很有必要去理解。Haskell嚴(yán)格地把純代碼從那些會讓外部世界發(fā)生事情的代碼中分隔開。就是說,它給純代碼提供了完全的副作用隔離。除了幫助程序員推斷他們自己代碼的正確性,它還使編譯器可以自動(dòng)采取優(yōu)化和并行化成為可能。

我們將用簡單標(biāo)準(zhǔn)的I/O來開始這一章。然后我們要討論下一些更強(qiáng)大的選項(xiàng),以及提供更多I/O是怎么適應(yīng)純的,惰性的,函數(shù)式的Haskell世界的細(xì)節(jié)。

Haskell經(jīng)典I/O

讓我們開始使用Haskell的I/O吧。先來看一個(gè)程序,它看起來很像在C或者Perl等其他語言的I/O。

-- file: ch07/basicio.hs
main = do
    putStrLn "Greetings!  What is your name?"
    inpStr <- getLine
    putStrLn $ "Welcome to Haskell, " ++ inpStr ++ "!"

你可以編譯這個(gè)程序,變成一個(gè)單獨(dú)的可執(zhí)行文件,然后用 runghc 運(yùn)行它,或者從 ghci 調(diào)用 main 。這里有一個(gè)使用runghc的例子:

$ runghc basicio.hs
Greetings!  What is your name?
John
Welcome to Haskell, John!

這相單簡單,結(jié)果很明顯。你可以看到 putStrLn 輸出一個(gè) string ,后面跟了一個(gè)換行符。 getLine 從標(biāo)準(zhǔn)輸入讀取一行。 <- 語法對于你可能比較新。簡單來看,它綁定一個(gè)I/O動(dòng)作的結(jié)果到一個(gè)名字。我們用簡單的列表串聯(lián)運(yùn)算符 ++ 來聯(lián)合輸入字符串和我們自己的文本。

讓我們來看一下 putStrLn 和 getLine 的類型。你可以在庫參考手冊里看到這些信息,或者直接問 ghci :

ghci> :type putStrLn
putStrLn :: String -> IO ()
ghci> :type getLine
getLine :: IO String

注意,這些類型在他們的返回值里面都有IO?,F(xiàn)在關(guān)鍵的是,你要從這里知道他們可能有副作用,或者他們用相同的參數(shù)調(diào)用可能返回不同的值,或者兩者都有。 putStrLn 的類型看起來像一個(gè)函數(shù),它接受一個(gè) String 類型的參數(shù),并返回 IO() 類型的值。可是 IO() 是什么呢?

IOsomething 類型的所有東西都是一個(gè)IO動(dòng)作,你可以保存它但是什么都不會發(fā)生。我可以說 writefoo=putStrLn"foo" 并且現(xiàn)在什么都不發(fā)生。但是如果我過一會在另一個(gè)I/O動(dòng)作中間使用 writefoo , writefoo 動(dòng)作將會在它的父動(dòng)作被執(zhí)行的時(shí)候執(zhí)行 – I/O動(dòng)作可以粘合在一起來形成更大的I/O動(dòng)作。 () 是一個(gè)空的元組(讀作“unit”),表明從 putStrLn 沒有返回值。這和Java或C里面的 void 類似。

Tip

I/O動(dòng)作可以被創(chuàng)建,賦值和傳遞到任何地方,但是它們只能在另一個(gè)I/O動(dòng)作里面被執(zhí)行。

我們在 ghci 下看下這句代碼:

ghci> let writefoo = putStrLn "foo"
ghci> writefoo
foo

在這個(gè)例子中,輸出 foo 不是 putStrLn 的返回值,而是它的副作用,把 foo 寫到終端上。

還有另一件事要注意, 實(shí)際上是 ghci 執(zhí)行的 writefoo 。意思是,如果給 ghci 一個(gè)I/O動(dòng)作,它將會在那個(gè)地方幫你執(zhí)行它。

Note

什么是I/O動(dòng)作? 類型是 IOt 是Haskell的頭等值,并且和Haskell的類型系統(tǒng)無縫結(jié)合。 在運(yùn)行(perform)的時(shí)候產(chǎn)生作用,而不是在估值(evaluate)的時(shí)候。 任何表達(dá)式都會產(chǎn)生一個(gè)動(dòng)作作為它的值,但是這個(gè)動(dòng)作直到在另一個(gè)I/O動(dòng)作里面被執(zhí)行的時(shí)候才會運(yùn)行。* 運(yùn)行(執(zhí)行)一個(gè) IOt 類型的動(dòng)作可能運(yùn)行I/O,并且最終交付一個(gè)類型 t 的結(jié)果。

getLine 的類型可能看起來比較陌生。它看起來像一個(gè)值,而不像一個(gè)函數(shù)。但實(shí)際上,有一種看它的方法: getLine 保存了一個(gè)I/O動(dòng)作。當(dāng)這個(gè)動(dòng)作運(yùn)行了你會得到一個(gè) String 。 <- 運(yùn)算符是用來從運(yùn)行I/O動(dòng)作中抽出結(jié)果,并且保存到一個(gè)變量中。

main 自己就是一個(gè)I/O動(dòng)作,類型是 IO() 。你可以在其他I/O動(dòng)作中只是運(yùn)行I/O動(dòng)作。Haskell程序中的所有I/O動(dòng)作都是由從 main 的頂部開始驅(qū)動(dòng)的, main 是每一個(gè)Haskell程序開始執(zhí)行的地方。然后,要說的是給Haskell中副作用提供隔離的機(jī)制是:你在I/O動(dòng)作中運(yùn)行I/O,并且在那兒調(diào)用純的(非I/O)函數(shù)。大部分Haskell代碼是純的,I/O動(dòng)作運(yùn)行I/O并且調(diào)用存代碼。

do 是用來定義一串動(dòng)作的方便方法。你馬上就會看到,還有其他方法可以用來定義。當(dāng)你用這種方式來使用 do 的時(shí)候,縮進(jìn)很重要,確保你的動(dòng)作正確地對齊了。

只有當(dāng)你有多余一個(gè)動(dòng)作需要運(yùn)行的時(shí)候才要用到 do 。 do 代碼塊的值是最后一個(gè)動(dòng)作執(zhí)行的結(jié)果。想要看 do 語法的完整介紹,可以看 do代碼塊提取_ .

我們來考慮一個(gè)在I/O動(dòng)作中調(diào)用存代碼的一個(gè)例子:

-- file: ch07/callingpure.hs
name2reply :: String -> String
name2reply name =
    "Pleased to meet you, " ++ name ++ ".\n" ++
    "Your name contains " ++ charcount ++ " characters."
    where charcount = show (length name)

main :: IO ()
main = do
       putStrLn "Greetings once again.  What is your name?"
       inpStr <- getLine
       let outStr = name2reply inpStr
       putStrLn outStr

注意例子中的 name2replay 函數(shù)。這是一個(gè)Haskell的一個(gè)常規(guī)函數(shù),它遵守所有我們告訴過你的規(guī)則:給它相同的輸入,它總是返回相同的結(jié)果,沒有副作用,并且以惰性方式運(yùn)行。它用了其他Haskell函數(shù): (++) , show 和 length 。

往下看到 main ,我們綁定 name2replayinpStr 的結(jié)果到 outStr 。當(dāng)你在用 do 代碼塊的時(shí)候,你用 <- 去得到I/O動(dòng)作的結(jié)果,用 let 得到存代碼的結(jié)果。 當(dāng)你在 do 代碼塊中使用 let 聲明的時(shí)候,不要在后面放上 in 。

你可以看到這里是怎么從鍵盤讀取這人的名字的。然后,數(shù)據(jù)被傳到一個(gè)純函數(shù),接著它的結(jié)果被打印出來。實(shí)際上, main 的最后兩行可以被替換成 putStrLn(name2replyinpStr) 。所以, main 有副作用(比如它在終端上顯示東西), name2replay 沒有副作用,也不能有副作用。因?yàn)?name2replay 是一個(gè)純函數(shù),不是一個(gè)動(dòng)作。

我們在 ghci 上檢查一下:

ghci> :load callingpure.hs
[1 of 1] Compiling Main             ( callingpure.hs, interpreted )
Ok, modules loaded: Main.
ghci> name2reply "John"
"Pleased to meet you, John.\nYour name contains 4 characters."
ghci> putStrLn (name2reply "John")
Pleased to meet you, John.
Your name contains 4 characters.

字符串里面的 \n 是換行符, 它讓終端在輸出中開始新的一行。在 ghci 直接調(diào)用 name2replay"John" 會字面上顯示 \n ,因?yàn)槭褂?show 來顯示返回值。但是使用 putStrLn 來發(fā)送到終端的話,終端會把 \n 解釋成開始新的一行。

如果你就在 ghci 提示符那打上 main ,你覺得會發(fā)生什么?來試一下吧。

看完這幾個(gè)例子程序之后,你可能會好奇Haskell是不是真正的命令式語言呢,而不是純的,惰性的,函數(shù)式的。這些例子里的一些看起來是按照順序的一連串的操作。這里面還有很多東西,我們會在這一章的 Haskell是不是真正的命令式的呢?_惰性I/O 章節(jié)來討論這個(gè)問題。

Pure vs. I/O

這里有一個(gè)比較的表格,用來幫助理解存代碼和I/O之間的區(qū)別。 當(dāng)我們說起存代碼的時(shí)候,我們是在說Haskell函數(shù)在輸入相同的時(shí)候總是返回相同結(jié)果,并且沒有副作用。在Haskell里面只有I/O動(dòng)作的執(zhí)行違反這些規(guī)則。

表格7.1. Pure vs. Impure

Pure Impure
輸入相同時(shí)總是產(chǎn)生相同結(jié)果 相同的參數(shù)可能產(chǎn)生不同的結(jié)果
從不會有副作用 可能有副作用
從不修改狀態(tài) 可能修改程序、系統(tǒng)或者世界的全局狀態(tài)

為什么純不純很重要?

在這一節(jié)中,我們已經(jīng)討論了Haskell是怎么在存代碼和I/O動(dòng)作之間做了很明確的區(qū)分。很多語言沒有這種區(qū)分。在C或者Java這樣的語言中,編譯器不能保證一個(gè)函數(shù)對于同樣的參數(shù)總是返回同樣的結(jié)果,或者保證函數(shù)沒有副作用。要知道一個(gè)函數(shù)有沒有副作用只有一個(gè)辦法,就是去讀它的文檔,并且希望文檔說的準(zhǔn)確。

程序中的很多錯(cuò)誤都是由意料之外的副作用造成的。函數(shù)在某些情況下對于相同參數(shù)可能返回不同的結(jié)果,還有更多錯(cuò)誤是由于誤解了這些情況而造成的。 多線程和其他形式的并行化變得越來越普遍, 管理全局副作用變得越來越困難。

Haskell隔離副作用到I/O動(dòng)作中的方法提供了一個(gè)明確的界限。你總是可以知道系統(tǒng)中的那一部分可能修改狀態(tài)哪一部分不會。你總是可以確定程序中純的部分不會有意想不到的結(jié)果。這樣就幫助你思考程序,也幫助編譯器思考程序。比如最新版本的 ghc 可以自動(dòng)給你代碼純的部分提供一定程度的并行化 – 一個(gè)計(jì)算的神圣目標(biāo)。

對于這個(gè)主題,你可以在 _惰性I/O副作用 一節(jié)看更多的討論。

使用文件和句柄(Handle)

到目前為止,我們已經(jīng)看了在計(jì)算機(jī)的終端里怎么和用戶交互。當(dāng)然,你經(jīng)常會需要去操作某個(gè)特定文件,這個(gè)也很簡單。

Haskell位I/O定義了一些基本函數(shù),其中很多和你在其他語言里面見到的類似。 System.IO 的參考手冊為這些函數(shù)提供了很好的概要。你會用到這里面某個(gè)我們在這里沒有提及的某個(gè)函數(shù)。

通常開始的時(shí)候你會用到 openFile ,這個(gè)函數(shù)給你一個(gè)文件句柄,這個(gè)句柄用來對這個(gè)文件做特定的操作。Haskell提供了像 hPutStrLn 這樣的函數(shù),它用起來和 putStrLn 很像,但是多一個(gè)參數(shù)(句柄),指定操作哪個(gè)文件。當(dāng)操作完成之后,需要用 hClose 來關(guān)閉這個(gè)句柄 。這些函數(shù)都是定義在 System.IO 中的,所以當(dāng)你操作文件的時(shí)候你要引入這個(gè)模塊。幾乎每一個(gè)非“h”的函數(shù)都有一個(gè)對應(yīng)的“h”函數(shù),比如,print 打印到顯示器,有一個(gè)對應(yīng)的 hPrint 打印到文件。

我們用一種命令式的方式來開始讀寫文件。這有點(diǎn)像一個(gè)其他語言中 while 循環(huán),這在Haskell中不是最好的方法。接著我們會看幾個(gè)更加Haskell風(fēng)格的例子。

-- file: ch07/toupper-imp.hs
import System.IO
import Data.Char(toUpper)

main :: IO ()
main = do
    inh <- openFile "input.txt" ReadMode
    outh <- openFile "output.txt" WriteMode
    mainloop inh outh
    hClose inh
    hClose outh

mainloop :: Handle -> Handle -> IO ()
mainloop inh outh =
    do ineof <- hIsEOF inh
        if ineof
        then return ()
        else do inpStr <- hGetLine inh
                hPutStrLn outh (map toUpper inpStr)
                mainloop inh outh

像每一個(gè)Haskell程序一樣,程序在 main 那里開始執(zhí)行。兩個(gè)文件被打開: input.txt 被打開用來讀,還有一個(gè) output.txt 被打開用來寫。然后我們調(diào)用 mainloop 來處理這個(gè)文件。

mainloop 開始的時(shí)候檢查看看我們是否在輸入文件的結(jié)尾(EOF)。如果不是,我們從輸入文件讀取一行,把這一行轉(zhuǎn)成大寫,再把它寫到輸出文件。然后我們遞歸調(diào)用 mainloop 繼續(xù)處理這個(gè)文件。

注意那個(gè) return 調(diào)用。這個(gè)和C或者Python中的 return 不一樣。在那些語言中, return 用來立即退出當(dāng)前函數(shù)的執(zhí)行,并且給調(diào)用者返回一個(gè)值。在Haskell中, return 是和 <- 相反。也就是說, return 接受一個(gè)純的值,把它包裝進(jìn)IO。因?yàn)槊總€(gè)I/O動(dòng)作必須返回某個(gè) IO 類型,如果你的結(jié)果來自純的計(jì)算,你必須用 return 把它包裝進(jìn)IO。舉一個(gè)例子,如果 7 是一個(gè) Int ,然后 return7 會創(chuàng)建一個(gè)動(dòng)作,里面保存了一個(gè) IOInt 類型的值。在執(zhí)行的時(shí)候,這個(gè)動(dòng)作將會產(chǎn)生結(jié)果 7 。關(guān)于 return 的更多細(xì)節(jié),可以參見 Return的本色 一節(jié)。

我們來嘗試運(yùn)行這個(gè)程序。我們已經(jīng)有一個(gè)像這樣的名字叫 input.txt 的文件:

This is ch08/input.txt

Test Input
I like Haskell
Haskell is great
I/O is fun

123456789

現(xiàn)在,你可以執(zhí)行 runghctoupper-imp.hs,你會在你的目錄里找到 output.txt 。它看起來應(yīng)該是這樣:

THIS IS CH08/INPUT.TXT

TEST INPUT
I LIKE HASKELL
HASKELL IS GREAT
I/O IS FUN

123456789

關(guān)于 openFile 的更多信息

我們用 ghci 來檢查 openFifle 的類型:

ghci> :module System.IO
ghci> :type openFile
openFile :: FilePath -> IOMode -> IO Handle

FilePath 就是 String 的另一個(gè)名字。它在I/O函數(shù)的類型中使用,用來闡明那個(gè)參數(shù)是用來表示文件名的,而不是其他通常的數(shù)據(jù)。

IOMode 指定文件是怎么被管理的, IOMode 的可能值在表格7.2中列出來了。

表格7.2. IOMode 可能值

IOMode 可讀 可寫 開始位置 備注
ReadMode 文件開頭 文件必須存在
WriteMode 文件開頭 如果存在,文件會被截?cái)啵ㄍ耆蹇眨?/td>
ReadWriteMode 文件開頭 如果不存在會新建文件,如果存在不會損害原來的數(shù)據(jù)
AppendMode 文件結(jié)尾 如果不存在會新建文件,如果存在不會損害原來的數(shù)據(jù)

我們在這一章里大多數(shù)是操作文本文件,二進(jìn)制文件同樣可以在Haskell里使用。如果你在操作一個(gè)二進(jìn)制文件,你要用 openBinaryFile 替代 openFile 。你當(dāng)做二進(jìn)制文件打開,而不是當(dāng)做文本文件打開的話,像Windows這樣的操作系統(tǒng)會用不同的方式來處理文件。在Linux這類操作系統(tǒng)中, openFile 和 openBinaryFile 執(zhí)行相同的操作。不過為了移植性,當(dāng)你處理二進(jìn)制數(shù)據(jù)的時(shí)候總是用 openBinaryFile 還是明智的。

關(guān)閉句柄

你已經(jīng)看到 hClose 用來關(guān)閉文件句柄 。我們花點(diǎn)時(shí)間思考下為什么這個(gè)很重要。

就和你將在 緩沖區(qū)(Buffering) 一節(jié)看到的一樣,Haskell為文件維護(hù)內(nèi)部緩沖區(qū),這提供了一個(gè)重要的性能提升。然而,也就是說,直到你在一個(gè)打開來寫的文件上調(diào)用 hClose ,你的數(shù)據(jù)不會被清理出操作系統(tǒng)。

確保 hClose 的另一個(gè)理由是,打開的文件會占用系統(tǒng)資源。如果你的程序運(yùn)行很長一段時(shí)間,并且打開了很多文件,但是沒有關(guān)閉他們,你的程序很有可能因?yàn)橘Y源耗盡而崩潰。所有這些Haskell和其他語言沒有什么不同。

當(dāng)一個(gè)程序退出的時(shí)候,Haskell通常會小心地關(guān)閉所以還打開著的文件。然而在一些情況下Haskell可能不會幫你做這些。所以再一次強(qiáng)調(diào),最好任何時(shí)候由你負(fù)責(zé)調(diào)用 hClose 。

Haskell給你提供了一些工具,不管出現(xiàn)什么錯(cuò)誤,用來簡單地確保這些工作。你可以閱讀在 擴(kuò)展例子:函數(shù)式I/O和臨時(shí)文件 一節(jié)的 finally 和 獲取-使用-回收 周期_ 一節(jié)的 bracket 。

Seek and Tell

當(dāng)從一個(gè)對應(yīng)硬盤上某個(gè)文件句柄上讀寫的時(shí)候,操作系統(tǒng)維護(hù)了一個(gè)當(dāng)前硬盤位置的內(nèi)部記錄。每次你做另一次讀的時(shí)候,操作系統(tǒng)返回下一個(gè)從當(dāng)前位置開始的數(shù)據(jù)塊,并且增加這個(gè)位置,反映出你正在讀的數(shù)據(jù)。

你可以用 hTell 來找出你文件中的當(dāng)前位置。當(dāng)文件剛新建的時(shí)候,文件是空的,這個(gè)位置為0。在你寫入5個(gè)字節(jié)之后,位置會變成5,諸如此類。 hTell 接受一個(gè) Handle 并返回一個(gè)帶有位置的 IOInteger 。

hTell 的伙伴是 hSeek 。 hSeek 讓你可以改變文件位置,它有3個(gè)參數(shù):一個(gè) Handle , 一個(gè) seekMode ,還有一個(gè)位置。

SeekMode 可以是三個(gè)不同值中的一個(gè),這個(gè)值指定怎么去解析這個(gè)給的位置。 AbsoluteSeek 表示這個(gè)位置是在文件中的精確位置,這個(gè)和 hTell 給你的是同樣的信息。 RelativeSeek 表示從當(dāng)前位置開始尋找,一個(gè)正數(shù)要求在文件中向前推進(jìn),一個(gè)負(fù)數(shù)要求向后倒退。最后, SeekFromEnd 會尋找文件結(jié)尾之前特定數(shù)目的字節(jié)。 hSeekhandleSeekFromEnd0 把你帶到文件結(jié)尾。舉一個(gè) hSeek 的例子,參考 擴(kuò)展例子:函數(shù)式I/O和臨時(shí)文件 一節(jié)。

不是所有句柄都是可以定位的。一個(gè)句柄通常對應(yīng)于一個(gè)文件,但是它也可以對應(yīng)其他東西,比如網(wǎng)絡(luò)連接,磁帶機(jī)或者終端。你可以用 hIsSeekable 去看給定的句柄是不是可定位的。

標(biāo)準(zhǔn)輸入,輸出和錯(cuò)誤

先前我們指出對于每一個(gè)非“h”函數(shù)通常有一個(gè)對應(yīng)的“h”函數(shù)用在句柄上的。實(shí)際上,非“h”的函數(shù)就是他們的“h”函數(shù)的一個(gè)快捷方式。

在 System.IO 里有3個(gè)預(yù)定義的句柄,這些句柄總是可用的。他們是 stdin ,對應(yīng)標(biāo)準(zhǔn)輸入; stdout ,對應(yīng)標(biāo)準(zhǔn)輸出;和 stderr 對應(yīng)標(biāo)準(zhǔn)錯(cuò)誤。標(biāo)準(zhǔn)輸入一般對應(yīng)鍵盤,標(biāo)準(zhǔn)輸出對應(yīng)顯示器,標(biāo)準(zhǔn)錯(cuò)誤一般輸出到顯示器。

像 getLine 的這些函數(shù)可以簡單地這樣定義:

getLine = hGetLine stdin
putStrLn = hPutStrLn stdout
print = hPrint stdout

Tip

我們這里使用了局部應(yīng)用。如果不明白,可以參考 局部函數(shù)應(yīng)用和柯里化_

之前我們告訴你這3個(gè)標(biāo)準(zhǔn)文件句柄一般對應(yīng)什么。那是因?yàn)橐恍┎僮飨到y(tǒng)可以讓你重定向這個(gè)文件句柄到不同的地方-文件,設(shè)備,甚至是其他程序。這個(gè)功能在POSIX(Linux,BSD,Mac)操作系統(tǒng)Shell編程中廣泛使用,在Windows中也能使用。

使用標(biāo)準(zhǔn)輸入輸出經(jīng)常是很有用的,這讓你和終端前的用戶交互。它也能讓你操作輸入輸出文件,或者甚至讓你的代碼和其他程序組合在一起。

舉一個(gè)例子,我們可以像這樣在前面提供標(biāo)準(zhǔn)輸入給 callingpure.hs :

$ echo John|runghc callingpure.hs
Greetings once again.  What is your name?
Pleased to meet you, John.
Your name contains 4 characters.

當(dāng) callingpure.hs 運(yùn)行的時(shí)候,它不用等待鍵盤的輸入,而是從 echo 程序接收 John 。注意輸出也沒有把 John 這個(gè)詞放在一個(gè)分開的行,這和用鍵盤運(yùn)行程序一樣。終端一般回顯所有你輸入的東西給你,但這是一個(gè)技術(shù)上的輸入,不會包含在輸出流中。

刪除和重命名文件

這一章到目前為止,我們已經(jīng)討論了文件的內(nèi)容。現(xiàn)在讓我們說一點(diǎn)文件自己的東西。System.Directory 提供了兩個(gè)你可能覺得有用的函數(shù)。 removeFile 接受一個(gè)參數(shù),一個(gè)文件名,然后刪除那個(gè)文件。 renameFile 接受兩個(gè)文件名:第一個(gè)是老的文件名,第二個(gè)是新的文件名。如果新的文件名在另外一個(gè)目錄中,你也可以把它想象成移動(dòng)文件。在調(diào)用 renameFile 之前老的文件必須存在。如果新的文件已經(jīng)存在了,它在重命名之前會被刪除掉。

像很多其他接受文件名的函數(shù)一樣,如果老的文件名不存在, renameFile 會引發(fā)一個(gè)異常。更多關(guān)于異常處理的信息你可以在 第十九章,錯(cuò)誤處理_ 中找到。

在 System.Directory 中有很多其他函數(shù),用來創(chuàng)建和刪除目錄,查找目錄中文件列表,和測試文件是否存在。它們在 目錄和文件信息_ 一節(jié)中討論。

臨時(shí)文件

程序員頻繁需要用到臨時(shí)文件。臨時(shí)文件可能用來存儲大量需要計(jì)算的數(shù)據(jù),其他程序要使用的數(shù)據(jù),或者很多其他的用法。

當(dāng)你想一個(gè)辦法來手動(dòng)打開同名的多個(gè)文件,安全地做到這一點(diǎn)的細(xì)節(jié)在各個(gè)平臺上都不相同。Haskell提供了一個(gè)方便的函數(shù)叫做 openTempFile (還有一個(gè)對應(yīng)的 openBinaryTempFile )來為你處理這個(gè)難點(diǎn)。

openTempFile 接受兩個(gè)參數(shù):創(chuàng)建文件所在的目錄,和一個(gè)命名文件的“模板”。這個(gè)目錄可以簡單是“.”,表示當(dāng)前目錄?;蛘吣憧梢杂?System.Directory.getTemporaryDirectory 去找指定機(jī)器上存放臨時(shí)文件最好的地方。這個(gè)模板用做文件名的基礎(chǔ),它會添加一些隨機(jī)的字符來保證文件名是唯一的,從實(shí)際上保證被操作的文件具有獨(dú)一無二的文件名。

openTempFile 返回類型是 IO(FilePath,Handle) 。元組的第一部分是創(chuàng)建的文件的名字,第二部分是用 ReadWriteMode 打開那個(gè)文件的一個(gè)句柄 。當(dāng)你處理完這個(gè)文件,你要 hClose 它并且調(diào)用 removeFile 刪除它??聪旅娴睦又幸粋€(gè)樣本函數(shù)的使用。

擴(kuò)展例子:函數(shù)式I/O和臨時(shí)文件

這里有一個(gè)大一點(diǎn)的例子,它把很多這一章的還有前面幾章的概念放在一起,還包含了一些沒有介紹過的概念??匆幌逻@個(gè)程序,看你是否能知道它是干什么的,是怎么做的。

-- file: ch07/tempfile.hs
import System.IO
import System.Directory(getTemporaryDirectory, removeFile)
import System.IO.Error(catch)
import Control.Exception(finally)

-- The main entry point.  Work with a temp file in myAction.
main :: IO ()
main = withTempFile "mytemp.txt" myAction

{- The guts of the program.  Called with the path and handle of a temporary
file.  When this function exits, that file will be closed and deleted
because myAction was called from withTempFile. -}
myAction :: FilePath -> Handle -> IO ()
myAction tempname temph =
    do -- Start by displaying a greeting on the terminal
        putStrLn "Welcome to tempfile.hs"
        putStrLn $ "I have a temporary file at " ++ tempname

        -- Let's see what the initial position is
        pos <- hTell temph
        putStrLn $ "My initial position is " ++ show pos

        -- Now, write some data to the temporary file
        let tempdata = show [1..10]
        putStrLn $ "Writing one line containing " ++
            show (length tempdata) ++ " bytes: " ++
               tempdata
        hPutStrLn temph tempdata

        -- Get our new position.  This doesn't actually modify pos
        -- in memory, but makes the name "pos" correspond to a different
        -- value for the remainder of the "do" block.
        pos <- hTell temph
        putStrLn $ "After writing, my new position is " ++ show pos

        -- Seek to the beginning of the file and display it
        putStrLn $ "The file content is: "
        hSeek temph AbsoluteSeek 0

        -- hGetContents performs a lazy read of the entire file
        c <- hGetContents temph

        -- Copy the file byte-for-byte to stdout, followed by \n
        putStrLn c

        -- Let's also display it as a Haskell literal
        putStrLn $ "Which could be expressed as this Haskell literal:"
        print c

{- This function takes two parameters: a filename pattern and another
function.  It will create a temporary file, and pass the name and Handle
of that file to the given function.

The temporary file is created with openTempFile.  The directory is the one
indicated by getTemporaryDirectory, or, if the system has no notion of
a temporary directory, "." is used.  The given pattern is passed to
openTempFile.

After the given function terminates, even if it terminates due to an
exception, the Handle is closed and the file is deleted. -}
withTempFile :: String -> (FilePath -> Handle -> IO a) -> IO a
withTempFile pattern func =
    do -- The library ref says that getTemporaryDirectory may raise on
       -- exception on systems that have no notion of a temporary directory.
       -- So, we run getTemporaryDirectory under catch.  catch takes
       -- two functions: one to run, and a different one to run if the
       -- first raised an exception.  If getTemporaryDirectory raised an
       -- exception, just use "." (the current working directory).
       tempdir <- catch (getTemporaryDirectory) (\_ -> return ".")
       (tempfile, temph) <- openTempFile tempdir pattern

       -- Call (func tempfile temph) to perform the action on the temporary
       -- file.  finally takes two actions.  The first is the action to run.
       -- The second is an action to run after the first, regardless of
       -- whether the first action raised an exception.  This way, we ensure
       -- the temporary file is always deleted.  The return value from finally
       -- is the first action's return value.
       finally (func tempfile temph)
               (do hClose temph
                   removeFile tempfile)

讓我們從結(jié)尾開始看這個(gè)程序。 writeTempFile 函數(shù)證明Haskell當(dāng)I/O被引入的時(shí)候沒有忘記它的函數(shù)式特性。這個(gè)函數(shù)接受一個(gè) String 和另外一個(gè)函數(shù),傳給 withTempFile 的函數(shù)使用這個(gè)名字和一個(gè)臨時(shí)文件的句柄調(diào)用。當(dāng)函數(shù)退出時(shí),這個(gè)臨時(shí)文件被關(guān)閉和刪除。所以甚至在處理I/O時(shí),我們?nèi)匀豢梢园l(fā)現(xiàn)為了方便傳遞函數(shù)作為參數(shù)的習(xí)慣。Lisp程序員可能看到我們的 withTempFile 函數(shù)有點(diǎn)類似Lisp的 with-open-file 函數(shù)。

為了讓程序能夠更好地處理錯(cuò)誤,我們需要為它添加一些異常處理代碼。你一般需要臨時(shí)文件在處理完成之后被刪除,就算有錯(cuò)誤發(fā)生。所以我們要確保刪除發(fā)生。關(guān)于異常處理的更多信息,請看 第十九章:錯(cuò)誤處理_

讓我們回到這個(gè)程序的開頭, main 被簡單定義成 withTempFile"mytemp.txt"myAction 。然后, myAction 將會被調(diào)用,使用名字和這個(gè)臨時(shí)文件的句柄作為參數(shù)。

myAction 顯示一些信息到終端,寫一些數(shù)據(jù)到文件,尋找文件的開頭,并且使用 hGetContents 把數(shù)據(jù)讀取回來。然后把文件的內(nèi)容按字節(jié)地,通過 printc 當(dāng)做Haskell字面量顯示出來。這和 putStrLn(showc) 一樣。

我們看一下輸出:

$ runhaskell tempfile.hs
Welcome to tempfile.hs
I have a temporary file at /tmp/mytemp8572.txt
My initial position is 0
Writing one line containing 22 bytes: [1,2,3,4,5,6,7,8,9,10]
After writing, my new position is 23
The file content is:
[1,2,3,4,5,6,7,8,9,10]

Which could be expressed as this Haskell literal:
"[1,2,3,4,5,6,7,8,9,10]\n"

每次你運(yùn)行這個(gè)程序,你的臨時(shí)文件的名字應(yīng)該有點(diǎn)細(xì)微的差別,因?yàn)樗艘粋€(gè)隨機(jī)生成的部分。看一下這個(gè)輸出,你可能會問一些問題?

  1. 為什么寫入一行22個(gè)字節(jié)之后你的位置是23?
  2. 為什么文件內(nèi)容顯示之后有一個(gè)空行?
  3. 為什么Haskell字面量顯示的最后有一個(gè) \n ?

你可能能猜到這三個(gè)問題的答案都是相關(guān)的??纯茨隳懿荒茉谝粫?nèi)答出這些題。如果你需要幫助,這里有解釋:

  1. 是因?yàn)槲覀冇?hPutStrLn 替代 hPutStr 來寫這個(gè)數(shù)據(jù)。 hPutStrLn 總是在結(jié)束一行的時(shí)候在結(jié)尾處寫上一個(gè) \n ,而這個(gè)沒有出現(xiàn)在 tempdata 。
  2. 我們用 putStrLnc 來顯示文件內(nèi)容 c 。因?yàn)閿?shù)據(jù)原來使用 hPutStrLn 來寫的,c 結(jié)尾處有一個(gè)換行符,并且 putStrLn 又添加了第二個(gè)換行符,結(jié)果就是多了一個(gè)空行。
  3. 這個(gè) \n 是來自原始的 hPutStrLn 的換行符。

最后一個(gè)注意事項(xiàng),字節(jié)數(shù)目可能在一些操作系統(tǒng)上不一樣。比如Windows,使用連個(gè)字節(jié)序列 \r\n 作為行結(jié)束標(biāo)記,所以在Windows平臺你可能會看到不同。

惰性I/O

這一章到目前為止,你已經(jīng)看了一些相當(dāng)傳統(tǒng)的I/O例子。單獨(dú)請求和處理每一行或者每一塊數(shù)據(jù)。

Haskell還為你準(zhǔn)備了另一種方法。因?yàn)镠askell是一種惰性語言,意思是任何給定的數(shù)據(jù)片只有在它的值必須要知道的情況下才會被計(jì)算。有一些新奇的方法來處理I/O。

hGetContents

一種新奇的處理I/O的辦法是 hGetContents 函數(shù),這個(gè)函數(shù)類型是 Handle->IOString 。這個(gè)返回的 String 表示 Handle 所給文件里的所有數(shù)據(jù)。

在一個(gè)嚴(yán)格求值(strictly-evaluated)的語言中,使用這樣的函數(shù)不是一件好事情。讀取一個(gè)2KB文件的所有內(nèi)容可能沒事,但是如果你嘗試去讀取一個(gè)500GB文件的所有內(nèi)容,你很可能因?yàn)槿鄙賰?nèi)存去存儲這些數(shù)據(jù)而崩潰。在這些語言中,傳統(tǒng)上你會采用循環(huán)去處理文件的全部數(shù)據(jù)的機(jī)制。

但是 hGetContents 不一樣。它返回的 String 是惰性估值的。在你調(diào)用 hGetContents 的時(shí)刻,實(shí)際上沒有讀任何東西。數(shù)據(jù)只從句柄讀取, 作為處理的一個(gè)元素(字符)列表。 String 的元素一直都用不到,Haskell的垃圾收集器會自動(dòng)釋放那塊內(nèi)存。所有這些都是完全透明地發(fā)生的。因?yàn)楹瘮?shù)的返回值是一個(gè)如假包換的純 String ,所以它可以被傳遞給非 I/O 的純代碼。讓我們快速看一個(gè)例子?;氐?操作文件和句柄_ 一節(jié),你看到一個(gè)命令式的程序,它把整個(gè)文件內(nèi)容轉(zhuǎn)換成大寫。它的命令式算法和你在其他語言看到的很類似。接下來展示的是一個(gè)利用了惰性求值實(shí)現(xiàn)的更簡單的算法。

-- file: ch07/toupper-lazy1.hs
import System.IO
import Data.Char(toUpper)

main :: IO ()
main = do
       inh <- openFile "input.txt" ReadMode
       outh <- openFile "output.txt" WriteMode
       inpStr <- hGetContents inh
       let result = processData inpStr
       hPutStr outh result
       hClose inh
       hClose outh

processData :: String -> String
processData = map toUpper

注意到 hGetContents 為我們處理所有的讀取工作??匆幌?processData ,它是一個(gè)純函數(shù),因?yàn)樗鼪]有副作用,并且每次調(diào)用的時(shí)候總是返回相同的結(jié)果。它不需要知道,也沒辦法告訴它,它的輸入是惰性從文件讀取的。不管是20個(gè)字符的字面量還是硬盤上500GB的數(shù)據(jù)它都可以很好的工作。

你可以用 ghci 驗(yàn)證一下:

ghci> :load toupper-lazy1.hs
[1 of 1] Compiling Main             ( toupper-lazy1.hs, interpreted )
Ok, modules loaded: Main.
ghci> processData "Hello, there!  How are you?"
"HELLO, THERE!  HOW ARE YOU?"
ghci> :type processData
processData :: String -> String
ghci> :type processData "Hello!"
processData "Hello!" :: String

Warning

如果我們嘗試去抓住上面例子中的 inpStr ,在超過它被使用的地方( processData 調(diào)用那),內(nèi)存中將沒有它了。這是因?yàn)榫幾g器會強(qiáng)制保存 inpStr 的值在內(nèi)存里,為了以后的使用。這里我們知道 inpStr 講不會被重用,它一被使用完就會被釋放內(nèi)存。只要記住:最后一次使用后釋放內(nèi)存。

這個(gè)程序?yàn)榱饲宄乇砻魇褂昧舜娲a,顯得有點(diǎn)啰嗦。這里有更加簡潔的版本,新版本在下一個(gè)例子里:

-- file: ch07/toupper-lazy2.hs
import System.IO
import Data.Char(toUpper)

main = do
       inh <- openFile "input.txt" ReadMode
       outh <- openFile "output.txt" WriteMode
       inpStr <- hGetContents inh
       hPutStr outh (map toUpper inpStr)
       hClose inh
       hClose outh

你在使用 hGetContents 的時(shí)候不要求去使用輸入文件的所有數(shù)據(jù)。任何時(shí)候Haskell系統(tǒng)能決定整個(gè) hGgetContents 返回的字符串能否被垃圾收集掉,意思就是它不會再被使用,文件會自動(dòng)被關(guān)閉。同樣的原理適用于從文件讀取的數(shù)據(jù)。當(dāng)給定的數(shù)據(jù)片不會再被使用的任何時(shí)候,Haskell會釋放它保存的那塊內(nèi)存。嚴(yán)格意義上來講,我們在這個(gè)例子中根本不必要去調(diào)用 hClose 。但是,養(yǎng)成習(xí)慣去調(diào)用還是個(gè)好的實(shí)踐。以后對程序的修改可能讓 hClose 的調(diào)用變得重要。

Warning

當(dāng)使用 hGetContents 的時(shí)候,記住,就算你可能在剩下的程序里面不再顯式引用句柄 ,你絕不能關(guān)閉句柄 ,直到在你結(jié)束對結(jié)果的使用后, 這點(diǎn)很重要。提早關(guān)閉會造成丟失文件數(shù)據(jù)的部分或全部。因?yàn)镠askell是惰性的,一般地可以假定,你只有在包含輸入的計(jì)算被算出結(jié)果輸出之后,你才能使用這個(gè)輸入。

readFile和writeFile

Haskell程序員經(jīng)常使用 hGetContents 作為一個(gè)過濾器。他們從一個(gè)文件讀取,在數(shù)據(jù)上做一些事情,然后把結(jié)果寫到其他地方。這很常見,有很多種快捷方式可以做。 readFile 和 writeFile 是把文件當(dāng)做字符串處理的快捷方式。他們處理所有細(xì)節(jié),包括打開文件,關(guān)閉文件,讀取文件和寫入文件。 readFile 在內(nèi)部使用 hGetContents 。

你能猜到這些函數(shù)的Haskell類型嗎?我們用 ghci 檢查一下:

ghci> :type readFile
readFile :: FilePath -> IO String
ghci> :type writeFile
writeFile :: FilePath -> String -> IO ()

現(xiàn)在有一個(gè)例子程序使用了 readFile 和 writeFile :

-- file: ch07/toupper-lazy3.hs
import Data.Char(toUpper)

main = do
       inpStr <- readFile "input.txt"
       writeFile "output.txt" (map toUpper inpStr)

看一下,這個(gè)程序的內(nèi)部只有兩行。 readFile 返回一個(gè)惰性 String ,我們保存在 inpStr 。然后我們拿到它,處理它,然后把它傳給 writeFile 函數(shù)去寫入。

readFile 和 writeFile 都不提供一個(gè)句柄給你操作,所以沒有東西要去 hClose 。 readFile 在內(nèi)部使用 hGetContents ,底下的句柄在返回的 String 被垃圾回收或者所有輸入都被消費(fèi)之后就會被關(guān)閉。 writeFile 會在供應(yīng)給它的 String 全部被寫入之后關(guān)閉它底下的句柄。

一言以蔽惰性輸出

到現(xiàn)在為止,你應(yīng)該理解了Haskell的惰性輸入怎么工作的。但是在輸入的時(shí)候惰性是怎么樣的呢?

據(jù)你所知,Haskell中的所有東西都是在需要的時(shí)候才被求值的。因?yàn)橄?writeFile 和 putStr 這樣的函數(shù)寫傳遞給它們的整個(gè) String , 所以這整個(gè) String 必須被求值。所以保證 putStr 的參數(shù)會被完全求值。

但是輸入的惰性是什么意思呢? 在上面的例子中,對 putStr 或者 writeFile 的調(diào)用會強(qiáng)制一次性把整個(gè)輸入字符串載入到內(nèi)存中嗎,直接全部寫出?

答案是否定的。 putStr (以及所有類似的輸出函數(shù))在它變得可用時(shí)才寫出數(shù)據(jù)。他們也不需要保存已經(jīng)寫的數(shù)據(jù),所以只要程序中沒有其他地方需要它,這塊內(nèi)存就可以立即釋放。在某種意義上,你可以把這個(gè)在 readFile 和 writeFile 之間的 String 想成一個(gè)連接它們兩個(gè)的管道。數(shù)據(jù)從一頭進(jìn)去,通過某種方式傳遞,然后從另外一頭流出。

你可以自己驗(yàn)證這個(gè),通過給 toupper-lazy3.hs 產(chǎn)生一個(gè)大的 input.txt 。處理它可能時(shí)間要花一點(diǎn)時(shí)間,但是在處理它的時(shí)候你應(yīng)該能看到一個(gè)常量的并且低的內(nèi)存使用。

interact

你學(xué)習(xí)了 readFile 和 writeFile 處理讀文件,做個(gè)轉(zhuǎn)換,然后寫到不同文件的普通情形。還有一個(gè)比他還普遍的情形:從標(biāo)準(zhǔn)輸入讀取,做一個(gè)轉(zhuǎn)換,然后把結(jié)果寫到標(biāo)準(zhǔn)輸出。對于這種情形,有一個(gè)函數(shù)叫做 interact 。 interact 函數(shù)的類型是 (String->String)->IO() 。也就是說,它接受一個(gè)參數(shù):一個(gè)類型為 String->String 的函數(shù)。 getContents 的結(jié)果傳遞給這個(gè)函數(shù),也就是,惰性讀取標(biāo)準(zhǔn)輸入。這個(gè)函數(shù)的結(jié)果會發(fā)送到標(biāo)準(zhǔn)輸出。

我們可以使用 interact 來轉(zhuǎn)換我們的例子程序去操作標(biāo)準(zhǔn)輸入和標(biāo)準(zhǔn)輸出。這里有一種方式:

-- file: ch07/toupper-lazy4.hs
import Data.Char(toUpper)

main = interact (map toUpper)

來看一下,一行就完成了我們的變換。要實(shí)現(xiàn)上一個(gè)例子同樣的效果,你可以像這樣來運(yùn)行這個(gè)例子:

$ runghc toupper-lazy4.hs < input.txt > output.txt

或者,如果你想看輸出打印在屏幕上的話,你可以打下面的命令:

$ runghc toupper-lazy4.hs < input.txt

如果你想看看Haskell是否真的一接收到數(shù)據(jù)塊就立即寫出的話,運(yùn)行 runghctoupper-lazy4.hs ,不要其他的命令行參數(shù)。你可以看到每一個(gè)你輸入的字符都會立馬回顯,但是都變成大寫了。緩沖區(qū)可能改變這種行為,更多關(guān)于緩沖區(qū)的看這一章后面的 緩沖區(qū)_ 一節(jié)。如果你看到你輸入的沒一行都立馬回顯,或者甚至一段時(shí)間什么都沒有,那就是緩沖區(qū)造成的。

你也可以用 interactive 寫一個(gè)簡單的交互程序。讓我們從一個(gè)簡單的例子開始:

-- file: ch07/toupper-lazy5.hs
import Data.Char(toUpper)

main = interact (map toUpper . (++) "Your data, in uppercase, is:\n\n")

Tip

如果 . 運(yùn)算符不明白的話,你可以參考 使用組合來重用代碼_ 一節(jié)。

這里我們在輸出的開頭添加了一個(gè)字符串。你可以發(fā)現(xiàn)這個(gè)問題嗎?

因?yàn)槲覀冊?(++) 的結(jié)果上調(diào)用 map ,這個(gè)頭自己也會顯示成大寫。我們可以這樣來解決:

-- file: ch07/toupper-lazy6.hs
import Data.Char(toUpper)

main = interact ((++) "Your data, in uppercase, is:\n\n" .
                 map toUpper)

現(xiàn)在把頭移出了 map 。

interact 過濾器

interact 另一個(gè)通常的用法是過濾器。比如說你要寫一個(gè)程序,這個(gè)程序讀一個(gè)文件,并且輸出所有包含字符“a”的行。你可能會這樣用 interact 來實(shí)現(xiàn):

-- file: ch07/filter.hs
main = interact (unlines . filter (elem 'a') . lines)

這里引入了三個(gè)你還不熟悉的函數(shù)。讓我們在 ghci 里檢查它們的類型:

ghci> :type lines
lines :: String -> [String]
ghci> :type unlines
unlines :: [String] -> String
ghci> :type elem
elem :: (Eq a) => a -> [a] -> Bool

你只是看它們的類型,你能猜到它們是干什么的嗎?如果不能,你可以在 熱身:快捷文本行分割_ 一節(jié)和 特殊字符串處理函數(shù)_ 一節(jié)找到解釋。你會頻繁看到 lines 和 unlines 和I/O一起使用。最后, elem 接受一個(gè)元素和一個(gè)列表,如果元素在列中中出現(xiàn)則返回 True 。

試著用我們的標(biāo)準(zhǔn)輸入例子來運(yùn)行:

$ runghc filter.hs < input.txt
I like Haskell
Haskell is great

果然,你得到包含“a”的兩行。惰性過濾器是使用Haskell強(qiáng)大的方式。你想想看,一個(gè)過濾器,就像標(biāo)準(zhǔn)Unix程序 Grep ,聽起來很像一個(gè)函數(shù)。它接受一些輸入,應(yīng)用一些計(jì)算,然后生成一個(gè)意料之中的輸出。

The IO Monad

這個(gè)時(shí)候你已經(jīng)看了若干Haskell中I/O的例子。讓我們花點(diǎn)時(shí)間回想一下,并且思考下I/O是怎么和更廣闊的Haskell語言相關(guān)聯(lián)的。

因?yàn)镠askell是一個(gè)純的語言,如果你給特定的函數(shù)一個(gè)指定的參數(shù),每次你給它那個(gè)參數(shù)這個(gè)函數(shù)將會返回相同的結(jié)果。此外,這個(gè)函數(shù)不會改變程序的總體狀態(tài)的任何東西。

你可能想知道I/O是怎么融合到整體中去的呢?當(dāng)然如果你想從鍵盤輸入中讀取一行,去讀輸入的那個(gè)函數(shù)肯定不可能每次都返回相同的結(jié)果。是不是?此外,I/O都是和改變狀態(tài)相關(guān)的。I/O可以點(diǎn)亮終端上的一個(gè)像素,可以讓打印機(jī)的紙開始出來,或者甚至是讓一個(gè)包裹從倉庫運(yùn)送到另一個(gè)大洲。I/O不只是改變一個(gè)程序的狀態(tài)。你可以把I/O想成可以改變世界的狀態(tài)。

動(dòng)作(Actions)

大多數(shù)語言在純函數(shù)和非純函數(shù)之間沒有明確的區(qū)分。Haskell的函數(shù)有數(shù)學(xué)上的意思:它們是純粹的計(jì)算過程,并且這些計(jì)算不會被外部所影響。此外,這些計(jì)算可以在任何時(shí)候、按需地執(zhí)行。

顯然,我們需要其他一些工具來使用I/O。Haskell里的這個(gè)工具叫做動(dòng)作(Actions)。動(dòng)作類似于函數(shù),它們在定義的時(shí)候不做任何事情,而在它們被調(diào)用時(shí)執(zhí)行一些任務(wù)。I/O動(dòng)作被定義在 IO Monad。Monad是一種強(qiáng)大的將函數(shù)鏈在一起的方法,在 第十四章:Monad_ 會講到。為了理解I/O你不是一定要理解Monad,只要理解操作的返回類型都帶有 IO 就行了。我們來看一些類型:

ghci> :type putStrLn
putStrLn :: String -> IO ()
ghci> :type getLine
getLine :: IO String

putStrLn 的類型就像其他函數(shù)一樣,接受一個(gè)參數(shù),返回一個(gè) IO() 。這個(gè) IO() 就是一個(gè)操作。如果你想你可以在純代碼中保存和傳遞操作,雖然我們不經(jīng)常這么干。一個(gè)操作在它被調(diào)用前不做任何事情。我們看一個(gè)這樣的例子:

-- file: ch07/actions.hs
str2action :: String -> IO ()
str2action input = putStrLn ("Data: " ++ input)

list2actions :: [String] -> [IO ()]
list2actions = map str2action

numbers :: [Int]
numbers = [1..10]

strings :: [String]
strings = map show numbers

actions :: [IO ()]
actions = list2actions strings

printitall :: IO ()
printitall = runall actions

-- Take a list of actions, and execute each of them in turn.
runall :: [IO ()] -> IO ()
runall [] = return ()
runall (firstelem:remainingelems) =
    do firstelem
       runall remainingelems

main = do str2action "Start of the program"
          printitall
          str2action "Done!"

str2action 這個(gè)函數(shù)接受一個(gè)參數(shù)并返回 IO() ,就像你在 main 結(jié)尾看到的那樣,你可以直接在另一個(gè)操作里使用這個(gè)函數(shù),它會立刻打印出一行?;蛘吣憧梢员4妫ú皇菆?zhí)行)純代碼中的操作。你可以在 list2actions 里看到保存的例子,我們在 str2action 用 map ,返回一個(gè)操作的列表,就和操作其他純數(shù)據(jù)一樣。所有東西都通過 printall 顯示出來, 而 printall 是用純代碼寫的。

雖然我們定義了 printall ,但是直到它的操作在其他地方被求值的時(shí)候才會執(zhí)行?,F(xiàn)在注意,我們是怎么在 main 里把 str2action 當(dāng)做一個(gè)I/O操作使用,并且執(zhí)行了它。但是先前我們在I/O Monad外面使用它,只是把結(jié)果收集進(jìn)一個(gè)列表。

你可以這樣來思考: do 代碼塊中的每一個(gè)聲明,除了 let ,都要產(chǎn)生一個(gè)I/O操作,這個(gè)操作在將來被執(zhí)行。

對 printall 的調(diào)用最后會執(zhí)行所有這些操作。實(shí)際上,因?yàn)镠askell是惰性的,所以這些操作直到這里才會被生成。

當(dāng)你運(yùn)行這個(gè)程序時(shí),你的輸出看起來像這樣:

Data: Start of the program
Data: 1
Data: 2
Data: 3
Data: 4
Data: 5
Data: 6
Data: 7
Data: 8
Data: 9
Data: 10
Data: Done!

我們實(shí)際上可以寫的更緊湊。來看看這個(gè)例子的修改:

-- file: ch07/actions2.hs
str2message :: String -> String
str2message input = "Data: " ++ input

str2action :: String -> IO ()
str2action = putStrLn . str2message

numbers :: [Int]
numbers = [1..10]

main = do str2action "Start of the program"
          mapM_ (str2action . show) numbers
          str2action "Done!"

注意在 str2action 里對標(biāo)準(zhǔn)函數(shù)組合運(yùn)算符的使用。在 main 里面,有一個(gè)對 mapM 的調(diào)用,這個(gè)函數(shù)和 map 類似,接受一個(gè)函數(shù)和一個(gè)列表。提供給 mapM 的函數(shù)是一個(gè)I/O操作,這個(gè)操作對列表中的每一項(xiàng)都執(zhí)行。 mapM_ 扔掉了函數(shù)的結(jié)果,但是如果你想要 I/O的結(jié)果,你可以用 mapM 返回一個(gè)I/O結(jié)果的列表。來看一下它們的類型:

ghci> :type mapM
mapM :: (Monad m) => (a -> m b) -> [a] -> m [b]
ghci> :type mapM_
mapM_ :: (Monad m) => (a -> m b) -> [a] -> m ()

Tip

這些函數(shù)其實(shí)可以做I/O更多的事情,所有的Monad都可以使用他們。到現(xiàn)在為止,你看到“M”就把它想成“IO”。還有,那些以下劃線結(jié)尾的函數(shù)一般不管它們的返回值。

為什么我們有了 map 還要有一個(gè) mapM ,因?yàn)?map 是返回一個(gè)列表的純函數(shù),它實(shí)際上不直接執(zhí)行也不能執(zhí)行操作。 maoM 是一個(gè) IO Monda里面的可以執(zhí)行操作的實(shí)用程序。

現(xiàn)在回到 main , mapM 在 numbers.show 每個(gè)元素上應(yīng)用 (str2action.show) , number.show 把每個(gè)數(shù)字轉(zhuǎn)換成一個(gè) String , str2action 把每個(gè) String 轉(zhuǎn)換成一個(gè)操作。 mapM 把這些單獨(dú)的操作組合成一個(gè)打的操作,然后打印出這些行。

串聯(lián)化

do 代碼塊實(shí)際上是把操作連接在一起的快捷記號。有兩個(gè)運(yùn)算符可以用來代替 do 代碼塊: >> 和 >>= 。在 ghci 看一下它們的類型:

ghci> :type (>>)
(>>) :: (Monad m) => m a -> m b -> m b
ghci> :type (>>=)
(>>=) :: (Monad m) => m a -> (a -> m b) -> m b

運(yùn)算符把兩個(gè)操作串聯(lián)在一起:第一個(gè)操作先運(yùn)行,然后是第二個(gè)。運(yùn)算符的計(jì)算的結(jié)果是第二個(gè)操作的結(jié)果,第一個(gè)操作的結(jié)果被丟棄了。這和在 do 代碼塊中只有一行是類似的。你可能會寫 putStrLn"line1">>putStrLn"line2" 來測試這一點(diǎn)。它會打印出兩行,把第一個(gè) putStrLn 的結(jié)果丟掉了,值提供第二個(gè)操作的結(jié)果。

= 運(yùn)算符運(yùn)行一個(gè)操作,然后把它的結(jié)果傳遞給一個(gè)返回操作的函數(shù)。那樣第二個(gè)操作可以同樣運(yùn)行,而且整個(gè)表達(dá)式的結(jié)果就是第二個(gè)操作的結(jié)果。例如,你寫 getLine>>=putStrLn ,這會從鍵盤讀取一行,然后顯示出來。

讓我們重寫例子中的一個(gè),不用 do 代碼快。還記得這一章開頭的這個(gè)例子嗎?

-- file: ch07/basicio.hs
main = do
       putStrLn "Greetings!  What is your name?"
       inpStr <- getLine
       putStrLn $ "Welcome to Haskell, " ++ inpStr ++ "!"

我們不用 do 代碼塊來重寫它:

-- file: ch07/basicio-nodo.hs
main =
    putStrLn "Greetings!  What is your name?" >>
    getLine >>=
    (\inpStr -> putStrLn $ "Welcome to Haskell, " ++ inpStr ++ "!")

你定義 do 代碼塊的時(shí)候,Haskell編譯器內(nèi)部會把它翻譯成像這樣。

Tip

忘記了怎么使用 \ (lambda表達(dá)式)了嗎?參見 匿名(lambda)函數(shù)_ 一節(jié)。

Return的本色

在這一章的前面,我們提到 return 很可能不是它看起來的那樣。很多語言有一個(gè)關(guān)鍵字叫做 return ,它取消函數(shù)的執(zhí)行并立即給調(diào)用者一個(gè)返回值。

Haskell的 return 函數(shù)很不一樣。在Haskell中, return 用來在Monad里面包裝數(shù)據(jù)。當(dāng)說I/O的時(shí)候, return 用來拿到純數(shù)據(jù)并把它帶入IO Monad。

為什么我們需要那樣做?還記得結(jié)果依賴I/O的所有東西都必須在一個(gè)IO Monad里面嗎?所以如果我們在寫一個(gè)執(zhí)行I/O的函數(shù),然后一個(gè)純的計(jì)算,我們需要用 return 來讓這個(gè)純的計(jì)算能給函數(shù)返回一個(gè)合適的值。否則,會發(fā)生一個(gè)類型錯(cuò)誤。這兒有一個(gè)例子:

-- file: ch07/return1.hs
import Data.Char(toUpper)

isGreen :: IO Bool
isGreen =
    do putStrLn "Is green your favorite color?"
       inpStr <- getLine
       return ((toUpper . head $ inpStr) == 'Y')

我們有一個(gè)純的計(jì)算產(chǎn)生一個(gè) Bool ,這個(gè)計(jì)算傳給了 return , return 把它放進(jìn)了 IO Monad。因?yàn)樗?do 代碼塊的最后一個(gè)值,所以它變成 isGreen 的返回值,而不是因?yàn)槲覀冇昧?return 函數(shù)。

這有一個(gè)相同程序但是把純計(jì)算移到一個(gè)單獨(dú)的函數(shù)里的版本。這幫助純代碼保持分離,并且讓意圖更清晰。

-- file: ch07/return2.hs
import Data.Char(toUpper)

isYes :: String -> Bool
isYes inpStr = (toUpper . head $ inpStr) == 'Y'

isGreen :: IO Bool
isGreen =
    do putStrLn "Is green your favorite color?"
       inpStr <- getLine
       return (isYes inpStr)

最后,有一個(gè)人為的例子,這個(gè)例子顯示了 return 確實(shí)沒有在 do 代碼塊的結(jié)尾出現(xiàn)。在實(shí)踐中,通常是這樣的,但是不一定需要這樣。

-- file: ch07/return3.hs
returnTest :: IO ()
returnTest =
    do one <- return 1
       let two = 2
       putStrLn $ show (one + two)

注意,我們用了 <- 和 return 的組合,但是 let 是和簡單字面量組合的。這是因?yàn)槲覀冃枰际羌兊闹挡拍苋ハ嗉铀鼈儯?<- 把東西從Monad里面拿出來,實(shí)際上就是 return 的反作用。在 ghci 運(yùn)行一下,你會看到和預(yù)期一樣顯示3。

Haskell 實(shí)際上是命令式的嗎?

這些 do 代碼塊可能開起來很像一個(gè)命令式語言?畢竟大部分時(shí)間你給了一些命令按順序運(yùn)行。

但是Haskell在它的核心上是一個(gè)惰性語言。時(shí)常在需要給I/O串聯(lián)操作的時(shí)候,是由一些工具完成的,這些工具就是Haskell的一部分。Haskell通過 I/O Monad實(shí)現(xiàn)了出色的I/O和語言剩余部分的分離。

惰性I/O的副作用

本章前面你看到了 hGetContents ,我們解釋說它返回的 String 可以在純代碼中使用。

關(guān)于副作用我們需要得到一些更具體的東西。當(dāng)我們說Haskell沒有副作用,這到底意味著什么?

在一定程度上,副作用總是可能的。一個(gè)寫的不好的循環(huán),就算寫成純代碼形式的,也會造成系統(tǒng)內(nèi)存耗盡和機(jī)器崩潰,或者導(dǎo)致數(shù)據(jù)交換到硬盤上。

當(dāng)我們說沒有副作用的時(shí)候,我們意思是,Haskell中的存代碼不能運(yùn)行那些能觸發(fā)副作用的命令。純函數(shù)不能修改全局變量,請求I/O,或者運(yùn)行一條關(guān)閉系統(tǒng)的命令。

當(dāng)你有從 hGetContents 拿到一個(gè) String ,你把它傳給一個(gè)純函數(shù),這個(gè)函數(shù)不知道這個(gè) String 是由硬盤文件上來的。這個(gè)函數(shù)表現(xiàn)地還是和原來一樣,但是處理那個(gè) String 的時(shí)候可能造成環(huán)境發(fā)出I/O命令。純函數(shù)是不會發(fā)出I/O命令的,它們作為處理正在運(yùn)行的純函數(shù)的一個(gè)結(jié)果,就和交換內(nèi)存到磁盤的例子一樣。

有時(shí)候,你在I/O發(fā)生時(shí)需要更多的控制。可能你正在從用戶那里交互地讀取數(shù)據(jù),或者通過管道從另一個(gè)程序讀取數(shù)據(jù),你需要直接和用戶交流。在這些時(shí)候, hGetContents 可能就不合適了。

緩沖區(qū)(Buffering)

I/O子系統(tǒng)是現(xiàn)代計(jì)算機(jī)中最慢的部分之一。完成一次寫磁盤的時(shí)間是一次寫內(nèi)存的幾千倍。在網(wǎng)絡(luò)上的寫入還要慢成百上千倍。就算你的操作沒有直接和磁盤通信,可能數(shù)據(jù)被緩存了,I/O還是需要一個(gè)系統(tǒng)調(diào)用,這個(gè)也會減慢速度。

由于這個(gè)原因,現(xiàn)代操作系統(tǒng)和編程語言都提供了工具來幫助程序當(dāng)涉及到I/O的時(shí)候更好地運(yùn)行。操作系統(tǒng)一般采用緩存(Cache),把頻繁使用的數(shù)據(jù)片段保存在內(nèi)存中,這樣就能更快的訪問了。

編程語言通常采用緩沖區(qū)。就是說,它們可能從操作系統(tǒng)請求一大塊數(shù)據(jù),就算底層代碼是一次一個(gè)字節(jié)地處理數(shù)據(jù)的。通過這樣,它們可以實(shí)現(xiàn)顯著的性能提升,因?yàn)槊看蜗虿僮飨到y(tǒng)的I/O請求帶來一次處理開銷。緩沖區(qū)允許我們?nèi)プx相同數(shù)量的數(shù)據(jù)可以用少得多的I/O請求。

緩沖區(qū)模式

Haskell中有3種不同的緩沖區(qū)模式,它們定義成 BufferMode 類型: NoBuffering , LineBuffering 和 BlockBuffering 。

NoBuffering 就和它聽起來那樣-沒有緩沖區(qū)。通過像 hGetLine 這樣的函數(shù)讀取的數(shù)據(jù)是從操作系統(tǒng)一次一個(gè)字符讀取的。寫入的數(shù)據(jù)會立即寫入,也是一次一個(gè)字符地寫入。因此, NoBuffering 通常性能很差,不適用于一般目的的使用。

LineBuffering 當(dāng)換行符輸出的時(shí)候會讓輸出緩沖區(qū)寫入,或者當(dāng)緩沖區(qū)太大的時(shí)候。在輸入上,它通常試圖去讀取塊上所有可用的字符,直到它首次遇到換行符。當(dāng)從終端讀取的時(shí)候,每次按下回車之后它會立即返回?cái)?shù)據(jù)。這個(gè)模式經(jīng)常是默認(rèn)模式。

BlockBuffering 讓Haskell在可能的時(shí)候以一個(gè)固定的塊大小讀取或者寫入數(shù)據(jù)。這在批處理大量數(shù)據(jù)的時(shí)候是性能做好的,就算數(shù)據(jù)是以行存儲的也是一樣。然而,這個(gè)對于交互程序不能用,因?yàn)樗鼤枞斎胫钡揭徽麎K數(shù)據(jù)被讀取。 BlockBuffering 接受一個(gè) Maybe 類型的參數(shù): 如果是 Nothing , 它會使用一個(gè)自定的緩沖區(qū)大小,或者你可以使用一個(gè)像 Just4096 的設(shè)定,設(shè)置緩沖區(qū)大小為4096個(gè)字節(jié)。

默認(rèn)的緩沖區(qū)模式依賴于操作系統(tǒng)和Haskell的實(shí)現(xiàn)。你可以通過調(diào)用 hGetBuffering 查看系統(tǒng)的當(dāng)前緩沖區(qū)模式。當(dāng)前的模式可以通過 hSetBuffering 來設(shè)置,它接受一個(gè) Handle 和 BufferMode 。例如,你可以寫 hSetBufferingstdin(BlockBufferingNothing) 。

刷新緩沖區(qū)

對于任何類型的緩沖區(qū),你可能有時(shí)候需要強(qiáng)制Haskell去寫出所有保存在緩沖區(qū)里的數(shù)據(jù)。有些時(shí)候這個(gè)會自動(dòng)發(fā)生:比如,對 hClose 的調(diào)用。有時(shí)候你可能需要調(diào)用 hFlush 作為代替, hFlush 會強(qiáng)制所有等待的數(shù)據(jù)立即寫入。這在句柄是一個(gè)網(wǎng)絡(luò)套接字的時(shí)候,你想數(shù)據(jù)被立即傳輸,或者你想讓磁盤的數(shù)據(jù)給其他程序使用,而其他程序也正在并發(fā)地讀那些數(shù)據(jù)的時(shí)候都是有用的。

讀取命令行參數(shù)

很多命令行程序喜歡通過命令行來傳遞參數(shù)。 System.Environment.getArgs 返回 IO[String] 列出每個(gè)參數(shù)。這和C語言的 argv 一樣,從 argv[1] 開始。程序的名字(C語言的 argv[0] )用 System.Environment.getProgName 可以得到。

System.Console.GetOpt 模塊提供了一些解析命令行選項(xiàng)的工具。如果你有一個(gè)程序,它有很復(fù)雜的選項(xiàng),你會覺得它很有用。你可以在 命令行解析_ 一節(jié)看到一個(gè)例子和使用方法。

環(huán)境變量

如果你需要閱讀環(huán)境變量,你可以使用 System.Environment 里面兩個(gè)函數(shù)中的一個(gè): getEnv 或者 getEnvironment 。 getEnv 查找指定的變量,如果不存在會拋出異常。 getEnvironment 用一個(gè) [(String,String))] 返回整個(gè)環(huán)境,然后你可以用 lookup 這樣的函數(shù)來找你想要的環(huán)境條目。

在Haskell設(shè)置環(huán)境變量沒有采用跨平臺的方式來定義。如果你在像Linux這樣的POSIX平臺上,你可以使用 System.Posix.Env 模塊中的 putEnv 或者 setEnv 。環(huán)境設(shè)置在Windows下面沒有定義。

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

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號