跳轉到內容

另一個 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 和另一個 putStrLnputStrLn 操作的型別為 String -> IO (),所以我們提供給它一個 String,因此完全應用的操作的型別為 IO ()。這是我們可以執行的。

getLine 操作的型別為 IO String,因此可以直接執行它。但是,為了從操作中獲取值,我們編寫 name <- getLine,這基本上意味著“執行 getLine,並將結果放在名為 name 的變數中”。

if/then/elsecase/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

在這裡,我們正在按順序執行兩個操作:putStrLndoGuessing。第一個的型別為 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!
華夏公益教科書