第七章:I/O

2018-02-24 15:49 更新

第七章:I/O

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

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

我們將用簡單標準的I/O來開始這一章。然后我們要討論下一些更強大的選項,以及提供更多I/O是怎么適應純的,惰性的,函數(shù)式的Haskell世界的細節(jié)。

Haskell經(jīng)典I/O

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

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

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

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

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

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

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

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

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

Tip

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

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

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

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

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

Note

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

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

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

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

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

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

-- 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ù)。這是一個Haskell的一個常規(guī)函數(shù),它遵守所有我們告訴過你的規(guī)則:給它相同的輸入,它總是返回相同的結(jié)果,沒有副作用,并且以惰性方式運行。它用了其他Haskell函數(shù): (++) , show 和 length 。

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

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

我們在 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 ,因為使用 show 來顯示返回值。但是使用 putStrLn 來發(fā)送到終端的話,終端會把 \n 解釋成開始新的一行。

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

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

Pure vs. I/O

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

表格7.1. Pure vs. Impure

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

為什么純不純很重要?

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

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

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

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

使用文件和句柄(Handle)

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

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

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

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

-- 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

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

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

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

我們來嘗試運行這個程序。我們已經(jīng)有一個像這樣的名字叫 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 。它看起來應該是這樣:

THIS IS CH08/INPUT.TXT

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

123456789

關于 openFile 的更多信息

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

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

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

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

表格7.2. IOMode 可能值

IOMode 可讀 可寫 開始位置 備注
ReadMode 文件開頭 文件必須存在
WriteMode 文件開頭 如果存在,文件會被截斷(完全清空)
ReadWriteMode 文件開頭 如果不存在會新建文件,如果存在不會損害原來的數(shù)據(jù)
AppendMode 文件結(jié)尾 如果不存在會新建文件,如果存在不會損害原來的數(shù)據(jù)

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

關閉句柄

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

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

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

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

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

Seek and Tell

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

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

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

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

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

標準輸入,輸出和錯誤

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

在 System.IO 里有3個預定義的句柄,這些句柄總是可用的。他們是 stdin ,對應標準輸入; stdout ,對應標準輸出;和 stderr 對應標準錯誤。標準輸入一般對應鍵盤,標準輸出對應顯示器,標準錯誤一般輸出到顯示器。

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

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

Tip

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

之前我們告訴你這3個標準文件句柄一般對應什么。那是因為一些操作系統(tǒng)可以讓你重定向這個文件句柄到不同的地方-文件,設備,甚至是其他程序。這個功能在POSIX(Linux,BSD,Mac)操作系統(tǒng)Shell編程中廣泛使用,在Windows中也能使用。

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

舉一個例子,我們可以像這樣在前面提供標準輸入給 callingpure.hs :

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

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

刪除和重命名文件

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

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

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

臨時文件

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

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

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

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

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

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

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

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

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

myAction 顯示一些信息到終端,寫一些數(shù)據(jù)到文件,尋找文件的開頭,并且使用 hGetContents 把數(shù)據(jù)讀取回來。然后把文件的內(nèi)容按字節(jié)地,通過 printc 當做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"

每次你運行這個程序,你的臨時文件的名字應該有點細微的差別,因為它包含了一個隨機生成的部分??匆幌逻@個輸出,你可能會問一些問題?

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

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

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

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

惰性I/O

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

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

hGetContents

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

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

但是 hGetContents 不一樣。它返回的 String 是惰性估值的。在你調(diào)用 hGetContents 的時刻,實際上沒有讀任何東西。數(shù)據(jù)只從句柄讀取, 作為處理的一個元素(字符)列表。 String 的元素一直都用不到,Haskell的垃圾收集器會自動釋放那塊內(nèi)存。所有這些都是完全透明地發(fā)生的。因為函數(shù)的返回值是一個如假包換的純 String ,所以它可以被傳遞給非 I/O 的純代碼。讓我們快速看一個例子?;氐?操作文件和句柄_ 一節(jié),你看到一個命令式的程序,它把整個文件內(nèi)容轉(zhuǎn)換成大寫。它的命令式算法和你在其他語言看到的很類似。接下來展示的是一個利用了惰性求值實現(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 ,它是一個純函數(shù),因為它沒有副作用,并且每次調(diào)用的時候總是返回相同的結(jié)果。它不需要知道,也沒辦法告訴它,它的輸入是惰性從文件讀取的。不管是20個字符的字面量還是硬盤上500GB的數(shù)據(jù)它都可以很好的工作。

你可以用 ghci 驗證一下:

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)存中將沒有它了。這是因為編譯器會強制保存 inpStr 的值在內(nèi)存里,為了以后的使用。這里我們知道 inpStr 講不會被重用,它一被使用完就會被釋放內(nèi)存。只要記?。鹤詈笠淮问褂煤筢尫艃?nèi)存。

這個程序為了清楚地表明使用了存代碼,顯得有點啰嗦。這里有更加簡潔的版本,新版本在下一個例子里:

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

Warning

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

readFile和writeFile

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

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

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

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

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

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

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

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

一言以蔽惰性輸出

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

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

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

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

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

interact

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

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

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

main = interact (map toUpper)

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

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

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

$ runghc toupper-lazy4.hs < input.txt

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

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

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

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

Tip

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

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

因為我們在 (++) 的結(jié)果上調(diào)用 map ,這個頭自己也會顯示成大寫。我們可以這樣來解決:

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

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

這里引入了三個你還不熟悉的函數(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 接受一個元素和一個列表,如果元素在列中中出現(xiàn)則返回 True 。

試著用我們的標準輸入例子來運行:

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

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

The IO Monad

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

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

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

動作(Actions)

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

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

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

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

-- 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 這個函數(shù)接受一個參數(shù)并返回 IO() ,就像你在 main 結(jié)尾看到的那樣,你可以直接在另一個操作里使用這個函數(shù),它會立刻打印出一行。或者你可以保存(不是執(zhí)行)純代碼中的操作。你可以在 list2actions 里看到保存的例子,我們在 str2action 用 map ,返回一個操作的列表,就和操作其他純數(shù)據(jù)一樣。所有東西都通過 printall 顯示出來, 而 printall 是用純代碼寫的。

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

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

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

當你運行這個程序時,你的輸出看起來像這樣:

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!

我們實際上可以寫的更緊湊。來看看這個例子的修改:

-- 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 里對標準函數(shù)組合運算符的使用。在 main 里面,有一個對 mapM 的調(diào)用,這個函數(shù)和 map 類似,接受一個函數(shù)和一個列表。提供給 mapM 的函數(shù)是一個I/O操作,這個操作對列表中的每一項都執(zhí)行。 mapM_ 扔掉了函數(shù)的結(jié)果,但是如果你想要 I/O的結(jié)果,你可以用 mapM 返回一個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ù)其實可以做I/O更多的事情,所有的Monad都可以使用他們。到現(xiàn)在為止,你看到“M”就把它想成“IO”。還有,那些以下劃線結(jié)尾的函數(shù)一般不管它們的返回值。

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

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

串聯(lián)化

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

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

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

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

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

-- 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 代碼塊的時候,Haskell編譯器內(nèi)部會把它翻譯成像這樣。

Tip

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

Return的本色

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

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

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

-- 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')

我們有一個純的計算產(chǎn)生一個 Bool ,這個計算傳給了 return , return 把它放進了 IO Monad。因為它是 do 代碼塊的最后一個值,所以它變成 isGreen 的返回值,而不是因為我們用了 return 函數(shù)。

這有一個相同程序但是把純計算移到一個單獨的函數(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)

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

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

注意,我們用了 <- 和 return 的組合,但是 let 是和簡單字面量組合的。這是因為我們需要都是純的值才能去相加它們, <- 把東西從Monad里面拿出來,實際上就是 return 的反作用。在 ghci 運行一下,你會看到和預期一樣顯示3。

Haskell 實際上是命令式的嗎?

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

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

惰性I/O的副作用

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

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

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

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

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

有時候,你在I/O發(fā)生時需要更多的控制??赡苣阏趶挠脩裟抢锝换サ刈x取數(shù)據(jù),或者通過管道從另一個程序讀取數(shù)據(jù),你需要直接和用戶交流。在這些時候, hGetContents 可能就不合適了。

緩沖區(qū)(Buffering)

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

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

編程語言通常采用緩沖區(qū)。就是說,它們可能從操作系統(tǒng)請求一大塊數(shù)據(jù),就算底層代碼是一次一個字節(jié)地處理數(shù)據(jù)的。通過這樣,它們可以實現(xiàn)顯著的性能提升,因為每次向操作系統(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)一次一個字符讀取的。寫入的數(shù)據(jù)會立即寫入,也是一次一個字符地寫入。因此, NoBuffering 通常性能很差,不適用于一般目的的使用。

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

BlockBuffering 讓Haskell在可能的時候以一個固定的塊大小讀取或者寫入數(shù)據(jù)。這在批處理大量數(shù)據(jù)的時候是性能做好的,就算數(shù)據(jù)是以行存儲的也是一樣。然而,這個對于交互程序不能用,因為它會阻塞輸入直到一整塊數(shù)據(jù)被讀取。 BlockBuffering 接受一個 Maybe 類型的參數(shù): 如果是 Nothing , 它會使用一個自定的緩沖區(qū)大小,或者你可以使用一個像 Just4096 的設定,設置緩沖區(qū)大小為4096個字節(jié)。

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

刷新緩沖區(qū)

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

讀取命令行參數(shù)

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

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

環(huán)境變量

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

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

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

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號