純函數式的世界看似很美好,不可變動(Immutable)的特性帶來了許多優點,像是無副作用、引用透明(Referential Transparency)、可以很簡單地驗證函式的正確性……等,不過,真實世界狀態是可變的,該如何在與真實世界溝通的同時,又能保有純函數式風格帶來的好處?從Haskell的IO Monad設計探討中,可以得到許多的啟發。

認識IO Monad

在函數式的世界中,將程式區分為純粹(pure)與非純粹(impure)兩個部份,純粹就是不少開發者都已看過的,具備不可變動特性等純函數風格的程式碼,而非純粹的程式碼,就是那些必須與外界溝通(像是輸出至螢幕、取得使用者輸入),或者是具有副作用的函式(像是亂數產生這類的程式碼),在Haskell中有套清楚的區分機制,能有效地畫分出純粹與非純粹的界線,這條界線就是IO Monad。

以putStrLn函式來說,它會使得螢幕產生狀態上的變化,有副作用,可取得使用者輸入的getLine函式也是,因為每次呼叫都可能傳回不同的結果。

想想看,如果在純粹程式碼中出現了這類函式,也就不再純粹了,Haskell主張,讓開發者能清楚地得知這種情況已經發生,在Haskell中,每個函式都要有傳回值,putStrLn與getLine函式也有傳回值,前者是IO (),後者是IO String,實際上任何非純粹的函式,傳回值型態必然都是IO Something。

IO型態實際是個Monad,如果開發者對Monad已經有認識,會知道Monad的精神在於,隱藏某些繁複的運算情境細節,讓開發者只需指定感興趣的程式碼,以突顯程式意圖,IO Monad就是隱藏了輸入輸出、副作用處理的細節,例如,當開發者呼叫getLine而傳回IO String時,只要知道這個IO Monad,能從現實世界帶回一個字串就可以了,至於putStrLn則是將指定的字串帶到現實世界去,但不會帶回任何東西,因此傳回值是IO ()。

內容不純,就不再純粹的函式

Haskell中規定,任何函式無論純粹程式碼佔有多少比例,只要當中包括了一個會傳回IO型態的函式,它就一定也得傳回IO型態,而一個會傳回IO型態的函式,也就不再是純粹的函式。

舉例而言,開發者也許有個doubleIt number = number * 2,這是純粹的函式,若想改成以下是行不通的:

doubleIt number =
 let result = number * 2
 print result

雖然print具有傳回值IO (),這並不表示它可單純作為doubleIt的傳回值,將doubleIt視為純粹函式,這會引發編譯錯誤,想要修正錯誤的話,開發者要嘛維持doubleIt的純粹,使用print $ doubleIt 20來達到相同的效果,不嘛就在上面程式碼的=右邊加上do,這會將每行程式的運算結果用一個IO Monad包裝起來,而所有IO Monad會被串連起來,最後還是得到一個IO Monad,作為doubleIt的傳回值,也就是說,讓doubleIt也成為了非純粹函式。

這就是Haskell隔離純粹與非純粹的程式的作法,如果想要做一些非純粹的動作,那麼就得在非純粹函式中進行,然後取得值,丟到純粹函式中去做運算,得到結果後,再於非純粹的函式中進行輸出;為了得到純粹世界中無副作用、引用透明等各種好處,非純粹的部份要盡量地隔離在夠小的邊界。

由於Haskell會讓開發者知道哪些是不純粹的函式,雖然極端的做法中,可以在每個函式=右邊都加上do,使所有函式都成為非純粹,讓整個世界都是非純粹,令Haskell撰寫起來更像是命令式語言,只不過這樣就會失去函數式的許多優點。

純粹與非純粹的錯誤處理

Haskell將程式世界區分為純粹與非純粹的作法,也連帶影響了錯誤發生時該如何處理。

在純粹世界中,函式執行之後若可能有或沒有結果,可以使用Just Something或Nothing的Maybe型態,相當於命令式語言中現已不算鮮見的Optional型態,不過,Maybe的Nothing並沒有交代執行結果Nothing的原因;若要交代原因,可以使用具有Right Something、Left Somehting的Either型態,分別代表執行時有正確結果及錯誤原因的值。Maybe與Either在我先前專欄〈函數式風格錯誤處理〉中探討過。

除了使用Maybe與Either來表達錯誤之外,容易引人好奇的是,Haskell也有例外處理機制,開發者可以在任何地方拋出例外,即使是從純粹的世界中也行,不過,由於Haskell惰性求值的特性,開發者很難知道例外真正被拋出的點在哪,因此不建議從純粹世界中拋出例外,純粹世界中的錯誤建議使用Maybe或Either來表達。

Haskell中的例外被拋出之後,唯一能處理例外的地方,就是在IO Monad之中,因為,Haskell處理例外的catch、try、handle等函式,都是不純粹的,傳回值都是IO型態。

在進行具有副作用、輸入輸出等非純粹處理時,容易會有些意外,例如檔案讀取,總是會有檔名不存在、檔案毀損等狀況,由於例外也只能在IO Monad中處理,因此,從非純粹世界中拋出例外,比較合理,因為意外發生在非純粹世界,也在非純粹世界中處理。

實際上,要拋出例外,也有純粹與非純粹這兩種選擇,error與throw可以在純粹函式中使用,ioError與throwIO則是非純粹的,傳回型態都是IO,用了它們的函式,也要成為非純粹的函式。

比較有趣的兩個函式,是try與fail。

try函式會使用Either來包裝非純粹函式執行過的結果,如果正確執行,就傳回Right Something,如果捕捉例外,就用Left Exception包裝後傳回,這有點像是〈函數式風格錯誤處理〉中談到的Try類別。

而fail函式可以讓開發者,不用理會哪個Monad會被用來處理執行結果。如果使用Maybe Monad來處理,那fail就是純粹的﹔如果使用IO Monad來處理,那就會引發IOException,這時就要在非純粹的世界中,處理這個例外。

沒有強制區分純粹與否的世界呢?

Haskell是嚴格的語言,對型態有嚴格而明確要求,對純粹與非純粹也是強制區分,不過現實世界的其他語言在開發時,實際上也並非沒有類似考量。

比方說在MVC未盛行的年代,網頁開發就是夾雜著純粹與非純粹,因而導致各種維護的問題,MVC設計中Control負責處理輸入,View負責頁面輸出,Model純粹處理相關商務邏輯,就是區分純粹與非純粹的設計方式,而在Rails之類的框架中,Control、View中可進行的動作有限,就是一種強加的限制。

更進一步地,早期HTML夾雜著樣式,後來的HTML標準將樣式抽出來,讓HTML純粹僅表示頁面結構,瀏覽器中的JavaScript也曾經混雜在頁面之中,後來為了解決混亂,有了Unobstructive JavaScript的提倡,這表示,純粹與否,不單只是輸入輸出或有無副作用,即使語言或框架沒有將這類設計考量強制內建,在面對程式時,也是時時要思考與自律進行畫分。

當然,如果語言或框架本身作出限制,也就逼著開發者要多做一份思考,通常,也就少了一份彈性或多了許多繁複。這就好比靜態定型語言中,開發者總是得為了型態增加許多思考,以及多一點程式碼,而在基於類別的語言中,物件總是得遵守類別的規範,是一樣的道理。

當語言沒有了許些限制,在歌頌彈性與簡潔之餘,別忘了責任就落到了開發者身上,無論那份責任是職責分配、思考型態、區分有無副作用等哪一類純粹與否的問題。

作者簡介


Advertisement

更多 iThome相關內容