Haskell/簡單輸入輸出
除了內部計算值,我們希望我們的程式能夠與世界互動。任何語言中最常見的初學者程式只是在螢幕上顯示一個“hello world”問候語。以下是一個 Haskell 版本
Prelude> putStrLn "Hello, World!"
putStrLn 是標準 Prelude 工具之一。正如名稱中的“putStr”部分所暗示的,它接受一個 String 作為引數並將其列印到螢幕上。我們可以單獨使用 putStr,但我們通常會包含“Ln”部分,以便也列印換行符。因此,接下來列印的任何內容都將出現在新行上。
所以你現在應該在想,“putStrLn 函式的型別是什麼?”它接受一個 String 並返回……嗯……什麼?我們該怎麼稱呼它?程式沒有獲得可以用於另一個函式的結果。相反,結果涉及讓計算機更改螢幕。換句話說,它在程式外部世界中做了一些事情。什麼型別可以代表它?讓我們看看 GHCi 告訴我們什麼
Prelude> :t putStrLn
putStrLn :: String -> IO ()
"IO" 代表“輸入輸出”。無論何時在型別中出現 IO,都涉及與程式外部世界的互動。我們將這些 IO 值稱為 操作。IO 型別的另一部分,在本例中為 (),是操作返回值的型別;也就是說,它返回給程式的型別(而不是它在程式外部執行的操作)。()(發音為“單位”)是一種只包含一個也稱為 () 的值的型別(實際上是一個沒有元素的元組)。由於 putStrLn 將輸出傳送到世界但不會返回任何內容給程式,因此 () 用作佔位符。我們可以將 IO () 解釋為“返回 () 的操作”。
以下是一些使用 IO 的示例
- 將字串列印到螢幕
- 從鍵盤讀取字串
- 將資料寫入檔案
- 從檔案讀取資料
是什麼讓 IO 實際起作用?從 putStrLn 到螢幕上的畫素,幕後發生了很多事情,但我們不需要了解任何細節就能編寫程式。一個完整的 Haskell 程式實際上是一個大型 IO 操作。在編譯後的程式中,此操作稱為 main,其型別為 IO ()。從這個角度來看,編寫 Haskell 程式就是將操作和函式組合在一起,形成將在程式執行時執行的整體操作 main。編譯器負責指示計算機如何執行此操作。
| 練習 |
|---|
在“型別基礎”章節中,我們提到過 openWindow 函式 的型別已被簡化。你認為它的型別實際上應該是什麼? |
do 語法提供了一種方便的方法來將操作組合在一起(這對於使用 Haskell 做有用的事情至關重要)。考慮以下程式
示例:你的名字是什麼?
main = do
putStrLn "Please enter your name:"
name <- getLine
putStrLn ("Hello, " ++ name ++ ", how are you?")
注意
即使 do 語法看起來與我們迄今為止看到的 Haskell 程式碼非常不同,它也僅僅是少數函式的語法糖,其中最重要的函式是 (>>=) 運算子。我們可以解釋這些函式是如何工作的,然後介紹 do 語法。但是,在我們能夠給出令人信服的解釋之前,我們需要涵蓋幾個主題。現在就使用 do 是一個務實的捷徑,它將允許你立即開始使用 IO 編寫完整的程式。我們將在本書的後面看到 do 是如何工作的,從 理解單子 章節開始。
在我們深入研究 do 的工作原理之前,請看一下 getLine。它會進入外部世界(在本例中為終端)並返回一個 String。它的型別是什麼?
Prelude> :t getLine
getLine :: IO String
這意味著 getLine 是一個 IO 操作,執行時會返回一個 String。但是輸入呢?雖然函式具有類似於 a -> b 的型別,反映了它們接受引數並返回結果,但 getLine 實際上不接受引數。它以終端中的一行中的任何內容作為輸入。但是,外部世界中的那行直到我們將其引入 Haskell 程式中才成為具有型別的定義值。
程式在執行時才瞭解外部世界的狀態,因此它無法預測 IO 操作的確切結果。為了管理這些 IO 操作與程式其他方面的關係,必須以預先在程式碼中定義的、可預測的順序執行這些操作。對於不執行 IO 的常規函式來說,執行的確切順序問題不大——只要結果最終到達正確的位置即可。
在我們的姓名程式中,我們正在順序執行三個操作:帶有問候語的 putStrLn、一個 getLine 以及另一個 putStrLn。對於 getLine,我們使用 <- 符號,它分配一個變數名來代表返回的值。我們無法提前知道該值是什麼,但我們知道它將使用指定的變數名,因此我們可以隨後在其他地方使用該變數(在本例中,用於準備要列印的最終訊息)。最終操作定義了整個 do 塊的型別。這裡,最終操作是 putStrLn 的結果,因此我們的整個程式的型別為 IO ()。
| 練習 |
|---|
|
編寫一個程式,要求使用者輸入直角三角形的底和高,計算其面積,並將結果列印到螢幕上。互動應該類似於 The base? 3.3 The height? 5.4 The area of that triangle is 8.91你將需要使用 read 函式將類似於“3.3”的使用者字串轉換為類似於 3.3 的數字,並使用 show 函式將數字轉換為字串。 |
雖然類似於 getLine 的操作幾乎總是用於獲取值,但我們並不一定要獲取它們。例如,我們可以編寫類似於下面的內容
示例:直接執行 getLine
main = do
putStrLn "Please enter your name:"
getLine
putStrLn "Hello, how are you?"
在這種情況下,我們根本沒有使用輸入,但仍然讓使用者體驗輸入其姓名。透過省略 <-,操作將發生,但資料不會被儲存或供程式訪問。
對哪些操作可以獲取其值幾乎沒有限制。考慮以下示例,其中我們將每個操作的結果放入一個變數中(除了最後一個……稍後會解釋)
示例:將所有結果放入一個變數中
main = do
x <- putStrLn "Please enter your name:"
name <- getLine
putStrLn ("Hello, " ++ name ++ ", how are you?")
變數 x 從其操作中獲取值,但這在這種情況下沒有用,因為操作返回單位值 ()。所以,雖然我們可以從技術上獲取任何操作的值,但這並不總是值得的。
那麼,最後一個操作呢?為什麼我們不能從它那裡獲取值?讓我們看看嘗試這樣做會發生什麼
示例:從最後一個操作中獲取值
main = do
x <- putStrLn "Please enter your name:"
name <- getLine
y <- putStrLn ("Hello, " ++ name ++ ", how are you?")
糟糕!錯誤!
HaskellWikibook.hs:5:2:
The last statement in a 'do' construct must be an expression理解這一點需要對 Haskell 有比我們目前更深入的瞭解。簡而言之,在使用 <- 獲取操作值的任何行之後,Haskell 都期望另一個操作,因此最終的操作不能有任何 <-。
像if/then/else這樣的普通 Haskell 結構可以用於do符號,但是您需要注意一些事項。例如,在簡單的“猜數字”程式中,我們有
doGuessing num = do
putStrLn "Enter your guess:"
guess <- getLine
if (read guess) < num
then do putStrLn "Too low!"
doGuessing num
else if (read guess) > num
then do putStrLn "Too high!"
doGuessing num
else 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 (),這正是我們想要的。
注意:如果您發現自己這樣想,請小心,“好吧,我已經開始了一個do塊;我不需要另一個。”我們不能有這樣的程式碼
do if (read guess) < num
then putStrLn "Too low!"
doGuessing num
else ...
這裡,因為我們沒有重複do,編譯器不知道 putStrLn 和 doGuessing 呼叫應該被排序,編譯器會認為您正在嘗試使用三個引數呼叫 putStrLn:字串,函式 doGuessing 和整數 num,因此會拒絕程式。
| 練習 |
|---|
|
編寫一個程式,提示使用者輸入姓名。如果姓名是 Simon、John 或 Phil 之一,則告訴使用者您認為 Haskell 是一種很棒的程式語言。如果姓名是 Koen,則告訴他們您認為除錯 Haskell 很有趣(Koen Classen 是 Haskell 除錯工作者之一);否則,告訴使用者您不知道他是誰。 (就語法而言,有幾種不同的方法可以做到這一點;至少編寫一個使用if / then / else 的版本。) |
到目前為止,操作看起來可能很簡單,但它們是 Haskell 新手常見的絆腳石。如果您在使用操作時遇到問題,請檢視您的問題或疑問是否與以下任何情況匹配。我們建議您現在瀏覽一下本節,然後在遇到實際問題時再回來檢視。
一種誘惑可能是簡化我們獲取名稱並將其打印出來的程式。以下是一個不成功的嘗試
示例:為什麼這不起作用?
main =
do putStrLn "What is your name? "
putStrLn ("Hello " ++ getLine)
哎呀!錯誤!
HaskellWikiBook.hs:3:26:
Couldn't match expected type `[Char]'
against inferred type `IO String'
讓我們將上面的示例簡化為最簡單的形式。您期望這個程式編譯嗎?
示例:這仍然不起作用
main =
do putStrLn getLine
在大多數情況下,這是同一個(嘗試)程式,除了我們剝離了多餘的“您的姓名是什麼”提示以及禮貌的“您好”。理解這一點的一個技巧是根據型別來推斷它。讓我們比較一下
putStrLn :: String -> IO ()
getLine :: IO String
我們可以使用我們在 型別基礎 中學到的相同思維方式來弄清楚這是怎麼回事。putStrLn 期望一個 String 作為輸入。我們沒有 String;我們有一些非常接近的東西:IO String。這表示一個操作,當它執行時將提供一個 String。為了獲得 putStrLn 想要的 String,我們需要執行該操作,而我們使用方便的左箭頭 <- 來做到這一點。
示例:這次有效
main =
do name <- getLine
putStrLn name
逐步回到複雜的示例
main =
do putStrLn "What is your name? "
name <- getLine
putStrLn ("Hello " ++ name)
現在,名稱就是我們正在尋找的 String,一切又恢復正常了。
所以,我們已經大談特談了在不必要的情況下不能使用操作的想法。這方面的反面是,您不能在需要操作的情況下使用非操作。假設我們想問候使用者,但這次我們非常興奮地見到他們,我們必須大聲喊出他們的名字
示例:令人興奮但不正確。為什麼?
import Data.Char (toUpper)
main =
do name <- getLine
loudName <- makeLoud name
putStrLn ("Hello " ++ loudName ++ "!")
putStrLn ("Oh boy! Am I excited to meet you, " ++ loudName)
-- Don't worry too much about this function; it just converts a String to uppercase
makeLoud :: String -> String
makeLoud s = map toUpper s
這會出錯...
Couldn't match expected type `IO' against inferred type `[]'
Expected type: IO t
Inferred type: String
In a 'do' expression: loudName <- makeLoud name這類似於我們上面遇到的問題:我們遇到了一個期望 IO 型別的東西和一個不產生 IO 的東西之間的不匹配。這次,問題出在左箭頭 <- 上;我們試圖將 makeLoud name 的值左箭頭指向,這實際上不是左箭頭材料。它基本上與我們在上一節中看到的不匹配相同,只是現在我們試圖將普通 String(大聲的名稱)用作 IO String。後者是一個操作,需要執行,而前者只是一個按部就班的表示式。我們不能簡單地使用 loudName = makeLoud name,因為 do 對操作進行排序,而 loudName = makeLoud name 不是一個操作。
那麼我們如何從這個困境中解脫出來呢?我們有很多選擇
- 我們可以找到一種方法將
makeLoud變成一個操作,使其返回IO String。但是,我們不想無緣無故地讓操作進入世界。在我們的程式中,我們可以可靠地驗證一切是如何工作的。當操作與外部世界互動時,我們的結果就不可預測得多。IOmakeLoud會誤入歧途。還要考慮另一個問題:如果我們想從其他非 IO 函式中使用 makeLoud 呢?我們真的不想在絕對必要的情況下以外執行 IO 操作。 - 我們可以使用名為
return的特殊程式碼將大聲的名稱提升為操作,編寫類似loudName <- return (makeLoud name)的內容。這稍微好一點。我們至少使makeLoud函式本身保持整潔,沒有 IO,同時以與 IO 相容的方式使用它。這仍然相當笨拙,因為根據左箭頭的規定,我們暗示有操作可行——多麼令人興奮!——只是讓我們讀者失望於有點反高潮的return(注意:我們將在後面的章節中學習更多關於return的適當用法的知識)。 - 或者我們可以使用 let 繫結...
事實證明,Haskell 在操作中的 let 繫結有一個特殊的額外方便的語法。它看起來有點像這樣
示例:do 塊中的 let 繫結。
main =
do name <- getLine
let loudName = makeLoud name
putStrLn ("Hello " ++ loudName ++ "!")
putStrLn ("Oh boy! Am I excited to meet you, " ++ loudName)
如果您留心觀察,您可能會注意到上面的 let 繫結缺少 in。這是因為 do 塊內部的 let 繫結不需要 in 關鍵字。您可以隨意使用它,但這樣會產生雜亂無章的額外 do 塊。就其本身而言,以下兩個程式碼塊是等效的。
| 甜美 | 不甜 |
|---|---|
do name <- getLine
let loudName = makeLoud name
putStrLn ("Hello " ++ loudName ++ "!")
putStrLn (
"Oh boy! Am I excited to meet you, "
++ loudName)
|
do name <- getLine
let loudName = makeLoud name
in do putStrLn ("Hello " ++ loudName ++ "!")
putStrLn (
"Oh boy! Am I excited to meet you, "
++ loudName)
|
| 練習 |
|---|
|
此時,您擁有進行更高階的輸入/輸出所需的基本知識。以下是一些您可能想在與本課程主線平行的過程中檢視的 IO 相關主題。
- 您可以繼續按順序學習,瞭解更多關於 型別 的知識,並最終學習 單子。
- 或者,您也可以開始學習在 GUI 章節中構建圖形使用者介面。
- 有關更多與 IO 相關的功能,您還可以考慮學習更多關於 System.IO 庫 的知識。