8 單子與副作用

從純函數語言的觀點而言,程式是函數,而函數的「本分」是把一個輸入值對應到一個輸出值,除此之外發生的都是副作用(side effects)。 一個程式執行時若可能改變某個可變變數(mutable variable)的值,是一種副作用 --- 這可能是大家最耳熟能詳的例子。在數學式或函數語言程式中,例如 x + 2*y + 1, 其中的 x 和 y 被稱為 ``variables'',譯為「變數」。這名稱的由來是:相對於 1, 2 等等常數,「變數」的值被其所屬環境而決定。但在同一個環境與範圍(scope)中,一個變數仍代表同一個值。 命令式語言中的「變數」則是存放值的容器,我們可使用賦值(assignment)指令改變變數中的值。為了區分,我們把後者這種能被賦值而改變內容的變數稱作「可變變數(mutable variables)」。 此外,讀寫檔案、抓滑鼠的位置、往螢幕寫東西、和系統問現在的日期時間、取亂數等等,也是副作用。 程式若拋出例外(exception)、不傳回值了,是一種副作用。 一個程式若有不止一個可能的傳回值(程式員無法預測是哪一個),也是一種副作用。 在更嚴格的定義中,一個程式如果進入無窮迴圈而不終止(因此不傳回值),也可視為副作用。

許多程式設計典範都大量依靠副作用完成各種工作:更新變數、輸出入、用例外處理執行時間遇到的錯誤...。 在大家的一般印象中,純函數語言反其道而行,其特徵就是「不能有副作用」。 但這其實是個過度簡化的說法。 實情正好相反:純函數語言不僅允許副作用,甚至允許多種副作用並存,並且可把一個程式所被允許的副作用標示在其型別中。 只是,副作用也必須被納入嚴謹的數學架構中。 在純函數語言中,我們需好好地談每個副作用到底「是什麼」(即其語意為何),滿足哪些性質,而這些性質便可用來推論含副作用的程式的正確性。

例如,「拋出例外(exception)」算是一種副作用。 如果我們定義如下的型別:

data Except b = Exception String | Return b 

那麼一個正常終止時傳回型別為 b 的值、但也有可能拋一個例外(並附帶一個字串錯誤訊息)的程式可視為一個值域為 Except b 的全函數 --- 正常終止時傳回 Return x, 「拋出例外」則可用傳回 Exception msg 來模擬。 有多個可能傳回值(型別均為b)的程式可視為值域為 Set b 的全函數 --- 所有可能的回傳值都被收集在集合之中。 假設有一個型別為 s 的可變變數,那麼一個執行完畢後會傳回 b、但可能改變該可變變數的程式可以視為一個型別為 s -> (b, s) 的函數 --- 輸入的 s 為該可變變數的初始值,輸出的序對中,b 是程式的結果,s 是可變變數的新值。例如,當 s 為 Int, 一個傳回該可變變數的現有值、並同時將變數加一的「指令」可表示成一個函數 \s -> (s, 1+s).

如此一來,我們可在純函數語言中實作出許多種副作用。 這可能和讀者心目中的「實作」相當不同 --- 此處的「將可變變數加一」並沒有真正去修改原記憶體位置中的值,而是產生了一個新值; 「拋例外」並未採用高效率的實作(例如到系統堆疊中把回傳位置一個個 pop 出來),而和一般函數一樣,只是傳回一個資料結構。 但我們可把前述這些較有效率的做法當作交給編譯器做的最佳化 --- 當編譯器看到傳回 Except b 的函數,或著看到 s -> (b, s) 這樣的函數,可自己想辦法去找有效率的編譯方式。實務上這相當不容易...TODO 身為程式員,我們在乎的是將「會拋例外的程式其實是什麼?會修改可變變數的程式其實是什麼?」給描述出來。

另一個可能令讀者困擾的是:這樣寫程式實在不方便。一個使用 Except 的程式可能得有許多 case 將子程式的結果配對拆開。一個程式若由許多型別為 s -> (b, s) 的組成,得手動將序對配對,將輸入與輸出的 s 傳來傳去。 這些做法都太低階,我們希望能在抽象一點的層次上工作,方便我們寫程式、讓我們能做抽象些的思考。 這個抽象概念必須能適用於種種副作用,抓出這些副作用的共同模式。

在 Haskell 中,描述副作用最常用的抽象概念是單子(monad)。 大家常認為單子是學習函數語言碰上的第一個難關。 希望讀者讀完本章後,會發現它其實沒那麼難。 一般談單子的方式是由下往上的:由例外、可變變數等等特例的實作開始,歸納出這些特例的共同模式,使讀者可發現引出單子這個概念的動機。 從第 8.1 節開始,我們也將由這種觀點出發解說單子。 但也有另一種由上而下的看法:先討論單子與每個副作用的運算子該滿足什麼性質,才談他們可怎麼實作。我們認為這種看法一樣重要。

例:簡單算式求值   以下的資料結構 Expr 表達一個只有整數、負號、和加法的數學式:

data Expr = Num Int | Neg Expr | Add Expr Expr 

例如 -3 + 4 可表達為 Add (Neg (Num 3)) (Num 4). 我們可輕易寫一個歸納定義,將一個 Expr 計算成一個整數:

eval :: Expr -> Int
eval (Num n)      = nw
eval (Neg e)      = - (eval e)
eval (Add e0 e1)  = eval e0 + eval e1 

看來這是個再單純也不過的純函數。但只要我們稍加擴充這個數學式型別,情況就變複雜了。 如果數學式有除法,分母為零時 eval 就得拋出例外; 我們可以為數學式加入變數,那麼 eval 碰到變數時得查閱該變數的值; 我們也許想要用一個可變變數幫我們記住 eval 做了多少次加法。 程式只要稍微變複雜,我們就會開始需要許多種副作用。

我們將用這個例子介紹單子。在接下來的幾個章節中,我們將以不同的方式擴充 Expr 型別以及 eval 函。 每個擴充需要不同的副作用。 我們將由如何模擬出副作用為動機出發,介紹單子的概念。 引入單子這種抽象化後,我們會發現我們只需改用不同的單子,就可以在不太改變 eval 的主要程式的情況下,為 eval 加入新的副作用。 因此單子可以視為模組化地加入副作用的方法