在一個不是以函數式為主要典範的語言中,若我們想從事函數式設計,可以實施不可變動特性,也就是變數一旦指定值,就不能改變值,如此一來,程式會被迫分為純函式以及具有副作用的函式兩個部份。

副作用究竟是?

因為,副作用本質上難以掌控,函數式的世界中,鼓勵的是純函式越多越好,如此在日後若遇到了副作用的問題,要追蹤處理的話,就只要專心面對有副作用的部份。

然而,單純只是實施不可變動,還是有可能實現出不純的函式,例如,可產生隨機數的函式是純函式嗎?

如果隨機函式可以指定亂數種子,那麼,它是個純函式;如果隨機函式無法指定亂數種子,那麼,它是個有副作用的函式;如果函式中的x值來自有副作用的隨機函式,就算不再改變x的值,該函式仍是有副作用的函式。

所以,開發者應該思考的是:「副作用到底是什麼呢?」為什麼可以執行x=x+1的語言會有副作用?

如果我們要簡單用一句話來解釋副作用,那就是:「使用了狀態不歸自己管的東西」,而對於可以做x=x+1的語言來說,因為開發者有可能寫出這類函式:

let x = 0;
function foo() {
x = x + 1;
return x;
}

你的同事可能在別的地方呼叫foo,你也會在一些地方執行foo,因此,狀態就脫離了你們的管控,也就沒辦法預期foo的執行結果,所以,我們才會說:foo是個副作用的函式;如果語言一開始就不能x=x+1,程式碼本身的世界,就沒有寫出以上這類函式的可能性。

當然,應用程式會有接受使用者輸入、儲存資料等需求,若語言不能x=x+1,最後副作用就是來自程式外部的世界了。

因為這只是拿來輸入的資料來用,或者將運算結果丟給輸出處理,你其實並不清楚輸入、輸出是怎麼管理外部世界的狀態——它們的狀態不歸你管,而你卻拿來用,也就有了副作用。

Haskell的IO

Haskell不能有x=x+1這類操作,最後副作用的根源,往往就是與現實世界或設備的互動,如果函式中摻雜著這類互動,就是有副作用、非純粹的函式,Haskell規定這類函式一定要傳回IO,函式中若呼叫了會傳回IO的函式,該函式也一定要傳回IO,結果就是非純粹的動作,就必須在非純粹的函式當中進行。

簡單來說,Haskell使用IO來告知,這函式使用了狀態不歸自己管的東西!例如putStrLn傳回IO (),丟給它的值,該怎麼送至標準輸出,你管不到,輸出之後,它也不會將值回傳給你,因此傳回的IO只是個空tuple;getLine的傳回值是IO String,表示IO中包括的字串,先前如何從標準輸入取得,你也一無所知。

想呼叫會傳回IO的函式,方式之一是在do區塊執行,如果函式傳回的IO包含了值,可使用<-取出綁定至變數;但別因此誤會了,並不是do與<-令函式被Haskell認定為有副作用的函式,實際上,會傳回IO的函式才是!

IO只是因為它是Monad的實例,而Monad的實例可用來連續地將上一個運算情境的結果,傳遞給下個運算情境,而傳遞的方式之一是透過do與<-,在〈do區塊與<-綁定〉,我們可以看到:它們相當於逐層地使用>>=及lambda函式,將Monad銜接起來,而就結論而言,do就是銜接Monad的簡便方式,不是只針對IO。

例如,以下的函式可以傳入Just String,也可以傳入IO String,這是因為m接受Monad:

doIt m = do
v <- m
return (v ++ v)

那麼,為什麼要IO成為Monad的實例呢?因為需求是想從無法掌握狀態的運算情境取得結果,封裝運算情境正是Monad之目的。

Haskell裡的迴圈?

方才的doIt函式使用了return函式,它接受一個值,包裝為Monad後傳回,例如,可以傳入Just "FOO",v會是"FOO",v++v得到"FOOFOO",然後用return包裝為Maybe String後傳回;類似地,若將getLine結果傳入,return最後會將使用者輸入的值串接,包裝為IO String傳回。

玩過純函數設計的開發者都知道,純函數的世界沒有迴圈,想做重複的動作,必須靠遞迴,如果想重複地取得使用者輸入並顯示,也是必須用遞迴來進行,例如,Control.Monad模組提供了一些名稱上模仿迴圈的函式,像是when函式:

echoUntil str = do
input <- getLine
when (input /= str) $ do
putStrLn (">> " ++ input)
echoUntil str

when函式若接觸cond與value兩個參數,本體實作為do if cond then value else return (),在echoUntil的實作裡,when右方的do區塊,相當於逐層地使用>>=及lambda函式,將Monad銜接起來,因為惰性的關係,value只會在真正需要時才估值。

因此,從效果上看來,這就像是命令式語言當中的while迴圈語法,只不過,在最後我們要記得遞迴呼叫。

在命令式的世界裡,迴圈基本上就是具有副作用,因此Control.Monad模組中模仿迴圈名稱的函式,像是when、forever、forM等,多半就是搭配IO使用,既然如此,為什麼不是放在一個Control.IO模組呢?

因為只要是Monad就可以適用這些函式,在某些情境下也確實有其作用,例如forM雖然常搭配IO,不過forM [1,2,3] (\x->Just (x+10))是個無副作用運算,結果會是Just [11,12,13]。

更好地管理副作用

對於來自命令式的開發者,接觸Haskell時,通常會花費許多心力在如何寫出純函式,畢竟在命令式的世界中,比較欠缺這類型的思考方式。

然而,既然純函數式的世界,最終會分為純粹與非純粹這兩個部份,如果我們想要真正以純函數式的思考,來解決現實的需求,這對於非純粹部份的熟悉與掌控來說,絕對是必要的一環。

Haskell中帶有IO的函式就是不純粹,這是事實,不過,除了知道這點之外,我們應該更深入地去認識IO、do、<-的運作原理。

這麼一來,就會知道Monad為什麼在Haskell會如此的重要。因為IO就是Monad,只不過它所代表的,是你無法掌握狀態的運算情境,而既然無法掌握狀態,如果使用了它,就是會有副作用。

既然IO就是Monad,而Monad是可以組合的,這表示IO也是可以組合的,若能從Haskell中學習如何管理IO(Monad),回到命令式的世界之後,對於哪些是副作用,如何管理副作用,必然會有很大的啟發。

專欄作者


熱門新聞

Advertisement