如果你曾經試圖學習函數式編程,並嘗試理解Monad,但看到文件上的定義卻一個字都看不懂,使用的術語、概念和一般常見的語言又很不一樣。網路上的教程往往都是以最簡單的範例試圖解釋Monad,但看到實際案例後又發現你完全不懂。事實上大部分教程的描述並不適用於「所有」的Monad,甚至在某方面來說是錯的,就算有正確的描述,從軟體開發的角度仍然難以理解。我所謂的「軟體開發的角度」是指:當我們處理資料時,會試圖找實際案例理解資料的意義,這麼做能處理哪些狀況,遇到這個例外時又要怎麼辦。這種由下而上、窮舉式的思考是最實際解決問題的方法,然而Monad官方說明文件卻從不告訴你他是什麼,也從不說明遇到某些情況時為什麼仍沒有問題,這讓工程師無所適從,至少我曾經是如此。這個系列的文章目的是希望能從軟體開發的角度理解Monad等typeclass的概念,並試圖把所有我能想到的、遇到的、跟找得到的各種範例與反例一一展開討論,透過它們循序漸進地打破與重建我們對於Monad的印象。其中將以PureScript編寫範例,簡單來說它就是拿掉惰性求值的比較好的 haskell,其程式碼可以轉譯成有意義的js。
Monad 是函數式編程中很重要的概念,很多我們習以為常的指令式操作在純函數式程式語言中必須使用Monad實現,這使得純函數式編程的使用體驗與其他程式語言很不一樣。這並不是因為純函數式程式語言設計的很爛(至少這方面不是如此),而是它把我們之前沒有注意到習以為常的特性明確地展示出來(unknown known);複雜的不是Monad,而是被我們忽略掉的指令式編程的基本結構。因此Monad是進階函數式編程中最重要的概念,也是讓我們重新認識指令式編程的絕佳機會。要了解Monad,就要先談談函數式編程與眾不同的多型機制:typeclass。typeclass是haskell/PureScript實現根據類型而產生不同行為的唯一機制,這個能力類似於物件導向的子類機制,它們常常被用來實現抽象化的行為。事實上並不是只有子類關係和typeclass能夠描述抽象化,程式裡面處處是不同程度的抽象化。
當我們把一段重複出現的程式碼抽離出來共用,並把其中不一樣的地方改成參數,這個過程抹除了參數的實際數值,因此函式是計算過程對於這些變數的抽象化。
當我們處理某些業務邏輯的計算問題時,需要使用一些區域變數暫存一些業務資料,而不論這個資料代表什麼,通常會直接使用已有的基礎結構如List a, Map k v,這些結構本身並不帶有如業務邏輯的實際意義,而是根據使用方式決定意義,因此可以說這些通用容器類是資料對於意義的抽象化。
有時我們並不關心類型的實際結構(例如各種資料庫、各種容器類)而只在意行為(一系列的關聯方法),並希望不同結構但擁有相同行為的物件能夠在相同情況下被使用,而不需要重複編寫幾乎一樣的程式碼,這可以透過介面、typeclass實現,這種介面是物件行為對於實際類型的抽象化。
一些不同的抽象行為也有共通性,例如擁有狀態的操作可以抽象化成定義存取狀態方法的介面,而能夠執行平行運算的操作也可以抽象化成定義派發平行運算任務方法的介面,這兩種介面擁有共通性質:擁有可嵌套的情境,稱為Monad。執行一系列改變狀態的操作可看作一個普通的改變狀態的操作,而在平行執行緒中派發平行運算的任務理想情況下可看作普通地執行平行運算的操作。Monad只在意能否嵌套,實際上在做什麼並不是重點,只要符合特定規則就是一種Monad,這屬於規則對於實際能力的抽象化。
Functor, Applicative, Monad等都是最後一種類型的抽象化,它與我們一般使用介面的方法很不一樣,它描述的是規則而非業務邏輯的抽象化。在類型驅動編程中,我們會使用類型或介面描述特定概念,例如定義Point3D為帶有三個浮點數的結構以代表三維空間中的點,就算已經有一個類型Vector3D也應該這麼做,因為它們是不同的概念。即使我們對這個概念擁有什麼方法,遵守哪些規則都不清楚,也應該這麼做。例如rust的Pin就是如此,它只告訴你要自己定義各個成員的映射方法,要不然根本沒辦法用,真正重要的是它所代表的概念。而Functor、Monad等則恰恰相反,它們只關心規則,只要符合它所定義的規則,就是合法的實作。事實上它們的目的本來就不是抽象化某個概念,而是用來限定類型必須符合某種規則,因此不應該使用原本對介面的理解方式來認識它們。
你可能會想functor, monad, comonad等typeclass所定義的函式與規則實際上並不能做什麼,因為它們只描述了最最基本的特性,而更進一步的特性才能賦予我們操作的能力。我們應該把它當作「限制」而非「能力」,因此這句話就變成:它們只做了非常基本的限制,就能適用於非常多的地方。這點顯示了type class/trait與我們一般認知的interface/ability之間觀點的差異。函數式編程往往只透過限制基本規則來定義通用函式,而非把他當作某種抽象概念的延伸。因此如果要使用某個函式,只要看它符合哪些規則,如果你的情境符合這個規則,就可以根據要求實作相應的typeclass並使用它,而不需要擔心它所代表的概念是否適用於此。這種typeclass強調的是規則、結構,並沒有特別描述什麼概念,而軟體開發所使用interface的方法則更強調抽象概念。如果你從haskell跳到rust就必須注意這一點,haskell以規則為核心,而rust雖然有類似的類型系統,但是那些都是為概念服務的,因此會出現很多複雜的規則,只為了讓它心目中的概念可以運作。
即使這類的typeclass在定義上把實作時該符合哪些規則都寫得清清楚楚,單從規則很難想像他到底是什麼,很容易就會被已知的情報所困住,因而陷入既定印象。例如一般都會把functor描述成容器,因為List, Maybe, Map k等容器都會實作functor,然而不是容器的Function a也實作了functor,因此這並不是合適的描述*。如果沒有人告訴你這些例子,一般是很難自己意識到這種偏見,因此認識這類的typeclass需要更多實例幫助理解,尤其是反例,藉由反問哪些不符合這個規則或是印象,可以更加理解他所代表的概念的邊界。