初探 IO 型態

February 10, 2022

純綷的世界泡久了,現在換換口味,來看看不純綷的世界好了,你還是得與真實世界溝通,接受輸入,在結果運算出來後輸出到真實世界,這表示總得有個地方,函式執行結果不一定相同,接受的輸入不會總是相同,你要顯示的資料會改變終端機的狀態。

從 main 開始

重新來看看第一個〈Hello, Haskell〉中第一個程式:

main = putStrLn "哈囉!世界!"

putStrLn 函式有副作用,你指定的值會丟到一個你無法控制狀態的世界,這個世界是標準輸出,若標準輸出是終端機,最終就是改變它的顯示狀態,每執行一次 putStrLn,終端機就會多一行字串顯示。

Haskell 的每個函式都要有傳回值,對於輸出資料至標準輸出的 putStrLn,能有什麼樣的傳回值?

ghci> :t putStrLn
putStrLn :: String -> IO ()
ghci>

String -> IO () 表示 putStrLn 接受 String,傳回一個 IO (),在〈初探變數與函式〉談過,IO 代表「你使用了狀態不歸自己管的東西」,你丟給 putStrLn 的值,該怎麼送至標準輸出你管不到,輸出後它也不會傳回值給你,因此傳回的 IO 包括空 tuple。

實際上,在 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 會取得使用者的輸入,它的型態是什麼呢?

ghci> :t getLine
getLine :: IO String
ghci>

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 最後必須傳回 IO,想知道為什麼得瞭解什麼是 MonadIO 是個 Monad,具體來說 IO 是具有 Monad 型態類別的行為,Monad 的實例會封裝某種運算情境,do 之目的就結論而言,是逐層地將 Monad 銜接起來,最內層傳回的 IO 會作為整個 do 的傳回值,更多細節之後才會談到,暫且先記得這個限制就好了。

簡單來說,可以先將 do 理解為,逐層銜接一組 IOdo 最後呼叫函式的傳回型態,就是 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 :: String -> IO ()
doubleIt input = do
    let number = read input
        output = number * 2
    putStrLn $ "Double your " ++ input
    putStrLn $ show output

main = do
    input <- getLine
    doubleIt input

或者讓 doubleIt 成為純綷、無副作用的函式:

doubleIt :: (Num a, Read a) => String -> a
doubleIt input = 
    let number = read input
    in number * 2

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

簡單來說,要嘛是純綷、無副作用的函式,要嘛就得是非純綷、具副作用的函式,這就是 Haskell 將程式中純綷與非純綷部份切割開來的作法。

由於 Haskell 不能有 x = x + 1 這類操作,最後副作用的根源,往往就是與現實世界或設備的互動,如果函式中摻雜著這類互動,就是有副作用、非純綷的函式,如果想做這類非純綷的動作,就得在非純綷的函式中進行,然後取得值,丟到純綷的函式中去做運算,結果出來後,若想與外界溝通,那就還是得在非純綷的函式中進行。

分享到 LinkedIn 分享到 Facebook 分享到 Twitter