深入理解 Java 函數語言程式設計,第 5 部分: 深入解析 Monad
深入理解 Java 函數語言程式設計,第 5 部分
深入解析 Monad
成 富
2018 年 12 月 03 日釋出
系列內容:
此內容是該系列 5 部分中的第 # 部分: 深入理解 Java 函數語言程式設計,第 5 部分
https://www.ibm.com/developerworks/cn/views/global/libraryview.jsp?series_title_by=深入理解+java+函數語言程式設計
敬請期待該系列的後續內容。
此內容是該系列的一部分: 深入理解 Java 函數語言程式設計,第 5 部分
敬請期待該系列的後續內容。
在本系列的前四篇文章中對函數語言程式設計進行了多方位的介紹。本文將著重介紹函數語言程式設計中一個重要而又複雜的概念:Monad。一直以來,Monad 都是函數語言程式設計中最具有神祕色彩的概念。正如 JSON 格式的提出者 Douglas Crockford 所指出的,Monad 有一種魔咒,一旦你真正理解了它的意義,就失去了解釋給其他人的能力。本文嘗試深入解析 Monad 這一概念。由於 Monad 的概念會涉及到一些數學理論,可能讀起來會比較枯燥。本文側重在 Monad 與程式設計相關的方面,並結合 Java 示例程式碼來進行說明。
範疇論
要解釋 Monad,就必須提到範疇論(Category Theory)。範疇(category)本身是一個很簡單的概念。一個範疇由物件(object)以及物件之間的箭頭(arrow)組成。範疇的核心是組合,體現在箭頭的組合性上。如果從物件 A 到物件 B 有一個箭頭,從物件 B 到物件 C 也有一個箭頭,那麼必然有一個從物件 A 到物件 C 的箭頭。從 A 到 C 的這個箭頭,就是 A 到 B 的箭頭和 B 到 C 的箭頭的組合。這種組合的必然存在性,是範疇的核心特徵。以專業術語來說,箭頭被稱為態射(morphisms)。範疇中物件和箭頭的概念可以很容易地對映到函式中。型別可以作為範疇中的物件,把函式看成是箭頭。如果有一個函式 f 的引數型別是 A,返回值型別是 B,那麼這個函式是從 A 到 B 的態射;另外一個函式 g 的引數型別是 B,返回值型別是 C,這個函式是從 B 到 C 的態射。可以把 f 和 g 組合起來,得到一個新的從型別 A 到型別 C 的函式,記為 g ∘f,也就是從 A 到 C 的態射。這種函式的組合方式是必然存在的。
一個範疇中的組合需要滿足兩個條件:
- 組合必須是傳遞的(associative)。如果有 3 個態射 f、g 和 h 可以按照 h∘g∘f 的順序組合,那麼不管是 g 和 h 先組合,還是 f 和 g 先組合,所產生的結果都是一樣的。
- 對於每個物件 A,都有一個作為組合基本單元的箭頭。這個箭頭的起始和終止都是該物件 A 本身。當該箭頭與從物件 A 起始或結束的其他箭頭組合時,得到的結果是原始的箭頭。以函式的概念來說,這個函式稱為恆等函式(identity function)。在 Java 中,這個函式由 Function.identity() 表示。
從程式設計的角度來說,範疇論的概念要求在設計時應該考慮物件的介面,而不是具體的實現。範疇論中的物件非常的抽象,沒有關於物件的任何定義。我們只知道物件上的箭頭,而對於物件本身則一無所知。物件實際上是由它們之間的相互組合關係來定義的。
範疇的概念雖然抽象,實際上也很容易找到現實的例子。最直接的例子是從有向圖中創建出範疇。對於有向圖中的每個節點,首先新增一個從當前節點到自身的箭頭。然後對於每兩條首尾相接的邊,新增一條新的箭頭連線起始和結束節點。如此反覆,就得到了一個範疇。
範疇中的物件和態射的概念很抽象。從程式設計的角度來說,我們可以找到更好的表達方式。在程式中,討論單個的物件例項並沒有意義,更重要的是物件的型別。在各種程式語言中,我們已經認識了很多型別,包括 int、long、double 和 char 等。型別可以看成是值的集合。比如 bool 型別就只有兩個值 true 和 false,int 型別包含所有的整數。型別的值可以是有限的,也可以是無限的。比如 String 型別的值是無限的。程式語言中的函式其實是從型別到型別的對映。對於引數超過 1 個的函式,總是可以使用柯里化來轉換為只有一個引數的函式。
型別和函式可以分別與範疇中的物件和態射相對應。範疇中的物件是型別,而態射則是函式。型別的作用在於限定了範疇中態射可以組合的方式,也就是函式的組合方式。只有一個函式的返回值型別與另一個函式的引數型別匹配時,這兩個函式才能並肯定可以組合。這也就滿足了範疇的定義。
之前討論的函式都是純函式,不含任何副作用。而在實際的程式設計中,是離不開副作用的。純函式適合於描述計算,但是沒辦法描述輸出字串到控制檯或是寫資料到檔案這樣的副作用。Monad 的作用正是解決了如何描述副作用的問題。實際上,純粹的函數語言程式設計語言 Haskell 正是用 Monad 來處理描述 IO 等基於副作用的操作。在介紹 Monad 之前,需要先說明 Functor。
Functor
Functor 是範疇之間的對映。對於兩個範疇 A 和 B,Functor F 把範疇 A 中的物件對映到範疇 B 中。Functor 在對映時會保留物件之間的連線關係。如果範疇 A 中存在從物件 a 到物件 b 的態射,那麼 a 和 b 經過 Functor F 在範疇 B 中的對映值 F a 和 F b 之間也存在著態射。同樣的,態射之間的組合關係,以及恆等態射都會被保留。所以說 Functor 不僅是範疇中物件之間的對映,也是態射之間的對映。如果一個 Functor 從一個範疇對映到自己,稱為 endofunctor。
前面提到過,程式語言中的範疇中的物件是型別,而態射是函式。因此,這樣的 endofunctor 是從型別到型別的對映,同時也是函式到函式的對映。我們首先看一個具體的 Functor :Option。Option 的定義很簡單,Java 標準庫和 Vavr 中都有對應的類。不過我們這裡討論的 Option 與 Java 中的 Optional 類有很大不同。Option 本身是一個型別構造器,使用時需要提供一個型別,所得到的結果是另外一個新的型別。這裡可以與 Java 中的泛型作為類比。Option 有兩種可能的值:Some 和 None。Some 表示對應型別的一個值,而 None 表示沒有值。對於一個從 a 到 b 的對映 f,可以很容易地找到與之對應的使用 Option 的對映。該對映把 None 對應到 None,而把 f(Some a)對映到 Some f(a)。
Monad
Monad 本身也是一種 Functor。Monad 的目的在於描述副作用。
函式的副作用與組合方式
清單 1 給出了一個簡單的函式 increase。該函式的作用是返回輸入的引數加 1 之後的值。除了進行計算之外,還通過 count++來修改一個變數的值。這行語句的出現,使得函式 increase 不再是純函式,每次呼叫都會對外部環境造成影響。
清單 1. 包含副作用的函式
int count = 0; int increase(int x) { count++; return x + 1; }
清單 1 中的函式 increase 可以劃分成兩個部分:產生副作用的 count++,以及剩餘的不產生副作用的部分。如果可以通過一些轉換,把副作用從函式 increase 中剝離出來,那麼就可以得到另外一個純函式的版本 increase1,如清單 2 所示。對函式 increase1 來說,我們可以把返回值改成一個 Vavr 中的 Tuple2<Integer, Integer> 型別,分別包含函式原始的返回值 x + 1 和在 counter 上增加的增量值 1。通過這樣的轉換之後,函式 increase1 就變成了一個純函式。
清單 2. 轉換之後的純函式版本
Tuple2<Integer, Integer> increase1(int x) { return Tuple.of(x + 1, 1); }
在經過這樣的轉換之後,對於函式 increase1 的呼叫方式也發生了變化,如清單 3 所示。遞增之後的值需要從 Tuple2 中獲取,而 count 也需要通過 Tuple2 的值來更新。
清單 3. 呼叫轉換之後的純函式版本
int x = 0; Tuple2<Integer, Integer> result = increase1(x); x = result._1; count += result._2;
我們可以採用同樣的方式對另外一個相似的函式 decrease 做轉換,如清單 4 所示。
清單 4. 函式 decrease 及其純函式版本
int decrease(int x) { count++; return x - 1; } Tuple2<Integer, Integer> decrease1(int x) { return Tuple.of(x - 1, 1); }
不過需要注意的是,經過這樣的轉換之後,函式的組合方式發生了變化。對於之前的 increase 和 decrease 函式,可以直接組合,因為它們的引數和返回值型別是匹配的,如類似 increase(decrease(x)) 或是 decrease(increase(x)) 這樣的組合方式。而經過轉換之後的 increase1 和 decrease1,由於返回值型別改變,increase1 和 decrease1 不能按照之前的方式進行組合。函式 increase1 的返回值型別與 decrease1 的引數型別不匹配。對於這兩個函式,需要另外的方式來組合。
在清單 5 中,compose 方法把兩個型別為 Function<Integer, Tuple2<Integer, Integer>> 的函式 func1 和 func2 進行組合,返回結果是另外一個型別為 Function<Integer, Tuple2<Integer, Integer>> 的函式。在進行組合時,Tuple2 的第一個元素是實際需要返回的結果,按照純函式組合的方式來進行,也就是把 func1 呼叫結果的 Tuple2 的第一個元素作為輸入引數來呼叫 func2。Tuple2 的第二個元素是對 count 的增量。需要把這兩個增量相加,作為 compose 方法返回的 Tuple2 的第二個元素。
清單 5. 函式的組合方式
Function<Integer, Tuple2<Integer, Integer>> compose( Function<Integer, Tuple2<Integer, Integer>> func1, Function<Integer, Tuple2<Integer, Integer>> func2) { return x -> { Tuple2<Integer, Integer> result1 = func1.apply(x); Tuple2<Integer, Integer> result2 = func2.apply(result1._1); return Tuple.of(result2._1, result1._2 + result2._2); }; }
清單 6 中的 doCompose 函式對 increase1 和 decrease1 進行組合。對於一個輸入 x,由於 increase1 和 decrease1 的作用相互抵消,得到的結果是值為 (x, 2) 的物件。
清單 6. 函式組合示例
Tuple2<Integer, Integer> doCompose(int x) { return compose(this::increase1, this::decrease1).apply(x); }
可以看到,doCompose 函式的輸入引數和返回值型別與 increase1 和 decrease1 相同。所返回的結果可以繼續使用 doCompose 函式來與其他型別相同的函式進行組合。
Monad 的定義
現在回到函式 increase 和 decrease。從範疇論的角度出發,我們考慮下面一個範疇。該範疇中的物件仍然是 int 和 bool 等型別,但是其中的態射不再是簡單的如 increase 和 decrease 這樣的函式,而是把這些函式通過類似從 increase 到 increase1 這樣的方式轉換之後的函式。範疇中的態射必須是可以組合的,而這些函式的組合是通過呼叫類似 doCompose 這樣的函式完成的。這樣就滿足了範疇的第一條原則。而第二條原則也很容易滿足,只需要把引數 x 的值設為 0,就可以得到組合的基本單元。由此可以得出,我們定義了一個新的範疇,而這個範疇就叫做 Kleisli 範疇。每個 Kleisli 範疇所使用的函式轉換方式是獨特的。中的示例使用 Tuple2 來儲存 count 的增量。與之對應的,Kleisli 範疇中對態射的組合方式也是獨特的,類似清單 6 中的 doCompose 函式。
在對 Kleisli 範疇有了一個直觀的瞭解之後,就可以對 Monad 給出一個形式化的定義。給定一個範疇 C 和 endofunctor m,與之相對應的 Kleisli 範疇中的物件與範疇 C 相同,但態射是不同的。K 中的兩個物件 a 和 b 之間的態射,是由範疇 C 中的 a 到 m(b) 的態射來實現的。注意,Kleisli 範疇 K 中的態射箭頭是從物件 a 到物件 b 的,而不是從物件 a 到 m(b)。如果存在一種傳遞的組合方式,並且每個物件都有組合單元箭頭,也就是滿足範疇的兩大原則,那麼這個 endofunctor m 就叫做 Monad。
一個 Monad 的定義中包含了 3 個要素。在定義 Monad 時需要提供一個型別構造器 M 和兩個操作 unit 和 bind:
- 型別構造器的作用是從底層的型別中創建出一元型別(monadic type)。如果 M 是 Monad 的名稱,而 t 是資料型別,則 M t 是對應的一元型別。
- unit 操作把一個普通值 t 通過型別構造器封裝在一個容器中,所產生的值的型別是 M t。unit 操作也稱為 return 操作。return 操作的名稱來源於 Haskell。不過由於 return 在很多程式語言中是保留關鍵詞,用 unit 做名稱更為合適。
- bind 操作的型別宣告是 (M t)→(t→M u)→(M u)。該操作接受型別為 M t 的值和型別為 t → M u 的函式來對值進行轉換。在進行轉換時,bind 操作把原始值從容器中抽取出來,再應用給定的函式進行轉換。函式的返回值是一個新的容器值 M u。M u 可以作為下一次轉換的起點。多個 bind 操作可以級聯起來,形成處理流水線。
如果只看 Monad 的定義,會有點晦澀難懂。實際上中的示例就是一種常見的 Monad,稱為 Writer Monad。下面我們結合 Java 程式碼來看幾種常見的 Monad。
Writer Monad
展示了 Writer Monad 的一種用法,也就是累積 count 的值。實際上,Writer Monad 的主要作用是在函式呼叫過程中收集輔助資訊,比如日誌資訊或是效能計數器等。其基本的思想是把副作用中對外部環境的修改聚合起來,從而把副作用從函式中分離出來。聚合的方式取決於所產生的副作用。中的副作用是修改計算器 count,相應的聚合方式是累加計數值。如果副作用是產生日誌,相應的聚合方式是連線日誌記錄的字串。聚合方式是每個 Writer Monad 的核心。對於聚合方式的要求和範疇中對於態射的要求是一樣,也就是必須是傳遞的,而且有組合的基本單元。在中,聚合方式是 Integer 型別的相加操作,是傳遞的;同時也有基本單元,也就是加零。
下面對 Writer Monad 進行更加形式化的說明。Writer Monad 除了其本身的型別 T 之外,還有另外一個輔助型別 W,用來表示聚合值。對型別 W 的要求是前面提到的兩點,也就是存在傳遞的組合操作和基本單元。Writer Monad 的 unit 操作比較簡單,返回的是型別 T 的值 t 和型別 W 的基本單元。而 bind 操作則需要分別轉換型別 T 和 W 的值。對於 T 的值,按照 Monad 自身的定義來轉換;而對於 W 的值,則使用該型別的傳遞操作來聚合值。聚合的結果作為轉換之後的新的 W 的值。
清單 7 中是記錄日誌的 Writer Monad 的例項。該 Monad 自身的型別使用 Java 泛型型別 T 來表示,而輔助型別是 List<String>,用來儲存記錄的日誌。List<String> 滿足作為輔助型別的要求。List<String> 上的相加操作是傳遞的,也存在作為基本單元的空列表。LoggingMonad 中的 unit 方法返回傳入的值 value 和空列表。bind 方法的第一個引數是 LoggingMonad<T1> 型別,作為變換的輸入;第二個引數是 Function<T1, LoggingMonad<T2>> 型別,用來把型別 T1 轉換成新的 LoggingMonad<T2> 型別。輔助型別 List<String> 中的值通過列表相加的方式進行組合。方法 pipeline 表示一個處理流水線,對於一個輸入 Monad,依次應用指定的變換,得到最終的結果。在使用示例中,LoggingMonad 中封裝的是 Integer 型別,第一個轉換把值乘以 4,第二個變換把值除以 2。每個變換都記錄自己的日誌。在執行流水線之後,得到的結果包含了轉換之後的值和聚合的日誌。
清單 7. 記錄日誌的 Monad
public class LoggingMonad<T> { private final T value; private final List<String> logs; public LoggingMonad(T value, List<String> logs) { this.value = value; this.logs = logs; } @Override public String toString() { return "LoggingMonad{" + "value=" + value + ", logs=" + logs + '}'; } public static <T> LoggingMonad<T> unit(T value) { return new LoggingMonad<>(value, List.of()); } public static <T1, T2> LoggingMonad<T2> bind(LoggingMonad<T1> input, Function<T1, LoggingMonad<T2>> transform) { final LoggingMonad<T2> result = transform.apply(input.value); List<String> logs = new ArrayList<>(input.logs); logs.addAll(result.logs); return new LoggingMonad<>(result.value, logs); } public static <T> LoggingMonad<T> pipeline(LoggingMonad<T> monad, List<Function<T, LoggingMonad<T>>> transforms) { LoggingMonad<T> result = monad; for (Function<T, LoggingMonad<T>> transform : transforms) { result = bind(result, transform); } return result; } public static void main(String[] args) { Function<Integer, LoggingMonad<Integer>> transform1 = v -> new LoggingMonad<>(v * 4, List.of(v + " * 4")); Function<Integer, LoggingMonad<Integer>> transform2 = v -> new LoggingMonad<>(v / 2, List.of(v + " / 2")); final LoggingMonad<Integer> result = pipeline(LoggingMonad.unit(8), List.of(transform1, transform2)); System.out.println(result); // 輸出為 LoggingMonad{value=16, logs=[8 * 4, 32 / 2]} } }
Reader Monad
Reader Monad 也被稱為 Environment Monad,描述的是依賴共享環境的計算。Reader Monad 的型別構造器從型別 T 中創建出一元型別 E → T,而 E 是環境的型別。型別構造器把型別 T 轉換成一個從型別 E 到 T 的函式。Reader Monad 的 unit 操作把型別 T 的值 t 轉換成一個永遠返回 t 的函式,而忽略型別為 E 的引數;bind 操作在轉換時,在所返回的函式的函式體中對型別 T 的值 t 進行轉換,同時保持函式的結構不變。
清單 8 是 Reader Monad 的示例。Function<E, T> 是一元型別的宣告。ReaderMonad 的 unit 方法返回的 Function 只是簡單的返回引數值 value。而 bind 方法的第一個引數是一元型別 Function<E, T1>,第二個引數是把型別 T1 轉換成 Function<E, T2> 的函式,返回值是另外一個一元型別 Function<E, T2>。bind 方法的轉換邏輯首先通過 input.apply(e) 來得到型別為 T1 的值,再使用 transform.apply 來得到型別為 Function<E, T2>> 的值,最後使用 apply(e) 來得到型別為 T2 的值。
清單 8. Reader Monad 示例
public class ReaderMonad { public static <T, E> Function<E, T> unit(T value) { return e -> value; } public static <T1, T2, E> Function<E, T2> bind(Function<E, T1> input, Function<T1, Function<E, T2>> transform) { return e -> transform.apply(input.apply(e)).apply(e); } public static void main(String[] args) { Function<Environment, String> m1 = unit("Hello"); Function<Environment, String> m2 = bind(m1, value -> e -> e.getPrefix() + value); Function<Environment, Integer> m3 = bind(m2, value -> e -> e.getBase() + value.length()); int result = m3.apply(new Environment()); System.out.println(result); } }
清單 8 中使用的環境型別 Environment 如清單 9 所示,其中有兩個方法 getPrefix 和 getBase 分別返回相應的值。清單 8 的 m1 是值為 Hello 的單元型別,m2 使用了 Environment 的 getPrefix 方法進行轉換,而 m3 使用了 getBase 方法進行轉換,最終輸出的結果是 107。因為字串 Hello 在添加了字首 $$ 之後的長度是 7,與 100 相加之後的值是 107。
清單 9. 環境型別
public class Environment { public String getPrefix() { return "$$"; } public int getBase() { return 100; } }
State Monad
State Monad 可以在計算中附加任意型別的狀態值。State Monad 與 Reader Monad 相似,只是 State Monad 在轉換時會返回一個新的狀態物件,從而可以描述可變的環境。State Monad 的型別構造器從型別 T 中建立一個函式型別,該函式型別的引數是狀態物件的型別 S,而返回值包含型別 S 和 T 的值。State Monad 的 unit 操作返回的函式只是簡單地返回輸入的型別 S 的值;bind 操作所返回的函式型別負責在執行時傳遞正確的狀態物件。
清單 10 給出了 State Monad 的示例。State Monad 使用元組 Tuple2<T, S> 來儲存計算值和狀態物件,所對應的一元型別是 Function<S, Tuple2<T, S>> 表示的函式。unit 方法所返回的函式只是簡單地返回輸入狀態物件。bind 方法的轉換邏輯使用 input.apply(s) 得到 T1 和 S 的值,再用得到的 S 值呼叫 transform。
清單 10. State Monad 示例
public class StateMonad { public static <T, S> Function<S, Tuple2<T, S>> unit(T value) { return s -> Tuple.of(value, s); } public static <T1, T2, S> Function<S, Tuple2<T2, S>> bind(Function<S, Tuple2<T1, S>> input, Function<T1, Function<S, Tuple2<T2, S>>> transform) { return s -> { Tuple2<T1, S> result = input.apply(s); return transform.apply(result._1).apply(result._2); }; } public static void main(String[] args) { Function<String, Function<String, Function<State, Tuple2<String, State>>>> transform = prefix -> value -> s -> Tuple .of(prefix + value, new State(s.getValue() + value.length())); Function<State, Tuple2<String, State>> m1 = unit("Hello"); Function<State, Tuple2<String, State>> m2 = bind(m1, transform.apply("1")); Function<State, Tuple2<String, State>> m3 = bind(m2, transform.apply("2")); Tuple2<String, State> result = m3.apply(new State(0)); System.out.println(result); } }
State Monad 中使用的狀態物件如清單 11 所示。State 是一個包含值 value 的不可變物件。清單 10 中的 m1 封裝了值 Hello。transform 方法用來從輸入的字串字首 prefix 中建立轉換函式。轉換函式會在字串值上新增給定的字首,同時會把字串的長度進行累加。轉換函式每次都返回一個新的 State 物件。轉換之後的結果中字串的值是 21Hello,而 State 物件中的 value 為 11,是字串 Hello 和 1Hello 的長度相加的結果。
清單 11. 狀態物件
public class State { private final int value; public State(final int value) { this.value = value; } public int getValue() { return value; } @Override public String toString() { return "State{" + "value=" + value + '}'; } }
總結
作為本系列的最後一篇文章,本文對函數語言程式設計中的重要概念 Monad 做了詳細的介紹。本文從範疇論出發,介紹了使用 Monad 描述函式副作用的動機和方式,以及 Monad 的定義。本文還對常見的幾種 Monad 進行了介紹,並添加了相應的 Java 程式碼。
參考資源
- 閱讀 ofollow,noindex" target="_blank"> Category Theory for Programmers 一書來深入理解範疇論。
- 瞭解更多關於 Monad 的內容。
- 瞭解 Java 中的 Functor 和 Monad 。
- 瞭解 Haskell 中的更多 Monad 型別 。