輸入與輸出
Last updated
Last updated
我們已經說明了 Haskell 是一個純粹函數式語言。雖說在命令式語言中我們習慣給電腦執行一連串指令,在函數式語言中我們是用定義東西的方式進行。在 Haskell 中,一個函數不能改變狀態,像是改變一個變數的內容。(當一個函數會改變狀態,我們說這函數是有副作用的。)在 Haskell 中函數唯一可以做的事是根據我們給定的參數來算出結果。如果我們用同樣的參數呼叫兩次同一個函數,它會回傳相同的結果。儘管這從命令式語言的角度來看是蠻大的限制,我們已經看過它可以達成多麼酷的效果。在一個命令式語言中,程式語言沒辦法給你任何保證在一個簡單如打印出幾個數字的函數不會同時燒掉你的房子,綁架你的狗並刮傷你車子的烤漆。例如,當我們要建立一棵二元樹的時候,我們並不插入一個節點來改變原有的樹。由於我們無法改變狀態,我們的函數實際上回傳了一棵新的二元樹。
函數無法改變狀態的好處是它讓我們促進了我們理解程式的容易度,但同時也造成了一個問題。假如說一個函數無法改變現實世界的狀態,那它要如何打印出它所計算的結果?畢竟要告訴我們結果的話,它必須要改變輸出裝置的狀態(譬如說螢幕),然後從螢幕傳達到我們的腦,並改變我們心智的狀態。
不要太早下結論,Haskell 實際上設計了一個非常聰明的系統來處理有副作用的函數,它漂亮地將我們的程式區分成純粹跟非純粹兩部分。非純粹的部分負責跟鍵盤還有螢幕溝通。有了這區分的機制,在跟外界溝通的同時,我們還是能夠有效運用純粹所帶來的好處,像是惰性求值、容錯性跟模組性。
到目前為止我們都是將函數載入 GHCi 中來測試,像是標準函式庫中的一些函式。但現在我們要做些不一樣的,寫一個真實跟世界互動的 Haskell 程式。當然不例外,我們會來寫個 "hello world"。
現在,我們把下一行打到你熟悉的編輯器中
我們定義了一個 main
,並在裡面以 "hello, world"
為參數呼叫了 putStrLn
。看起來沒什麼大不了,但不久你就會發現它的奧妙。把這程式存成 helloworld.hs
。
現在我們將做一件之前沒做過的事:編譯你的程式。打開你的終端並切換到包含 helloworld.hs
的目錄,並輸入下列指令。
順利的話你就會得到如上的訊息,接著你便可以執行你的程式 ./helloworld
這就是我們第一個編譯成功並打印出字串到螢幕的程式。很簡單吧。
讓我們來看一下我們究竟做了些什麼,首先來看一下 putStrLn
函數的型態:
我們可以這麼解讀 putStrLn
的型態:putStrLn
接受一個字串並回傳一個 I/O action,這 I/O action 包含了 ()
的型態。(即空的 tuple,或者是 unit 型態)。一個 I/O action 是一個會造成副作用的動作,常是指讀取輸入或輸出到螢幕,同時也代表會回傳某些值。在螢幕打印出幾個字串並沒有什麼有意義的回傳值可言,所以這邊用一個 ()
來代表。
那究竟 I/O action 會在什麼時候被觸發呢?這就是 main
的功用所在。一個 I/O action 會在我們把它綁定到 main
這個名字並且執行程式的時候觸發。
把整個程式限制在只能有一個 I/O action 看似是個極大的限制。這就是為什麼我們需要 do 表示法來將所有 I/O action 綁成一個。來看看下面這個例子。
新的語法,有趣吧!它看起來就像一個命令式的程式。如果你編譯並執行它,它便會照你預期的方式執行。我們寫了一個 do 並且接著一連串指令,就像寫個命令式程式一般,每一步都是一個 I/O action。將所有 I/O action 用 do 綁在一起變成了一個大的 I/O action。這個大的 I/O action 的型態是 IO ()
,這完全是由最後一個 I/O action 所決定的。
這就是為什麼 main
的型態永遠都是 main :: IO something
,其中 something
是某個具體的型態。按照慣例,我們通常不會把 main
的型態在程式中寫出來。
另一個有趣的事情是第三行 name <- getLine
。它看起來像是從輸入讀取一行並存到一個變數 name
之中。真的是這樣嗎?我們來看看 getLine
的型態吧
我們可以看到 getLine
是一個回傳 String
的 I/O action。因為它會等使用者輸入某些字串,這很合理。那 name <- getLine
又是如何?你能這樣解讀它:執行一個 I/O action getLine
並將它的結果綁定到 name
這個名字。getLine
的型態是 IO String
,所以 name
的型態會是 String
。你能把 I/O action 想成是一個長了腳的盒子,它會跑到真實世界中替你做某些事,像是在牆壁上塗鴉,然後帶回來某些資料。一旦它帶了某些資料給你,打開盒子的唯一辦法就是用 <-
。而且如果我們要從 I/O action 拿出某些資料,就一定同時要在另一個 I/O action 中。這就是 Haskell 如何漂亮地分開純粹跟不純粹的程式的方法。getLine
在這樣的意義下是不純粹的,因為執行兩次的時候它沒辦法保證會回傳一樣的值。這也是為什麼它需要在一個 IO
的型態建構子中,那樣我們才能在 I/O action 中取出資料。而且任何一段程式一旦依賴著 I/O 資料的話,那段程式也會被視為 I/O code。
但這不表示我們不能在純粹的程式碼中使用 I/O action 回傳的資料。只要我們綁定它到一個名字,我們便可以暫時地使用它。像在 name <- getLine
中 name
不過是一個普通字串,代表在盒子中的內容。我們能將這個普通的字串傳給一個極度複雜的函數,並回傳你一生會有多少財富。像是這樣:
tellFortune
並不知道任何 I/O 有關的事,它的型態只不過是 String -> String
。
再來看看這段程式碼吧,他是合法的嗎?
如果你回答不是,恭喜你。如果你說是,你答錯了。這麼做不對的理由是 ++
要求兩個參數都必須是串列。他左邊的參數是 String
,也就是 [Char]
。然而 getLine
的型態是 IO String
。你不能串接一個字串跟 I/O action。我們必須先把 String
的值從 I/O action 中取出,而唯一可行的方法就是在 I/O action 中使用 name <- getLine
。如果我們需要處理一些非純粹的資料,那我們就要在非純粹的環境中做。所以我們最好把 I/O 的部分縮減到最小的比例。
每個 I/O action 都有一個值封裝在裡面。這也是為什麼我們之前的程式可以這麼寫:
然而,foo
只會有一個 ()
的值,所以綁定到 foo
這個名字似乎是多餘的。另外注意到我們並沒有綁定最後一行的 putStrLn
給任何名字。那是因為在一個 do block 中,最後一個 action 不能綁定任何名字。我們在之後講解 Monad 的時候會說明為什麼。現在你可以先想成 do block 會自動從最後一個 action 取出值並綁定給他的結果。
除了最後一行之外,其他在 do 中沒有綁定名字的其實也可以寫成綁定的形式。所以 putStrLn "BLAH"
可以寫成 _ <- putStrLn "BLAH"
。但這沒什麼實際的意義,所以我們寧願寫成 putStrLn something
。
初學者有時候會想錯
以為這行會讀取輸入並給他綁定一個名字叫 name
但其實只是把 getLine
這個 I/O action 指定一個名字叫 name
罷了。記住,要從一個 I/O action 中取出值,你必須要在另一個 I/O action 中將他用 <-
綁定給一個名字。
I/O actions 只會在綁定給 main
的時候或是在另一個用 do 串起來的 I/O action 才會執行。你可以用 do 來串接 I/O actions,再用 do 來串接這些串接起來的 I/O actions。不過只有最外面的 I/O action 被指定給 main 才會觸發執行。
喔對,其實還有另外一個情況。就是在 GHCi 中輸入一個 I/O action 並按下 Enter 鍵,那也會被執行
就算我們只是在 GHCi 中打幾個數字或是呼叫一個函數,按下 Enter 就會計算它並呼叫 show
,再用 putStrLn
將字串打印出在終端上。
還記得 let binding 嗎?如果不記得,回去溫習一下這個章節。它們的形式是 let bindings in expression
,其中 bindings
是 expression 中的名字、expression
則是被運用到這些名字的算式。我們也提到了 list comprehensions 中,in
的部份不是必需的。你能夠在 do blocks 中使用 let bindings 如同在 list comprehensions 中使用它們一樣,像這樣:
注意我們是怎麼編排在 do block 中的 I/O actions,也注意到我們是怎麼編排 let 跟其中的名字的,由於對齊在 Haskell 中並不會被無視,這麼編排才是好的習慣。我們的程式用 map toUpper firstName
將 "John"
轉成大寫的 "JOHN"
,並將大寫的結果綁定到一個名字上,之後在輸出的時候參考到了這個名字。
你也許會問究竟什麼時候要用 <-
,什麼時候用 let bindings?記住,<-
是用來運算 I/O actions 並將他的結果綁定到名稱。而 map toUpper firstName
並不是一個 I/O action。他只是一個純粹的 expression。所以總結來說,當你要綁定 I/O actions 的結果時用 <-
,而對於純粹的 expression 使用 let bindings。對於錯誤的 let firstName = getLine
,我們只不過是把 getLine
這個 I/O actions 給了一個不同的名字罷了。最後還是要用 <-
將結果取出。
現在我們來寫一個會一行一行不斷地讀取輸入,並將讀進來的字反過來輸出到螢幕上的程式。程式會在輸入空白行的時候停止。
在分析這段程式前,你可以執行看看來感受一下程式的運行。
首先,我們來看一下 reverseWords
。他不過是一個普通的函數,假如接受了個字串 "hey there man"
,他會先呼叫 words
來產生一個字的串列 ["hey", "there", "man"]
。然後用 reverse
來 map 整個串列,得到 ["yeh", "ereht", "nam"]
,接著用 unwords
來得到最終的結果 "yeh ereht nam"
。這些用函數合成來簡潔的表達。如果沒有用函數合成,那就會寫成醜醜的樣子 reverseWords st = unwords (map reverse (words st))
那 main
又是怎麼一回事呢?首先,我們用 getLine
從終端讀取了一行,並把這行輸入取名叫 line
。然後接著一個條件式 expression。記住,在 Haskell 中 if 永遠要伴隨一個 else,這樣每個 expression 才會有值。當 if 的條件是 true (也就是輸入了一個空白行),我們便執行一個 I/O action,如果 if 的條件是 false,那 else 底下的 I/O action 被執行。這也就是說當 if 在一個 I/O do block 中的時候,長的樣子是 if condition then I/O action else I/O action
。
我們首先來看一下在 else 中發生了什麼事。由於我們在 else 中只能有一個 I/O action,所以我們用 do 來將兩個 I/O actions 綁成一個,你可以寫成這樣:
這樣可以明顯看到整個 do block 可以看作一個 I/O action,只是比較醜。但總之,在 do block 裡面,我們依序呼叫了 getLine
以及 reverseWords
,在那之後,我們遞迴呼叫了 main
。由於 main 也是一個 I/O action,所以這不會造成任何問題。呼叫 main
也就代表我們回到程式的起點。
那假如 null line
的結果是 true 呢?也就是說 then 的區塊被執行。我們看一下區塊裡面有 then return ()
。如果你是從 C、Java 或 Python 過來的,你可能會認為 return
不過是作一樣的事情便跳過這一段。但很重要的: return
在 Hakell 裡面的意義跟其他語言的 return
完全不同!他們有相同的樣貌,造成了許多人搞錯,但確實他們是不一樣的。在命令式語言中,return
通常結束 method 或 subroutine 的執行,並且回傳某個值給呼叫者。在 Haskell 中,他的意義則是利用某個 pure value 造出 I/O action。用之前盒子的比喻來說,就是將一個 value 裝進箱子裡面。產生出的 I/O action 並沒有作任何事,只不過將 value 包起來而已。所以在 I/O 的情況下來說,return "haha"
的型態是 IO String
。將 pure value 包成 I/O action 有什麼實質意義呢?為什麼要弄成 IO
包起來的值?這是因為我們一定要在 else 中擺上某些 I/O action,所以我們才用 return ()
做了一個沒作什麼事情的 I/O action。
在 I/O do block 中放一個 return
並不會結束執行。像下面這個程式會執行到底。
所有在程式中的 return
都是將 value 包成 I/O actions,而且由於我們沒有將他們綁定名稱,所以這些結果都被忽略。我們能用 <-
與 return
來達到綁定名稱的目的。
可以看到 return
與 <-
作用相反。return
把 value 裝進盒子中,而 <-
將 value 從盒子拿出來,並綁定一個名稱。不過這麼做是有些多餘,因為你可以用 let bindings 來綁定
在 I/O do block 中需要 return
的原因大致上有兩個:一個是我們需要一個什麼事都不做的 I/O action,或是我們不希望這個 do block 形成的 I/O action 的結果值是這個 block 中的最後一個 I/O action,我們希望有一個不同的結果值,所以我們用 return
來作一個 I/O action 包了我們想要的結果放在 do block 的最後。
在我們接下去講檔案之前,讓我們來看看有哪些實用的函數可以處理 I/O。
putStr
跟 putStrLn
幾乎一模一樣,都是接受一個字串當作參數,並回傳一個 I/O action 打印出字串到終端上,只差在 putStrLn
會換行而 putStr
不會罷了。
他的 type signature 是 putStr :: String -> IO ()
,所以是一個包在 I/O action 中的 unit。也就是空值,沒有辦法綁定他。
putChar
接受一個字元,並回傳一個 I/O action 將他打印到終端上。
putStr
實際上就是 putChar
遞迴定義出來的。putStr
的邊界條件是空字串,所以假設我們打印一個空字串,那他只是回傳一個什麼都不做的 I/O action,像 return ()
。如果打印的不是空字串,那就先用 putChar
打印出字串的第一個字元,然後再用 putStr
打印出字串剩下部份。
看看我們如何在 I/O 中使用遞迴,就像我們在 pure code 中所做的一樣。先定義一個邊界條件,然後再思考剩下如何作。
print
接受任何是 Show
typeclass 的 instance 的型態的值,這代表我們知道如何用字串表示他,呼叫 show
來將值變成字串然後將其輸出到終端上。基本上,他就是 putStrLn . show
。首先呼叫 show
然後把結果餵給 putStrLn
,回傳一個 I/O action 打印出我們的值。
就像你看到的,這是個很方便的函數。還記得我們提到 I/O actions 只有在 main
中才會被執行以及在 GHCI 中運算的事情嗎?當我們用鍵盤打了些值,像 3
或 [1,2,3]
並按下 Enter,GHCI 實際上就是用了 print
來將這些值輸出到終端。
當我們需要打印出字串,我們會用 putStrLn
,因為我們不想要周圍有引號,但對於輸出值來說,print
才是最常用的。
getChar
是一個從輸入讀進一個字元的 I/O action,因此他的 type signature 是 getChar :: IO Char
,代表一個 I/O action 的結果是 Char
。注意由於緩衝區的關係,只有當 Enter 被按下的時候才會觸發讀取字元的行為。
這程式看起來像是讀取一個字元並檢查他是否為一個空白。如果是的話便停止,如果不是的話便打印到終端上並重複之前的行為。在某種程度上來說也不能說錯,只是結果不如你預期而已。來看看結果吧。
上面的第二行是輸入。我們輸入了 hello sir
並按下了 Enter。由於緩衝區的關係,程式是在我們按了 Enter 後才執行而不是在某個輸入字元的時候。一旦我們按下了 Enter,那他就把我們直到目前輸入的一次做完。
when
這函數可以在 Control.Monad
中找到他 (你必須 import Contorl.Monad
才能使用他)。他在一個 do block 中看起來就像一個控制流程的 statement,但實際上他的確是一個普通的函數。他接受一個 boolean 值跟一個 I/O action。如果 boolean 值是 True
,便回傳我們傳給他的 I/O action。如果 boolean 值是 False
,便回傳 return ()
,即什麼都不做的 I/O action。我們接下來用 when
來改寫我們之前的程式。
就像你看到的,他可以將 if something then do some I/O action else return ()
這樣的模式封裝起來。
sequence
接受一串 I/O action,並回傳一個會依序執行他們的 I/O action。運算的結果是包在一個 I/O action 的一連串 I/O action 的運算結果。他的 type signature 是 sequence :: [IO a] -> IO [a]
其實可以寫成
所以 sequence [getLine, getLine, getLine]
作成了一個執行 getLine
三次的 I/O action。如果我們對他綁定一個名字,結果便是這串結果的串列。也就是說,三個使用者輸入的東西組成的串列。
一個常見的使用方式是我們將 print
或 putStrLn
之類的函數 map 到串列上。map print [1,2,3,4]
這個動作並不會產生一個 I/O action,而是一串 I/O action,就像是 [print 1, print 2, print 3, print 4]
。如果我們將一串 I/O action 變成一個 I/O action,我們必須用 sequence
那 [(),(),(),(),()]
是怎麼回事?當我們在 GHCI 中運算 I/O action,他會被執行並把結果打印出來,唯一例外是結果是 ()
的時候不會被打印出。這也是為什麼 putStrLn "hehe"
在 GHCI 中只會打印出 hehe
(因為 putStrLn "hehe"
的結果是 ()
)。但當我們使用 getLine
時,由於 getLine
的型態是 IO String
,所以結果會被打印出來。
由於對一個串列 map 一個回傳 I/O action 的函數,然後再 sequence 他這個動作太常用了。所以有一些函數在函式庫中 mapM
跟 mapM_
。mapM
接受一個函數跟一個串列,將對串列用函數 map 然後 sequence 結果。mapM_
也作同樣的事,只是他把運算的結果丟掉而已。在我們不關心 I/O action 結果的情況下,mapM_
是最常被使用的。
forever
接受一個 I/O action 並回傳一個永遠作同一件事的 I/O action。你可以在 Control.Monad
中找到他。下面的程式會不斷地要使用者輸入些東西,並把輸入的東西轉成大寫輸出到螢幕上。
在 Control.Monad
中的 forM
跟 mapM
的作用一樣,只是參數的順序相反而已。第一個參數是串列,而第二個則是函數。這有什麼用?在一些有趣的情況下還是有用的:
(\a -> do ...)
是接受一個數字並回傳一個 I/O action 的函數。我們必須用括號括住他,不然 lambda 會貪心 match 的策略會把最後兩個 I/O action 也算進去。注意我們在 do block 裡面 return color
。我們那麼作是讓 do block 的結果是我們選的顏色。實際上我們並不需那麼作,因為 getLine
已經達到我們的目的。先 color <- getLine
再 return color
只不過是把值取出再包起來,其實是跟 getLine
效果相當。forM
產生一個 I/O action,我們把結果綁定到 colors
這名稱。colors
是一個普通包含字串的串列。最後,我們用 mapM putStrLn colors
打印出所有顏色。
你可以把 forM
的意思想成將串列中的每個元素作成一個 I/O action。至於每個 I/O action 實際作什麼就要看原本的元素是什麼。然後,執行這些 I/O action 並將結果綁定到某個名稱上。或是直接將結果忽略掉。
其實我們也不是一定要用到 forM
,只是用了 forM
程式會比較容易理解。正常來講是我們需要在 map 跟 sequence 的時候定義 I/O action 的時候使用 forM
,同樣地,我們也可以將最後一行寫成 forM colors putStrLn
。
在這一節,我們學會了輸入與輸出的基礎。我們也了解了什麼是 I/O action,他們是如何幫助我們達成輸入與輸出的目的。這邊重複一遍,I/O action 跟其他 Haskell 中的 value 沒有兩樣。我們能夠把他當參數傳給函式,或是函式回傳 I/O action。他們特別之處在於當他們是寫在 main
裡面或 GHCI 裡面的時候,他們會被執行,也就是實際輸出到你螢幕或輸出音效的時候。每個 I/O action 也能包著一個從真實世界拿回來的值。
不要把像是 putStrLn
的函式想成接受字串並輸出到螢幕。要想成一個函式接受字串並回傳一個 I/O action。當 I/O action 被執行的時候,會漂亮地打印出你想要的東西。
getChar
是一個讀取單一字元的 I/O action。getLine
是一個讀取一行的 I/O action。這是兩個非常直覺的函式,多數程式語言也有類似這兩個函式的 statement 或 function。但現在我們來看看 getContents。getContents
是一個從標準輸入讀取直到 end-of-file 字元的 I/O action。他的型態是 getContents :: IO String
。最酷的是 getContents
是惰性 I/O (Lazy I/O)。當我們寫了 foo <- getContents
,他並不會馬上讀取所有輸入,將他們存在 memory 裡面。他只有當你真的需要輸入資料的時候才會讀取。
當我們需要重導一個程式的輸出到另一個程式的輸入時,getContents
非常有用。假設我們有下面一個文字檔:
還記得我們介紹 forever
時寫的小程式嗎?會把所有輸入的東西轉成大寫的那一個。為了防止你忘記了,這邊再重複一遍。
將我們的程式存成 capslocker.hs
然後編譯他。然後用 Unix 的 Pipe 將文字檔餵給我們的程式。我們使用的是 GNU 的 cat,會將指定的檔案輸出到螢幕。
就如你看到的,我們是用 |
這符號來將某個程式的輸出 piping 到另一個程式的輸入。我們做的事相當於 run 我們的 capslocker,然後將 haiku 的內容用鍵盤打到終端上,最後再按 Ctrl-D 來代表 end-of-file。這就像執行 cat haiku.txt 後大喊,嘿,不要把內容打印到終端上,把內容塞到 capslocker!
我們用 forever
在做的事基本上就是將輸入經過轉換後變成輸出。用 getContents
的話可以讓我們的程式更加精鍊。
我們將 getContents
取回的字串綁定到 contents
。然後用 toUpper
map 到整個字串後打印到終端上。記住字串基本上就是一串惰性的串列 (list),同時 getContents
也是惰性 I/O,他不會一口氣讀入內容然後將內容存在記憶體中。實際上,他會一行一行讀入並輸出大寫的版本,這是因為輸出才是真的需要輸入的資料的時候。
很好,程式運作正常。假如我們執行 capslocker 然後自己打幾行字呢?
按下 Ctrl-D 來離開環境。就像你看到的,程式是一行一行將我們的輸入打印出來。當 getContent
的結果被綁定到 contents
的時候,他不是被表示成在記憶體中的一個字串,反而比較像是他有一天會是字串的一個承諾。當我們將 toUpper
map 到 contents
的時候,便也是一個函數被承諾將會被 map 到內容上。最後 putStr
則要求先前的承諾說,給我一行大寫的字串吧。實際上還沒有任何一行被取出,所以便跟 contents
說,不如從終端那邊取出些字串吧。這才是 getContents
真正從終端讀入一行並把這一行交給程式的時候。程式便將這一行用 toUpper
處理並交給 putStr
,putStr
則打印出他。之後 putStr
再說:我需要下一行。整個步驟便再重複一次,直到讀到 end-of-file 為止。
接著我們來寫個程式,讀取輸入,並只打印出少於十個字元的行。
我們把 I/O 部份的程式碼弄得很短。由於程式的行為是接某些輸入,作些處理然後輸出。我們可以把他想成讀取輸入,呼叫一個函數,然後把函數的結果輸出。
shortLinesOnly
的行為是這樣:拿到一個字串,像是 "short\nlooooooooooooooong\nshort again"
。這字串有三行,前後兩行比較短,中間一行很常。他用 lines
把字串分成 ["short", "looooooooooooooong", "short again"]
,並把結果綁定成 allLines
。然後過濾這些字串,只有少於十個字元的留下,["short", "short again"]
,最後用 unlines
把這些字串用換行接起來,形成 "short\nshort again"
我們把 shortlines.txt 的內容經由 pipe 送給 shortlinesonly,結果就如你看到,我們只有得到比較短的行。
從輸入那一些字串,經由一些轉換然後輸出這樣的模式實在太常用了。常用到甚至建立了一個函數叫 interact。interact
接受一個 String -> String
的函數,並回傳一個 I/O action。那個 I/O action 會讀取一些輸入,呼叫提供的函數,然後把函數的結果打印出來。所以我們的程式可以改寫成這樣。
我們甚至可以再讓程式碼更短一些,像這樣
看吧,我們讓程式縮到只剩一行了,很酷吧!
能應用 interact
的情況有幾種,像是從輸入 pipe 讀進一些內容,然後丟出一些結果的程式;或是從使用者獲取一行一行的輸入,然後丟回根據那一行運算的結果,再拿取另一行。這兩者的差別主要是取決於使用者使用他們的方式。
我們再來寫另一個程式,它不斷地讀取一行行並告訴我們那一行字串是不是一個回文字串 (palindrome)。我們當然可以用 getLine
讀取一行然後再呼叫 main
作同樣的事。不過同樣的事情可以用 interact
更簡潔地達成。當使用 interact
的時候,想像你是將輸入經有某些轉換成輸出。在這個情況當中,我們要將每一行輸入轉換成 "palindrome"
或 "not a palindrome"
。所以我們必須寫一個函數將 "elephant\nABCBA\nwhatever"
轉換成 not a palindrome\npalindrome\nnot a palindrome"
。來動手吧!
再來將程式改寫成 point-free 的形式
很直覺吧!首先將 "elephant\nABCBA\nwhatever"
變成 ["elephant", "ABCBA", "whatever"]
然後將一個 lambda 函數 map 它,["not a palindrome", "palindrome", "not a palindrome"]
然後用 unlines
變成一行字串。接著
來測試一下吧。
即使我們的程式是把一大把字串轉換成另一個,其實他表現得好像我們是一行一行做的。這是因為 Haskell 是惰性的,程式想要打印出第一行結果時,他必須要先有第一行輸入。所以一旦我們給了第一行輸入,他便打印出第一行結果。我們用 end-of-line 字元來結束程式。
我們也可以用 pipe 的方式將輸入餵給程式。假設我們有這樣一個檔案。
將他存為 words.txt
,將他餵給程式後得到的結果
再一次地提醒,我們得到的結果跟我們自己一個一個字打進輸入的內容是一樣的。我們看不到 palindrome.hs
輸入的內容是因為內容來自於檔案。
你應該大致了解 Lazy I/O 是如何運作,並能善用他的優點。他可以從輸入轉換成輸出的角度方向思考。由於 Lazy I/O,沒有輸入在被用到之前是真的被讀入。
到目前為止,我們的示範都是從終端讀取某些東西或是打印出某些東西到終端。但如果我們想要讀寫檔案呢?其實從某個角度來說我們已經作過這件事了。我們可以把讀寫終端想成讀寫檔案。只是把檔案命名成 stdout
跟 stdin
而已。他們分別代表標準輸出跟標準輸入。我們即將看到的讀寫檔案跟讀寫終端並沒什麼不同。
首先來寫一個程式,他會開啟一個叫 girlfriend.txt 的檔案,檔案裡面有 Avril Lavigne 的暢銷名曲 Girlfriend,並將內容打印到終端上。接下來是 girlfriend.txt 的內容。
這則是我們的主程式。
執行他後得到的結果。
我們來一行行看一下程式。我們的程式用 do 把好幾個 I/O action 綁在一起。在 do block 的第一行,我們注意到有一個新的函數叫 openFile。他的 type signature 是 openFile :: FilePath -> IOMode -> IO Handle
。他說了 openFile
接受一個檔案路徑跟一個 IOMode
,並回傳一個 I/O action,他會打開一個檔案並把檔案關聯到一個 handle。
FilePath
不過是 String
的 type synonym。
IOMode
則是一個定義如下的型態
就像我們之前定義的型態,分別代表一個星期的七天。這個型態代表了我們想對打開的檔案做什麼。很簡單吧。留意到我們的型態是 IOMode
而不是 IO Mode
。IO Mode
代表的是一個 I/O action 包含了一個型態為 Mode
的值,但 IOMode
不過是一個陽春的 enumeration。
最後,他回傳一個 I/O action 會將指定的檔案用指定的模式打開。如果我們將 I/O action 綁定到某個東西,我們會得到一個 Handle
。型態為 Handle
的值代表我們的檔案在哪裡。有了 handle 我們才知道要從哪個檔案讀取內容。想讀取檔案但不將檔案綁定到 handle 上這樣做是很蠢的。所以,我們將一個 handle 綁定到 handle
。
接著一行,我們看到一個叫 hGetContents 的函數。他接了一個 Handle
,所以他知道要從哪個檔案讀取內容並回傳一個 IO String
。一個包含了檔案內容的 I/O action。這函數跟 getContents
差不多。唯一的差別是 getContents
會自動從標準輸入讀取內容(也就是終端),而 hGetContents
接了一個 file handle,這 file handle 告訴他讀取哪個檔案。除此之外,他們都是一樣的。就像 getContents
,hGetContents
不會把檔案一次都拉到記憶體中,而是有必要才會讀取。這非常酷,因為我們把 contents
當作是整個檔案般用,但他實際上不在記憶體中。就算這是個很大的檔案,hGetContents
也不會塞爆你的記憶體,而是只有必要的時候才會讀取。
要留意檔案的 handle 還有檔案的內容兩個概念的差異,在我們的程式中他們分別被綁定到 handle
跟 contents
兩個名字。handle 是我們拿來區分檔案的依據。如果你把整個檔案系統想成一本厚厚的書,每個檔案分別是其中的一個章節,handle 就像是書籤一般標記了你現在正在閱讀(或寫入)哪一個章節,而內容則是章節本身。
我們使用 putStr contents
打印出內容到標準輸出,然後我們用了 hClose。他接受一個 handle 然後回傳一個關掉檔案的 I/O action。在用了 openFile
之後,你必須自己把檔案關掉。
要達到我們目的的另一種方式是使用 withFile,他的 type signature 是 withFile :: FilePath -> IOMode -> (Handle -> IO a) -> IO a
。他接受一個檔案路徑,一個 IOMode
以及一個函數,這函數則接受一個 handle 跟一個 I/O action。withFile
最後回傳一個會打開檔案,對檔案作某件事然後關掉檔案的 I/O action。處理的結果是包在最後的 I/O action 中,這結果跟我們給的函數的回傳是相同的。這聽起來有些複雜,但其實很簡單,特別是我們有 lambda,來看看我們用 withFile
改寫前面程式的一個範例:
正如你看到的,程式跟之前的看起來很像。(\handle -> ... )
是一個接受 handle 並回傳 I/O action 的函數,他通常都是用 lambda 來表示。我們需要一個回傳 I/O action 的函數的理由而不是一個本身作處理並關掉檔案的 I/O action,是因為這樣一來那個 I/O action 不會知道他是對哪個檔案在做處理。用 withFile
的話,withFile
會打開檔案並把 handle 傳給我們給他的函數,之後他則拿到一個 I/O action,然後作成一個我們描述的 I/O action,最後關上檔案。例如我們可以這樣自己作一個 withFile
:
我們知道要回傳的是一個 I/O action,所以我們先放一個 do。首先我們打開檔案,得到一個 handle。然後我們 apply handle
到我們的函數,並得到一個做事的 I/O action。我們綁定那個 I/O action 到 result
這個名字,關上 handle 並 return result
。return
的作用把從 f
得到的結果包在 I/O action 中,這樣一來 I/O action 中就包含了 f handle
得到的結果。如果 f handle
回傳一個從標準輸入讀去數行並寫到檔案然後回傳讀入的行數的 I/O action,在 withFile'
的情形中,最後的 I/O action 就會包含讀入的行數。
就像 hGetContents
對應 getContents
一樣,只不過是針對某個檔案。我們也有 hGetLine、hPutStr、hPutStrLn、hGetChar 等等。他們分別是少了 h 的那些函數的對應。只不過他們要多拿一個 handle 當參數,並且是針對特定檔案而不是標準輸出或標準輸入。像是 putStrLn
是一個接受一個字串並回傳一個打印出加了換行字元的字串的 I/O action 的函數。hPutStrLn
接受一個 handle 跟一個字串,回傳一個打印出加了換行字元的字串到檔案的 I/O action。以此類推,hGetLine
接受一個 handle 然後回傳一個從檔案讀取一行的 I/O action。
讀取檔案並對他們的字串內容作些處理實在太常見了,常見到我們有三個函數來更進一步簡化我們的工作。
readFile 的 type signature 是 readFile :: FilePath -> IO String
。記住,FilePath
不過是 String
的一個別名。readFile
接受一個檔案路徑,回傳一個惰性讀取我們檔案的 I/O action。然後將檔案的內容綁定到某個字串。他比起先 openFile
,綁定 handle,然後 hGetContents
要好用多了。這邊是一個用 readFile
改寫之前例子的範例:
由於我們拿不到 handle,所以我們也無法關掉他。這件事 Haskell 的 readFile
在背後幫我們做了。
writeFile 的型態是 writefile :: FilePath -> String -> IO ()
。他接受一個檔案路徑,以及一個要寫到檔案中的字串,並回傳一個寫入動作的 I/O action。如果這個檔案已經存在了,他會先把檔案內容都砍了再寫入。下面示範了如何把 girlfriend.txt 的內容轉成大寫然後寫入到 girlfriendcaps.txt 中
appendFile 的型態很像 writeFile
,只是 appendFile
並不會在檔案存在時把檔案內容砍掉而是接在後面。
假設我們有一個檔案叫 todo.txt``,裡面每一行是一件要做的事情。現在我們寫一個程式,從標準輸入接受一行將他加到我們的 to-do list 中。
由於 getLine
回傳的值不會有換行字元,我們需要在每一行最後加上 "\n"
。
還有一件事,我們提到 contents <- hGetContents handle
是惰性 I/O,不會將檔案一次都讀到記憶體中。 所以像這樣寫的話:
實際上像是用一個 pipe 把檔案弄到標準輸出。正如你可以把 list 想成 stream 一樣,你也可以把檔案想成 stream。他會每次讀一行然後打印到終端上。你也許會問這個 pipe 究竟一次可以塞多少東西,讀去硬碟的頻率究竟是多少?對於文字檔而言,預設的 buffer 通常是 line-buffering。這代表一次被讀進來的大小是一行。這也是為什麼在這個 case 我們是一行一行處理。對於 binary file 而言,預設的 buffer 是 block-buffering。這代表我們是一個 chunk 一個 chunk 去讀得。而一個 chunk 的大小是根據作業系統不同而不同。
你能用 hSetBuffering
來控制 buffer 的行為。他接受一個 handle 跟一個 BufferMode
,回傳一個會設定 buffer 行為的 I/O action。BufferMode
是一個 enumeration 型態,他可能的值有:NoBuffering
, LineBuffering
或 BlockBuffering (Maybe Int)
。其中 Maybe Int
是表示一個 chunck 有幾個 byte。如果他的值是 Nothing
,則作業系統會幫你決定 chunk 的大小。NoBuffering
代表我們一次讀一個 character。一般來說 NoBuffering
的表現很差,因為他存取硬碟的頻率很高。
接下來是我們把之前的範例改寫成用 2048 bytes 的 chunk 讀取,而不是一行一行讀。
用更大的 chunk 來讀取對於減少存取硬碟的次數是有幫助的,特別是我們的檔案其實是透過網路來存取。
我們也可以使用 hFlush,他接受一個 handle 並回傳一個會 flush buffer 到檔案的 I/O action。當我們使用 line-buffering 的時候,buffer 在每一行都會被 flush 到檔案。當我們使用 block-buffering 的時候,是在我們讀每一個 chunk 作 flush 的動作。flush 也會發生在關閉 handle 的時候。這代表當我們碰到換行字元的時候,讀或寫的動作都會停止並回報手邊的資料。但我們能使用 hFlush
來強迫回報所有已經在 buffer 中的資料。經過 flushing 之後,資料也就能被其他程式看見。
把 block-buffering 的讀取想成這樣:你的馬桶會在水箱有一加侖的水的時候自動沖水。所以你不斷灌水進去直到一加侖,馬桶就會自動沖水,在水裡面的資料也就會被看到。但你也可以手動地按下沖水鈕來沖水。他會讓現有的水被沖走。沖水這個動作就是 hFlush
這個名字的含意。
我們已經寫了一個將 item 加進 to-do list 裡面的程式,現在我們想加進移除 item 的功能。我先把程式碼貼上然後講解他。我們會使用一些新面孔像是 System.Directory
以及 System.IO
裡面的函數。
來看一下我們包含移除功能的程式:
一開始,我們用 read mode 打開 todo.txt,並把他綁定到 handle
。
接著,我們使用了一個之前沒用過在 System.IO
中的函數 openTempFile。他的名字淺顯易懂。他接受一個暫存的資料夾跟一個樣板檔案名,然後打開一個暫存檔。我們使用 "."
當作我們的暫存資料夾,因為 .
在幾乎任何作業系統中都代表了現在所在的資料夾。我們使用 "temp"
當作我們暫存檔的樣板名,他代表暫存檔的名字會是 temp 接上某串隨機字串。他回傳一個創建暫存檔的 I/O action,然後那個 I/O action 的結果是一個 pair:暫存檔的名字跟一個 handle。我們當然可以隨便開啟一個 todo2.txt 這種名字的檔案。但使用 openTempFile
會是比較好的作法,這樣你不會不小心覆寫任何檔案。
我們不用 getCurrentDirectory
的來拿到現在所在資料夾而用 "."
的原因是 .
在 unix-like 系統跟 Windows 中都表示現在的資料夾。
然後,我們綁定 todo.txt 的內容成 contents
。把字串斷成一串字串,每個字串代表一行。todoTasks
就變成 ["Iron the dishes", "Dust the dog", "Take salad out of the oven"]
。我們用一個會把 3 跟 "hey"
變成 "3 - hey"
的函數,然後從 0 開始把這個串列 zip 起來。所以 numberedTasks
就是 ["0 - Iron the dishes", "1 - Dust the dog" ...
。我們用 unlines
把這個串列變成一行,然後打印到終端上。注意我們也有另一種作法,就是用 mapM putStrLn numberedTasks
。
我們問使用者他們想要刪除哪一個並且等著他們輸入一個數字。假設他們想要刪除 1 號,那代表 Dust the dog
,所以他們輸入 1
。於是 numberString
就代表 "1"
。由於我們想要一個數字,而不是一個字串,所以我們用對 1
使用 read
,並且綁定到 number
。
還記得在 Data.List
中的 delete
跟 !!
嗎?!!
回傳某個 index 的元素,而 delete
刪除在串列中第一個發現的元素,然後回傳一個新的沒有那個元素的串列。(todoTasks !! number)
(number 代表 1
) 回傳 "Dust the dog"
。我們把 todoTasks
去掉第一個 "Dust the dog"
後的串列綁定到 newTodoItems
,然後用 unlines
變成一行然後寫到我們所打開的暫存檔。舊有的檔案並沒有變動,而暫存檔包含砍掉那一行後的所有內容。
在我們關掉原始檔跟暫存檔之後我們用 removeFile 來移除原本的檔案。他接受一個檔案路徑並且刪除檔案。刪除舊得 todo.txt 之後,我們用 renameFile 來將暫存檔重新命名成 todo.txt。特別留意 removeFile
跟 renameFile
(兩個都在 System.Directory
中)接受的是檔案路徑,而不是 handle。
這就是我們要的,實際上我們可以用更少行寫出同樣的程式,但我們很小心地避免覆寫任何檔案,並詢問作業系統我們可以把暫存檔擺在哪?讓我們來執行看看。
如果你想要寫一個在終端裡運行的程式,處理命令列引數是不可或缺的。幸運的是,利用 Haskell 的 Standard Libary 能讓我們有效地處理命令列引數。
在之前的章節中,我們寫了一個能將 to-do item 加進或移除 to-do list 的一個程式。但我們的寫法有兩個問題。第一個是我們把放 to-do list 的檔案名稱給寫死了。我們擅自決定使用者不會有很多個 to-do lists,就把檔案命名為 todo.txt。
一種解決的方法是每次都詢問使用者他們想將他們的 to-do list 放進哪個檔案。我們在使用者要刪除的時候也採用這種方式。這是一種可以運作的方式,但不太能被接受,因為他需要使用者運行程式,等待程式詢問才能回答。這被稱為互動式的程式,但討厭的地方在當你想要自動化執行程式的時候,好比說寫成 script,這會讓你的 script 寫起來比較困難。
這也是為什麼有時候讓使用者在執行的時候就告訴程式他們要什麼會比較好,而不是讓程式去問使用者要什麼。比較好的方式是讓使用者透過命令列引數告訴程式他們想要什麼。
在 System.Environment
模組當中有兩個很酷的 I/O actions,一個是 getArgs,他的 type 是 getArgs :: IO [String]
,他是一個拿取命令列引數的 I/O action,並把結果放在包含的一個串列中。getProgName 的型態是 getProgName :: IO String
,他則是一個 I/O action 包含了程式的名稱。
我們來看一個展現他們功能的程式。
我們將 getArgs
跟 progName
分別綁定到 args
跟 progName
。我們打印出 The arguments are:
以及在 args
中的每個引數。最後,我們打印出程式的名字。我們把程式編譯成 arg-test
。
知道了這些函數現在你能寫幾個很酷的命令列程式。在之前的章節,我們寫了一個程式來加入待作事項,也寫了另一個程式刪除事項。現在我們要把兩個程式合起來,他會根據命令列引數來決定該做的事情。我們也會讓程式可以處理不同的檔案,而不是只有 todo.txt
我們叫這程式 todo,他會作三件事:
我們暫不考慮不合法的輸入這件事。
我們的程式要像這樣運作:假如我們要加入 Find the magic sword of power
,則我們會打 todo add todo.txt "Find the magic sword of power"
。要檢視事項我們則會打 todo view todo.txt
,如果要移除事項二則會打 todo remove todo.txt 2
我們先作一個分發的 association list。他會把命令列引數當作 key,而對應的處理函數當作 value。這些函數的型態都是 [String] -> IO ()
。他們會接受命令列引數的串列並回傳對應的檢視,加入以及刪除的 I/O action。