4 min to read
CIS 194 08 IO 輸入輸出
Source: 08-IO
▌IO 輸入輸出
CIS 194 第 8 週 2013 年 3 月 11 日
建議閱讀:
▌The problem with purity 純度問題
請記住,Haskell 是惰性 (lazy),因此 純粹 (pure)。這意味著兩件事:
- 函數可能沒有任何外部影響。例如,某個函數可能無法在屏幕上打印任何內容。函數只能計算其輸出
- 函數可能不取決於外部因素。例如,它們可能無法從鍵盤,文件系統或網絡讀取。函數可能僅取決於其輸入,換句話說,函數每次都應為相同的輸入提供相同的輸出
但是 — 有時候我們 確實 希望能夠做這樣的事情!如果使用 Haskell 唯一可以做的事情是編寫函數,然後我們可以在 ghci 提示符下對其進行評估,那麼從理論上講這很有趣,但實際上沒有用
實際上,使用 Haskell 可以做這些事情,但是看起來與大多數其他語言完全不同
▌The IO
type / IO
類型
解決難題的方法是一種稱為 IO
的特殊類型。 IO a
類型的值是對有效計算 (effectful computations) 的描述,如果執行這些計算,(可能)將執行一些有效的 I/O
操作,並最終產生 a
類型的值。 這裡有一個間接的水平,對於理解這一點至關重要。 IO a
類型的值本身本身就是一種惰性的,完全安全的東西,沒有任何影響。 這只是對有效計算的描述。 認為它是一種一流的命令式程序
舉例來說,假設您有
c :: Cake
你有什麼?為什麼,當然是美味的蛋糕。乾淨利落
相比之下,假設您有
r :: Recipe Cake
你有什麼?一塊蛋糕?不,你有一些 如何做蛋糕的 說明,只是一些書面的紙張
不僅您實際上沒有蛋糕,僅擁有食譜對其他任何事物都沒有影響。只需將食譜握在手中,就不會導致烤箱變熱或麵粉灑在地板上或任何類似的東西上。要真正製作蛋糕,必須 遵循 食譜(導致麵粉撒出,配料混合,烤箱變熱等)
同樣,IO a
類型的值只是用於產生 a
類型值的 “配方”(並且可能在此過程中會產生一些影響)。 像任何其他值一樣,它可以作為參數傳遞,作為函數的輸出返回,存儲在數據結構中,或者(如我們將很快看到的)與其他 IO
值組合成更複雜的配方
那麼,實際上如何執行 IO
類型的值? 只有一種方法:Haskell 編譯器尋找特殊值
main :: IO ()
實際上將被交給運行時系統並執行。而已!將 Haskell 運行時系統想像成是一位主廚,他是唯一可以做飯的廚師
如果您希望遵循自己的食譜,那麼最好將其作為大食譜(main
)的一部分,交給大廚。當然,main
可以任意複雜,並且通常由許多較小的 IO
計算組成
因此,讓我們編寫第一個實際的可執行的 Haskell 程序!我們可以使用函數
putStrLn :: String -> IO ()
給定一個 String
,它返回一個 IO
計算,該計算將(在執行時)在屏幕上打印出該 String
。 因此,我們將其簡單地放在一個名為 Hello.hs
的文件中:
main = putStrLn "Hello, Haskell!"
然後在命令行提示符下鍵入 runhaskell Hello.hs
,導致我們的消息被打印到屏幕上!我們還可以使用 ghc --make Hello.hs
生成一個稱為 Hello 的可執行版本(Windows 上的 Hello.exe )
▌There is no String
“inside” an IO String
IO String
“內部” 沒有 String
許多新的 Haskell 用戶最終都在問一個問題,例如 “我有 IO String
,如何將其轉換為 String
?”,或 “如何從IO String
中獲取 String
”? 根據上述直覺,應該清楚這些是無意義的問題:IO String
類型的值是對某種計算(一種用於生成 String
的配方)的描述。 在IO字符串“內部”沒有 String
,在蛋糕配方“內部”沒有蛋糕。 要生成字符串(或美味的蛋糕),需要實際執行計算(或配方)。 唯一的方法是通過 main 將其(可能是更大的 IO
值的一部分)提供給 Haskell 運行時系統
▌Combining IO
組合 IO
現在應該很清楚了,我們需要一種將 IO
計算組合成更大的方法。
合併兩個 IO
計算的最簡單方法是使用 (>>)
運算符(發音為“ and then”),其類型為
(>>) :: IO a -> IO b -> IO b
這只是創建了一個 IO
計算,其中包括依次運行兩個輸入計算。 注意,第一次計算的結果被丟棄,我們只關心它的作用。 例如:
main = putStrLn "Hello" >> putStrLn "world!"
這對於“執行此操作; 做這個; 這樣做”,結果並不重要。 但是,通常這是不夠的。 如果我們不想放棄第一次計算的結果怎麼辦?
解決這種情況的第一個嘗試可能是使類型為 IO a -> IO b -> IO (a, b)
。 但是,這也不足夠。 原因是我們希望第二個計算能夠依賴於第一個計算的結果。 例如,假設我們要從用戶那裡讀取一個整數,然後打印出比他們輸入的整數多一個的整數。 在這種情況下,第二次計算(在屏幕上打印一些數字)將根據第一次的結果而有所不同
相反,有一個運算符 (>>=)
(發音為 “bind” ),其類型為
(>>=) :: IO a -> (a -> IO b) -> IO b
首先這可能很難,會繞到你的頭! (>>=)
需進行計算,該計算將產生類型 a
的值,而一個函數根據該類型 a
的中間值進行第二次計算。 (>>=)
的結果是(a
的描述)計算,該計算將執行第一個計算,並使用其結果來確定下一步要執行的操作,然後執行該操作
例如,我們可以編寫一個程序來讀取用戶的號碼並打印出其後繼者。請注意,我們使用的 readLn :: Read a => IO a
,這是一種從用戶讀取輸入並將其轉換為 Read
實例的任何類型的計算
main :: IO ()
main = putStrLn "Please enter a number: " >> (readLn >>= (\n -> putStrLn (show (n+1))))
當然,這看起來很醜陋,但是有更好的編寫方法,我們將在以後討論
▌Record syntax 記錄語法
該材料未在講課中介紹,但作為完成作業 8 的額外資源而提供
假設我們有一個數據類型,例如
data D = C T1 T2 T3
我們還可以使用 記錄語法 (Record syntax) 聲明此數據類型,如下所示:
data D = C { field1 :: T1, field2 :: T2, field3 :: T3 }
我們不僅為存儲在構造函數中的每個字段指定類型,而且為其指定 名稱 C
。這個新版本的 D
可以與舊版本完全相同的方式使用(特別是我們仍然可以對 D
類型的值 C v1 v2 v3
進行構造和模式匹配)。 但是,我們還有其他好處
-
每個字段名稱自動是一個 投影函數 (projection function),該函數從
D
類型的值中獲取該字段的值。例如,field2 是類型的函數field2 :: D -> T2
以前,我們將不得不通過編寫代碼來自己實現 field2
field2 (C _ f _) = f
如果我們具有許多字段的數據類型,那麼這將消除很多樣板!
-
除了用於
D
類型值的構造、修改和模式匹配之外,還有特殊的語法(除了用於此類事物的常規語法外)我們可以使用如下語法構造
D
類型的值C { field3 = ..., field1 = ..., field2 = ... }
用正確類型的表達式填充
...
。 請注意,我們可以按任何順序指定字段假設我們有一個值
d :: D
。我們可以使用如下語法進行 修改d
d { field3 = ... }
當然,“修改”並不是指實際上對
d
進行突變(mutating),而是構造一個新的D
類型值,該值與d
相同,只是用給定值替換了field3
字段最後,我們可以像這樣對
D
類型的值進行模式匹配:foo (C { field1 = x }) = ... x ...
這僅與
D
值中的field1
字段匹配,將其稱為x
(當然,可以代替x
我們也可以放置任意模式),而忽略其他字段