15 分鐘瞭解 Monad
看到函數語言程式設計相關的資料的時候, 總是看到 Monad 這個詞, 一直想了解一下, 然而查資料對 Monad 的定義往往是上來一大堆數學概念:
Monad 是一個自函子範疇上的么半群
鑑於本人數學基礎實在太差, 一直沒能理解. 其實撇開這些數學概念來說, Monad 本身是一個非常簡 單的東西, 像是 Rust 中的 Option 一樣, 一旦理解, 就發現再也回不去之前沒有他的世界了. Monad 並不僅侷限於函數語言程式設計語言, 也可以用其他的語言來表示.
例子
1 日誌
假設我們有三個只接受一個引數的函式, f1
, f2
, f3
, 分別返回 +1, +2, +3 後的數局以及一 條關於做了什麼操作的資訊.
def f1(x): return (x + 1, str(x) + "+1") def f2(x): return (x + 2, str(x) + "+2") def f3(x): return (x + 3, str(x) + "+3") 複製程式碼
現在我們想要計算 x + 1 + 2 + 3, 那麼我們可以把這三個函式鏈式呼叫. 而且, 我們還想獲得關於 呼叫了那些函式的詳細日誌.
可以這樣做:
log = "Ops:" res, log1 = f1(x) log += log1 + ";" res, log2 = f2(res) log += log2 + ";" res, log3 = f3(res) log += log3 + ";" print(res, log) 複製程式碼
這種方法簡直太醜陋了, 首先我們重複編寫了好多膠水程式碼, 而且如果我們要再新增一個函式 f4 的 話, 就得再多些兩行膠水程式碼. 更糟糕的是, 不斷改變 res 和 log 兩個變數的值讓我們的程式碼變得 非常不可讀.
理想情況下, 我們希望能夠這樣鏈式呼叫: f3(f2(f1(x))). 不幸的是, f1 和 f2 的返回結果和 f2 和 f3 的入口引數是不一樣的. 為了解決這個問題, 我們引入兩個新的函式:
def unit(x): return (x, "Ops:") def bind(t, f): res = f(t[0]) return (res[0], t[1] + res[1] + ";") 複製程式碼
這樣的話, 我們就可以用下面的鏈式呼叫來解決了:
print(bind(bind(bind(unit(x), f1), f2), f3)) 複製程式碼
下面的圖展示了當 x=0 時候的呼叫過程, v1, v2, v3 分別表示中間資料.
unit 函式把引數 x 變成了 (int, str) 構成的 tuple. 接下來的 bind 函式呼叫了他的引數 f 函 數, 同時把結果累加到了形參 t 上.
這種方法避免了第一種方法的缺點, 因為所有的膠水程式碼都在 bind 函式中, 當我們要新增一個新的 函式的時候, 只需要接著鏈式呼叫就可以了.
print(bind(bind(bind(bind(unit(x), f1), f2), f3), f4)) 複製程式碼
2 中間值的列表
在這個例子中, 我們假設有三個簡單的單參函式:
def f1(x): return x + 1 def f2(x): return x + 2 def f3(x): return x + 3 複製程式碼
和前面的例子一樣, 我們想要組合這些函式來計算 x+1+2+3 的值. 除此之外, 我們還想要生成中間 值得列表, 也就是: x, x+1, x+1+2, x+1+2+3.
和前面的例子不同的是, 這三個函式的輸入和輸出型別是匹配的, 因此我們可以直接呼叫 f3(f2(f1(x)). 不過這樣做的話, 我們沒法獲得中間值.
一個可行的方法是:
lst = [x] res = f1(x) lst.append(res) res = f2(res) lst.append(res) res = f3(res) lst.append(res) print(res, lst) 複製程式碼
很顯然, 這並不是一個很好的做法, 我們又寫了一堆的膠水程式碼, 而且還得負責把中間變數聚合成一 個列表. 如果我們再新增一個新的函式 f4 的話, 又得再新增一些新的膠水程式碼了.
為了解決這個問題, 我們像之前一樣, 引入兩個輔助函式:
def unit(x): return (x, [x]) def bind(t, f): res = f(t[0]) return (res, t[1] + [res]) 複製程式碼
現在, 我們又可以鏈式呼叫了:
print( bind(bind(bind(unit(x), f1), f2), f3) ) 複製程式碼
下面的圖表展示了當 x=0 的時候, v1, v2, v3 分別代表了中間變數.
3 Nulls/Nones
下面讓我們來引入類和物件. 假設我們有一個類 Employee:
class Employee: def get_boss(self): """Retrun the employee's boss""" def get_wage(self): """Compute the wage""" 複製程式碼
每個 Employee 例項都有一個 boss, 也就是老闆, 並且也是 Employee 型別的, 還有一個工資屬性. 我們可以通過兩個方法來訪問他們. 每一個方法都有可能返回 None (也就是說工資不知道, 或者是 沒有 boss). 在這個例子中, 我們要開發一個程式, 給定一個 Employee, 比如說 john, 返回他的老 板的工資, 如果不能確定工資的話, 或者 john 是 None, 那麼我們應該返回 None.
理想情況下, 我們只要這樣寫就好了:
print(john.get_boss().get_wage()) 複製程式碼
然而, 因為每個方法都可能返回 None, 我們得這麼寫:
result = None if john is not None and john.get_boss() is not None and john.get_boss().get_wage() is not None: result = johs.get_boss().get_wage() print(result) 複製程式碼
然而, 在這個方案中, 我們呼叫了好多次 get_boss 和 get_wage 方法. 如果這兩個方法呼叫起來代 價很大的話(比如說需要查詢資料庫), 那麼顯然是不合適的. 所以方案應該是:
result = None if john is not None: boss = john.get_boss() if boss is not None: wage = boss.get_wage() if wage is not None: result = wage print(result) 複製程式碼
這個方案顯然不太好看, 三層 if 語句看起來太臃腫了. 為了解決這個問題, 我們使用和剛剛一樣的 方法: 定義下面的輔助函式
def unit(e): return e def bind(e, f): return None if e is None, else f(e) 複製程式碼
現在我們可以直接鏈式呼叫了:
print(bind(bind(unit(john), Employee.get_boss), Employee.get_wage)) 複製程式碼
你可能已經注意到了, 我們實際上並不需要呼叫 unit(john), 因為他就是返回自身而已. 我們這樣 做的原因是為了和之前的模式保持一致, 這樣我們就能推廣泛化到更通用的模式. 另外需要注意的是 , 在 Python 中, 方法也只是普通的函式, john.get_boss() 和 Employee.get_boss(john) 是完全 一樣的意思.
下面的圖表顯示了在 john 沒有 boss 的情況下的計算過程.
泛化 - Monads
如果我們想要組合函式 f1, f2, ... fn. 如果所有的引數都和返回型別對的上, 那麼我們可以直接 呼叫 fn(...f2(f1(x))...). 下面的圖說明了隱含的計算過程. v1, v2...vn 標識了其中的中間變數 .
然而, 這種情況往往是不存在的. 比如說在我們之前的日誌例子中, 輸入型別和輸出型別是不能匹配 的, 在第二個和第三個例子中, 函式是可以組合的, 但是我們想要在其中"注入"我們額外的邏輯. 在 第二個例子中, 我們想要記錄中間值, 而在第三個例子中, 我們想要加入 Null/None 檢測.
命令式解法
在上面的例子中, 我們首先使用了直觀的命令式解法. 如下圖所示:
在呼叫 f1 之前, 我們首先執行一些初始化程式碼. 比如, 在例子1 和例子2 中, 我們初始化了儲存日 志和中間值的變數. 在之後我們呼叫 f1, f2...fn 等函式的時候, 我們添加了一些膠水程式碼. 在例 子1 和例子2 中, 膠水程式碼分別負責聚合日誌和中間值. 在例子3 中, 膠水程式碼負責檢查中間值是否 是空的, 也就是 Null/None.
引入 Monad
正如我們在上面的例子中看到的一樣, 直接的方法會有一些讓人不悅的副作用 -- 醜陋的膠水程式碼, 多次檢查 Null/None 等等. 為了實現更優雅的方案, 在上面的例子中, 我們使用了一種設計模式, 包含了 unit 和 bind 兩種函式. 這種設計模式就叫做 Monad . 本質上來說, bind 函式實現了 膠水程式碼, 而 unit 實現了初始化程式碼. 這就讓我們可以在一行之內解決問題:
bind(bind(...bind(bind(unit(x), f1), f2)...fn-1), fn) 複製程式碼
下面的圖表說明了計算過程:
unit(x) 產生了初始值 v1, 然後 bind(v1, f1) 生成了新的中間值 v2, 然後在被用到了 bind(v2, f2) 中, 整個過程一直持續到最終結果產生. 使用這個模式, 配合上不同的 unit 和 bind 函式, 我們可以實 現多種不同的函式組合. 標準的 Monad 庫提供了幾種預定義好的常用 monad(也就是 unit 和 bind 函式), 可以直接拿來用.
為了組合 bind 和 unit 函式, unit 和 bind 的返回值, 和 bind 的第一個引數必須是匹配的. 這 叫做 Monadic 型別. 在上面的 Monad 計算過程中, 所有的中間值的型別都是 Monadic.
最後, 重複呼叫bind顯然也是醜陋的, 我們可以定義一個函式來輔助操作.
def pipeline(e, *fns): for fn in fns: e = bind(e, fn) return e 複製程式碼
下面的程式碼:
bind(bind(bind(bind(unit(x), f1), f2), f3), f4) 複製程式碼
就可以改成:
pipeline(unit(x), f1, f2, f3, f4) 複製程式碼
結論
Monad 是函式組合的一種簡單又強大的設計模式. 在宣告式的語言中, 他被用來實現命令式語言中的 日誌和 IO 操作. 在命令式的語言中, 他可以用來減少和隔離冗餘的膠水程式碼. 本文只是簡單地介紹 了 Monad 的一些只管解釋, 還可以檢視下面這些資料:
本文主要翻譯自: https://nikgrozev.com/2013/12/10/monads-in-15-minutes/