Haskell Tutorial(20)初探 IO 型態


純函數式的世界泡久了,現在換換口味,暫時探出頭來看看不純綷的世界好了,實際上,你還是得與真實世界溝通,你得接受輸入,在結果運算出來之後,再輸出到真實世界之中,這表示,你總得有個地方,某些函式每次呼叫的結果並不一定會是相同的,你接受使用者的輸入不會總是相同的,你要顯示的資料會改變終端機的狀態。

從 main 開始

重新來看看第一個〈Haskell Tutorial(1)哈囉!世界!〉中第一個程式:

main = putStrLn "哈囉!世界!"

putStrLn 函式會有副作用,它會改變真實世界的狀態,也就是終端機的顯示狀態,每執行一次 putStrLn,終端機的顯示就會多一行指定的字串顯示。

在 Haskell 中,每個函式都要有傳回值,對於輸出資料至終端機的 putStrLn,能有什麼樣的傳回值?

putStrLn 的型態

String -> IO () 表示 putStrLn 接受 String,並傳回一個 IO ()IO 是代表輸入輸出這類動作的型態,當中可以包括想與真實世界做溝通的值,對於輸出資料至終端機,實際上不需要什麼傳回值,因此傳回的 IO 包括了一個空的 Tuple,因此顯示為 IO ()

實際上,在 putStrLn 傳回 IO () 之後,還不會對終端機做任何改變,任何 IO 在成為 main 執行後的傳回值前,都不會有任何的作用,例如:

main = do
    let io = putStrLn "Hello, world!"
    putStrLn "哈囉!世界!"

在上面這個程式中,putStrLn "Hello, world!" 實際上只是指定給 io 名稱,並沒有被 do 串起來,成為一串 IO 中的一部份,所以這個程式實際上只會顯示 "哈囉!世界!",而不會顯示 "Hello, World!"

實際上,main 本身也是個函式,它也會有型態,願意的話,你可以如下幫它加上型態,只是慣例上不會加上而已,例如執行 putStrLn 的結果是 IO (),因此可以這麼定義 main 的型態:

main :: IO ()
main = putStrLn "哈囉!世界!"

綁定 IO 中的值

來看看取得使用者輸入的情況,例如下面這個程式:

main = do
    name <- getLine
    putStrLn ("哈囉!" ++ name ++ "!")

getLine 會取得使用者的輸入,它的型態是什麼呢?

getLine 的型態

前面說過,IO 是代表輸入輸出這類動作的型態,當中可以包括想與真實世界做溝通的值,getLine 的傳回值是 IO String,表示這個 IO 中包括了個字串,而這個字串來自使用者的輸入。

你不能使用 name = getLine,這只是表示 name 的值是 IO String,而不是 IO 中的 String,想取得 IO 中的值,必須使用 <-,這就是為什麼寫成 name <- getLine 的原因,以下故意先取得 IO String,再從 IO String 中取得 String,做為一個比較:

main = do
    let io = getLine
    name <- io
    putStrLn ("哈囉!" ++ name ++ "!")

當然,直接寫成 name <- getLine 就可以了,在 Haskell 的慣例中,稱這是將 getLine 傳回的 IO String 中之字串值「綁定(bind)」給 name

實際上,你也可以撰寫 variable <- putStrLn "哈囉!世界!",只是沒什麼意義,因為 putStrLn 的傳回值是 IO (),最後你只是將 IO 中的 () 綁定給 variable 而已,綁定一個空 Tuple 不能做什麼,因此不會這麼做。

這也表示,對於傳回 IO 的函式,你不一定要使用 <-,例如以下,只是將 getLine 取得的結果丟掉而已:

main = do
    getLine
    getLine
    putStrLn "哈囉!世界!"

先簡單談談 do

do 的最後一個動作不能使用 <- 做綁定,想知道為什麼得瞭解什麼是 Monad,IO 是個 Monad,具體來說,是具有 Monad Typeclass 的行為,do 實際上是將一連串的 Monad 連接在一起,這之後才會談到,暫且先記得這個限制就好了。

簡單來說,可以先將 do 理解為,可將一連串會傳回 IO 的函式串接在一起,成為一個更大的 IO,具體來說,do 定義了一個函式呼叫(或說是運算式),而這個被呼叫的函式中包括了數個會傳回 IO 的函式,而 do 定義的函式在呼叫過後傳回的結果型態,取決於其包括的函式中,最後一個函式傳回的型態。

例如,上頭的 main,型態會是 main :: IO (),因為 do 中最後一個函式為 putStrLn,其傳回型態是 IO (),如果改為以下:

main = do
    getLine
    putStrLn "哈囉!世界!"
    getLine

那麼 main 的型態就會是 main :: IO String,因為 do 中最後一個函式是 getLine,其傳回型態是 IO String

純粹跟非純粹

暫時可以這麼說,函式中若包括了會產生 IO 的函式,它就變成也得傳回 IO,為了達到這個目的,比較簡單的方法是將其他與該函式相關聯的程式碼,串聯起來成為一個會傳回 IO 的運算,就目前為止,你知道的方式就是用 do,例如,以下這個會編譯錯誤:

doubleIt input =
    let number = read input::Int
        output = number * 2
    putStrLn $ "Double your " ++ input  
    putStrLn $ show output

main = do
    input <- getLine
    doubleIt input

這是因為 doubleIt 函式中,包括了會傳回 IO ()putStrLn 函式,想解決這個問題,要嘛就是讓 doubleIt 成為非純綷、具副作用,也就是會產生 IO 的函式,像是使用 do 將整個串起來:

doubleIt input = do
    let number = read input::Int
        output = number * 2
    putStrLn $ "Double your " ++ input
    putStrLn $ show output

main = do
    input <- getLine
    doubleIt input

要嘛就是讓 doubleIt 成為純綷、無副作用的函式:

doubleIt input = 
    let number = read input::Int
    in number * 2

main = do
    input <- getLine
    putStrLn $ "Double your " ++ input
    putStrLn $ show $ doubleIt input

簡單來說,你要嘛是純綷、無副作用的函式,要嘛就得是非純綷、具副作用的函式,想要假裝無副作用,而實際上裏頭又有具副作用的函式是行不通的。

這就是 Haskell 將程式中純綷與非純綷部份切割開來的作法,如果你想要做一些非純綷的動作,那麼你就得在非純綷的函式中進行,然後取得值,丟到純綷的函式中去做運算,結果出來後,若想與外界溝通,那就還是得在非純綷的函式中進行。

之後若認識了 Monad,你會知道為什麼有這種限制。

舉例來說,程式初學者在練習〈河內塔〉時,經常會一邊遞迴,一邊顯示目前盤子的情況,在 Haskell 中方式也不是行不通,例如:

hanoi 1 a _ c = printf "Move from %c to %c\n" a c
hanoi n a b c = do
    hanoi (n - 1) a c b
    hanoi 1 a b c
    hanoi (n - 1) b a c

main = do 
    putStrLn "Please enter a number: "
    n <- getLine
    hanoi (read n) 'A' 'B' 'C'

這麼一來,就這個小程式而言,整個世界就變成非純綷了,這麼一來,你就得不到純綷、非副作用的好處,你得學著區分純綷與非純綷,並試著讓非純綷的部份越少越好,這樣你才能享受越多純綷的好處。

最後要出的功課是,試著將上面的河內塔練習的 hanoi 改成純綷、無副作用的函式吧!