另一個 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 的變數中”。
像if/then/else和case/of這樣的普通 Haskell 結構可以在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 庫
[edit | edit source]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` 仍然會被執行,並且異常將在之後重新丟擲。這樣,您就不必太擔心捕獲異常和關閉所有控制代碼。
檔案讀取程式
[edit | edit source]我們可以編寫一個簡單的程式,允許使用者讀取和寫入檔案。該介面的承認很差,它並沒有捕獲所有錯誤(嘗試讀取一個不存在的檔案)。然而,它應該提供一個相當完整的關於如何使用 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)
這個程式做了什麼?首先,它發出簡短的指令字串並讀取命令。然後它對命令執行caseswitch,並首先檢查第一個字元是否為 `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! |
