跳轉到內容

另一個 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的變數中”。

正常的 Haskell 結構,比如if/then/elsecase/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

這裡,我們正在對兩個動作進行排序: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,編譯器不知道putStrLndoGuessing呼叫應該被排序,編譯器會認為你試圖用三個引數呼叫putStrLn:字串、函式doGuessing和整數num。它肯定會抱怨(儘管錯誤可能在此時有點難以理解)。

我們可以使用case語句來編寫相同的doGuessing函式。為此,我們首先引入 Prelude 函式compare,它接受相同型別的兩個值(在Ord類中),並根據第一個值是否大於、小於或等於第二個值返回GTLTEQ之一。

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結果的LTGT。在這兩種情況下,它都沒有匹配的模式,程式將立即因異常而失敗。

練習

編寫一個程式,要求使用者輸入其姓名。如果姓名是 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

注意

型別FilePathString類型別名。也就是說,FilePathString之間沒有區別。因此,例如,readFile函式接收一個String(要讀取的檔案)並返回一個動作,該動作在執行時生成該檔案的內容。有關類型別名的更多資訊,請參見關於別名的部分。

這些函式中的大多數不言自明。openFilehClose函式分別使用IOMode引數作為開啟檔案的模式,開啟和關閉檔案。hIsEOF測試檔案結束符。hGetCharhGetLine分別從檔案中讀取一個字元或一行。hGetContents讀取整個檔案。getChargetLinegetContents變體從標準輸入讀取。hPutChar將一個字元列印到檔案;hPutStr列印一個字串;hPutStrLn列印一個字串,並在末尾新增一個換行符。沒有h字首的變體作用於標準輸出。readFilewriteFile函式讀取整個檔案,無需先開啟它。

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函式請求一些文字,從鍵盤讀取文字,然後將其寫入指定的檔案。

注意

doReaddoWrite本可以更簡單,使用readFilewriteFile,但它們是以擴充套件方式編寫的,以顯示如何使用更復雜的函式。

此程式的唯一主要問題是,如果你嘗試讀取一個不存在的檔案,或者指定了一些錯誤的檔名,例如*\^\#_@,它將崩潰。你可能會認為doReaddoWrite中的bracket呼叫應該解決這個問題,但事實並非如此。它們只捕獲主體內出現的異常,而不是啟動或關閉函式(在本例中為openFilehClose)內出現的異常。我們需要捕獲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!
華夏公益教科書