另一個 Haskell 教程/Io
| Haskell | |
|---|---|
| |
| 另一個 Haskell 教程 | |
| 前言 | |
| 介紹 | |
| 入門 | |
| 語言基礎 (解決方案) | |
| 型別基礎 (解決方案) | |
| IO (解決方案) | |
| 模組 (解決方案) | |
| 高階語言 (解決方案) | |
| 高階型別 (解決方案) | |
| 單子 (解決方案) | |
| 高階 IO | |
| 遞迴 | |
| 複雜度 | |
正如我們之前提到的,很難找到一種好的、乾淨的方式將輸入/輸出操作整合到純函式式語言中。在我們給出解決方案之前,讓我們退一步,思考一下這種任務固有的困難。
任何 IO 庫都應該提供許多函式,其中包含(至少)以下操作
- 將字串列印到螢幕上
- 從鍵盤讀取字串
- 將資料寫入檔案
- 從檔案讀取資料
這裡有兩個問題。讓我們先考慮前兩個示例,並思考一下它們的型別應該是什麼。當然,第一個操作(我猶豫是否將其稱為“函式”)應該接受一個String引數併產生一些東西,但它應該產生什麼?它可以產生一個單位(),因為從列印字串中實際上沒有返回值。第二個操作類似地應該返回一個String,但它似乎不需要引數。
我們希望這兩個操作都是函式,但它們本質上不是函式。從鍵盤讀取字串的專案不能是函式,因為它不會每次都返回相同的String。如果第一個函式每次都簡單地返回(),那麼用函式f _ = ()替換它應該沒有問題,因為引用透明性。但很明顯,這沒有達到預期的效果。
從某種意義上說,這些專案不是函式的原因是它們與“現實世界”互動。它們的值直接依賴於現實世界。假設我們有一個型別RealWorld,我們可以將這些函式寫成具有以下型別
printAString :: RealWorld -> String -> RealWorld readAString :: RealWorld -> (RealWorld, String)
也就是說,printAString接受當前的世界狀態和要列印的字串;然後它修改世界狀態,使得字串現在被列印並返回這個新值。類似地,readAString接受當前的世界狀態並返回一個新的世界狀態,與輸入的String配對。
這將是進行 IO 的一種可能方式,儘管它有點笨拙。在這種風格中(假設初始RealWorld狀態是main的引數),我們“Name.hs”程式中的互動性部分將如下所示
main rW =
let rW' = printAString rW "Please enter your name: "
(rW'',name) = readAString rW'
in printAString rW''
("Hello, " ++ name ++ ", how are you?")
這不僅難以閱讀,而且容易出錯,如果你不小心使用了錯誤版本的RealWorld。它也不模擬以下程式毫無意義的事實
main rW =
let rW' = printAString rW "Please enter your name: "
(rW'',name) = readAString rW'
in printAString rW' -- OOPS!
("Hello, " ++ name ++ ", how are you?")
在這個程式中,最後一行對rW''的引用已更改為對rW'的引用。這個程式應該做什麼完全不清楚。顯然,它必須讀取一個字串才能有一個name的值來列印。但這意味著RealWorld已經更新。然而,我們試圖透過使用RealWorld的“舊版本”來忽略這個更新。這裡顯然有什麼問題。
總之,在純惰性函式式語言中進行 IO 操作並非易事。
解決這個問題的突破是,Phil Wadler 意識到單子將是思考 IO 計算的一種好方法。實際上,單子能夠表達的遠不止上面描述的簡單操作;我們可以用它們來表達各種結構,例如併發、異常、IO、非確定性等等。此外,它們沒有什麼特別之處;它們可以在 Haskell 中定義內部,而不需要編譯器的特殊處理(儘管編譯器通常選擇最佳化單子操作)。
如前所述,我們不能將諸如“將字串列印到螢幕”或“從檔案讀取資料”之類的操作視為函式,因為它們不是(在純粹的數學意義上)。因此,我們給它們另一個名字:動作。我們不僅給它們一個特殊的名稱,還給它們一個特殊的型別。一個特別有用的動作是putStrLn,它將字串列印到螢幕上。這個動作的型別是
putStrLn :: String -> IO ()
如預期的那樣,putStrLn接受一個字串引數。它返回的是IO ()型別。這意味著這個函式實際上是一個動作(這就是IO的意思)。此外,當這個動作被評估(或“執行”)時,結果將具有()型別。
注意
實際上,這個型別意味著putStrLn是在IO 單子內的動作,但我們現在先忽略這一點。
你可能已經猜到getLine的型別了
getLine :: IO String
這意味著getLine是一個 IO 動作,當執行時,將具有String型別。
問題立即出現:“如何'執行'一個動作?”。這由編譯器決定。你實際上不能自己執行一個動作;相反,一個程式本身是一個單一動作,在編譯後的程式執行時執行。因此,編譯器要求main函式具有IO ()型別,這意味著它是一個不返回任何東西的 IO 動作。編譯後的程式碼然後執行這個動作。
但是,雖然你不能自己執行動作,但你可以組合動作。實際上,我們已經看到了一種使用以下方法來實現此目的的方法do符號(如何真正做到這一點將在單子章節中揭示)。讓我們考慮最初的姓名程式
main = do
hSetBuffering stdin LineBuffering
putStrLn "Please enter your name: "
name <- getLine
putStrLn ("Hello, " ++ name ++ ", how are you?")
我們可以將do符號視為組合動作序列的一種方式。此外,<-符號是一種從動作中獲取值的方式。因此,在這個程式中,我們正在對四個動作進行排序:設定緩衝區、一個putStrLn、一個getLine和另一個putStrLn。putStrLn動作的型別是String -> IO (),所以我們提供給它一個String,因此完全應用的動作的型別是IO ()。這是我們允許執行的。
getLine動作的型別是IO String,因此可以執行它。但是,為了從動作中獲取值,我們寫name <- getLine,這基本上意味著“執行getLine,並將結果放入名為name的變數中”。
正常的 Haskell 結構,比如if/then/else和case/of可以在do符號內使用,但你需要稍微小心一點。例如,在我們的“猜數字”程式中,我們有
do ...
if (read guess) < num
then do putStrLn "Too low!"
doGuessing num
else if read guess > num
then do putStrLn "Too high!"
doGuessing num
else do putStrLn "You Win!"
如果我們考慮if/then/else結構的工作方式,它本質上接受三個引數:條件、“then”分支和“else”分支。條件需要具有Bool型別,並且兩個分支可以具有任何型別,只要它們具有相同型別。整個if/then/else結構的型別然後是兩個分支的型別。
在最外層的比較中,我們有(read guess) < num作為條件。這顯然具有正確的型別。讓我們只考慮“then”分支。這裡的程式碼是
do putStrLn "Too low!"
doGuessing num
這裡,我們正在對兩個動作進行排序:putStrLn和doGuessing。第一個的型別是IO (),這很好。第二個的型別也是IO (),這很好。整個計算的型別結果恰好是最終計算的型別。因此,“then”分支的型別也是IO ()。類似的論證表明,“else”分支的型別也是IO ()。這意味著整個if/then/else結構的型別是IO (),這正是我們想要的。
注意
在這段程式碼中,最後一行是else do putStrLn "You Win!"。這有點過於冗長。實際上,else putStrLn "You Win!"就足夠了,因為do只需要對動作進行排序。由於我們這裡只有一個動作,所以它是多餘的。
認為“好吧,我已經開始了一個do塊;我不需要另一個,”並因此寫下類似以下內容是錯誤的
do if (read guess) < num
then putStrLn "Too low!"
doGuessing num
else ...
這裡,因為我們沒有重複do,編譯器不知道putStrLn和doGuessing呼叫應該被排序,編譯器會認為你試圖用三個引數呼叫putStrLn:字串、函式doGuessing和整數num。它肯定會抱怨(儘管錯誤可能在此時有點難以理解)。
我們可以使用case語句來編寫相同的doGuessing函式。為此,我們首先引入 Prelude 函式compare,它接受相同型別的兩個值(在Ord類中),並根據第一個值是否大於、小於或等於第二個值返回GT、LT、EQ之一。
doGuessing num = do
putStrLn "Enter your guess:"
guess <- getLine
case compare (read guess) num of
LT -> do putStrLn "Too low!"
doGuessing num
GT -> do putStrLn "Too high!"
doGuessing num
EQ -> putStrLn "You Win!"
這裡,->後的dos在第一個和第二個選項中是必需的,因為我們正在對動作進行排序。
如果你習慣於使用像 C 或 Java 這樣的命令式語言進行程式設計,你可能會認為return將退出你當前的函式。在 Haskell 中並非如此。在 Haskell 中,return它只是將一個普通的值(例如,型別為Int的值)轉換為一個返回該值的動作(例如,型別為IO Int的值)。具體來說,在命令式語言中,你可能會這樣編寫此函式:
void doGuessing(int num) {
print "Enter your guess:";
int guess = atoi(readLine());
if (guess == num) {
print "You win!";
return ();
}
// we won't get here if guess == num
if (guess < num) {
print "Too low!";
doGuessing(num);
} else {
print "Too high!";
doGuessing(num);
}
}
這裡,因為我們在第一個if匹配中使用了return (),所以我們期望程式碼在那裡退出(在大多數命令式語言中,它確實如此)。然而,在 Haskell 中等效的程式碼,看起來可能像這樣
doGuessing num = do
putStrLn "Enter your guess:"
guess <- getLine
case compare (read guess) num of
EQ -> do putStrLn "You win!"
return ()
-- we don't expect to get here unless guess == num
if (read guess < num)
then do putStrLn "Too low!";
doGuessing num
else do putStrLn "Too high!";
doGuessing num
不會按你預期的方式執行。首先,如果你猜對了,它會先列印“你贏了!”,但它不會退出,並且會檢查guess是否小於num。當然它不是,所以執行else分支,它會列印“太高了!”,然後要求你再次猜測。
另一方面,如果你猜錯了,它會嘗試計算case語句並獲得compare結果的LT或GT。在這兩種情況下,它都沒有匹配的模式,程式將立即因異常而失敗。
| 練習 |
|---|
|
編寫一個程式,要求使用者輸入其姓名。如果姓名是 Simon、John 或 Phil 之一,告訴使用者你認為 Haskell 是一種很棒的程式語言。如果姓名是 Koen,告訴他們你認為除錯 Haskell 很有趣(Koen Classen 是 Haskell 除錯工作者之一);否則,告訴使用者你不認識他或她。 編寫此程式的兩個不同版本,一個使用if 語句,另一個使用case語句。 |
IO 庫(透過import匯入System.IO模組來使用)包含許多定義,其中最常見的一些列在下面
data IOMode = ReadMode | WriteMode
| AppendMode | ReadWriteMode
openFile :: FilePath -> IOMode -> IO Handle
hClose :: Handle -> IO ()
hIsEOF :: Handle -> IO Bool
hGetChar :: Handle -> IO Char
hGetLine :: Handle -> IO String
hGetContents :: Handle -> IO String
getChar :: IO Char
getLine :: IO String
getContents :: IO String
hPutChar :: Handle -> Char -> IO ()
hPutStr :: Handle -> String -> IO ()
hPutStrLn :: Handle -> String -> IO ()
putChar :: Char -> IO ()
putStr :: String -> IO ()
putStrLn :: String -> IO ()
readFile :: FilePath -> IO String
writeFile :: FilePath -> String -> IO ()
bracket ::
IO a -> (a -> IO b) -> (a -> IO c) -> IO c
注意
型別FilePath是String的類型別名。也就是說,FilePath和String之間沒有區別。因此,例如,readFile函式接收一個String(要讀取的檔案)並返回一個動作,該動作在執行時生成該檔案的內容。有關類型別名的更多資訊,請參見關於別名的部分。
這些函式中的大多數不言自明。openFile和hClose函式分別使用IOMode引數作為開啟檔案的模式,開啟和關閉檔案。hIsEOF測試檔案結束符。hGetChar和hGetLine分別從檔案中讀取一個字元或一行。hGetContents讀取整個檔案。getChar、getLine和getContents變體從標準輸入讀取。hPutChar將一個字元列印到檔案;hPutStr列印一個字串;hPutStrLn列印一個字串,並在末尾新增一個換行符。沒有h字首的變體作用於標準輸出。readFile和writeFile函式讀取整個檔案,無需先開啟它。
bracket函式用於安全地執行操作。考慮一個開啟檔案、向其中寫入一個字元,然後關閉檔案的函式。在編寫此類函式時,需要小心確保如果在某個點出現錯誤,檔案仍能成功關閉。bracket函式簡化了這一過程。它接收三個引數:第一個是要在開始時執行的操作。第二個是在結束時執行的操作,無論是否出現錯誤。第三個是在中間執行的操作,該操作可能會導致錯誤。例如,我們的字元寫入函式可能如下所示:
writeChar :: FilePath -> Char -> IO ()
writeChar fp c =
bracket
(openFile fp ReadMode)
hClose
(\h -> hPutChar h c)
這將開啟檔案、寫入字元,然後關閉檔案。但是,如果寫入字元失敗,hClose仍然會執行,並且異常將在之後重新丟擲。這樣,你就不必過於擔心捕獲異常和關閉所有控制代碼。
我們可以編寫一個簡單的程式,允許使用者讀取和寫入檔案。該介面承認很差,並且沒有捕獲所有錯誤(嘗試讀取一個不存在的檔案)。然而,它應該提供一個關於如何使用 IO 的相當完整的示例。將以下程式碼輸入“FileRead.hs”,並編譯/執行
module Main
where
import System.IO
import Control.Exception
main = do
hSetBuffering stdin LineBuffering
doLoop
doLoop = do
putStrLn "Enter a command rFN wFN or q to quit:"
command <- getLine
case command of
'q':_ -> return ()
'r':filename -> do putStrLn ("Reading " ++ filename)
doRead filename
doLoop
'w':filename -> do putStrLn ("Writing " ++ filename)
doWrite filename
doLoop
_ -> doLoop
doRead filename =
bracket (openFile filename ReadMode) hClose
(\h -> do contents <- hGetContents h
putStrLn "The first 100 chars:"
putStrLn (take 100 contents))
doWrite filename = do
putStrLn "Enter text to go into the file:"
contents <- getLine
bracket (openFile filename WriteMode) hClose
(\h -> hPutStrLn h contents)
此程式的功能是什麼?首先,它發出一個簡短的指令字串並讀取一個命令。然後它執行case對命令進行switch操作,並首先檢查第一個字元是否為`q'。如果是,它將返回一個單位型別的值。
注意
return函式是一個接收型別為a的值並返回型別為IO a的動作的函式。因此,return ()的型別為IO ()。
如果命令的第一個字元不是`q',程式會檢查它是否為`r'後跟一些繫結到變數filename的字串。然後它會告訴你它正在讀取檔案,執行讀取操作並再次執行doLoop。對`w'的檢查幾乎相同。否則,它匹配_(萬用字元),並迴圈到doLoop。
doRead函式使用bracket函式來確保在讀取檔案時沒有問題。它以ReadMode開啟一個檔案,讀取其內容並列印前 100 個字元(take函式接收一個整數和一個列表,並返回列表的前個元素)。
doWrite函式請求一些文字,從鍵盤讀取文字,然後將其寫入指定的檔案。
注意
doRead和doWrite本可以更簡單,使用readFile和writeFile,但它們是以擴充套件方式編寫的,以顯示如何使用更復雜的函式。
此程式的唯一主要問題是,如果你嘗試讀取一個不存在的檔案,或者指定了一些錯誤的檔名,例如*\^\#_@,它將崩潰。你可能會認為doRead和doWrite中的bracket呼叫應該解決這個問題,但事實並非如此。它們只捕獲主體內出現的異常,而不是啟動或關閉函式(在本例中為openFile和hClose)內出現的異常。我們需要捕獲openFile引發的異常,以使其完整。當我們在關於異常的部分中詳細討論異常時,我們將做到這一點。
| 練習 |
|---|
|
編寫一個程式,首先詢問使用者是否要從檔案讀取、寫入檔案還是退出。如果使用者響應退出,程式應該退出。如果他響應讀取,程式應該詢問他一個檔名並將其列印到螢幕上(如果檔案不存在,程式可能會崩潰)。如果他響應寫入,它應該詢問他一個檔名,然後詢問他要寫入檔案的文字,以"."表示完成。除"."以外的所有內容都應寫入檔案。 例如,執行此程式可能會產生以下結果: 示例 Do you want to [read] a file, [write] a file or [quit]? read Enter a file name to read: foo ...contents of foo... Do you want to [read] a file, [write] a file or [quit]? write Enter a file name to write: foo Enter text (dot on a line by itself to end): this is some text for foo . Do you want to [read] a file, [write] a file or [quit]? read Enter a file name to read: foo this is some text for foo Do you want to [read] a file, [write] a file or [quit]? read Enter a file name to read: foof Sorry, that file does not exist. Do you want to [read] a file, [write] a file or [quit]? blech I don't understand the command blech. Do you want to [read] a file, [write] a file or [quit]? quit Goodbye! |
