純函數式的世界泡久了,現在換換口味,暫時探出頭來看看不純綷的世界好了,實際上,你還是得與真實世界溝通,你得接受輸入,在結果運算出來之後,再輸出到真實世界之中,這表示,你總得有個地方,某些函式每次呼叫的結果並不一定會是相同的,你接受使用者的輸入不會總是相同的,你要顯示的資料會改變終端機的狀態。
從 main 開始
重新來看看第一個〈Haskell Tutorial(1)哈囉!世界!〉中第一個程式:
main = putStrLn "哈囉!世界!"
putStrLn
函式會有副作用,它會改變真實世界的狀態,也就是終端機的顯示狀態,每執行一次 putStrLn
,終端機的顯示就會多一行指定的字串顯示。
在 Haskell 中,每個函式都要有傳回值,對於輸出資料至終端機的 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
會取得使用者的輸入,它的型態是什麼呢?
前面說過,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
改成純綷、無副作用的函式吧!