你可能聽說過函數語言程式設計(Functional programming),甚至已經使用了一段時間。
但是,你能說清楚,它到底是什麼嗎?
網上搜尋一下,你會輕鬆找到好多答案。
- 與物件導向程式設計(Object-oriented programming)和程式式程式設計(Procedural programming)並列的程式設計正規化。
- 最主要的特徵是,函式是第一等公民。
- 強調將計算過程分解成可複用的函式,典型例子就是
map
方法和reduce
方法組合而成 MapReduce 演算法。- 只有純的、沒有副作用的函式,才是合格的函式。
上面這些說法都對,但還不夠,都沒有回答下面這個更深層的問題。
為什麼要這樣做?
這就是,本文要解答的問題。我會透過最簡單的語言,幫你理解函數語言程式設計,並且學會它那些基本寫法。
需要宣告的是,我不是專家,而是一個初學者,最近兩年才真正開始學習函數語言程式設計。一直苦於看不懂各種資料,立志要寫一篇清晰易懂的教程。下面的內容肯定不夠嚴密,甚至可能包含錯誤,但是我發現,像下面這樣解釋,初學者最容易懂。
另外,本文比較長,閱讀時請保持耐心。結尾還有 Udacity 的《前端工程師認證課程》的推廣,非常感謝他們對本文的贊助。
一、範疇論
函數語言程式設計的起源,是一門叫做範疇論(Category Theory)的數學分支。
理解函數語言程式設計的關鍵,就是理解範疇論。它是一門很複雜的數學,認為世界上所有的概念體系,都可以抽象成一個個的"範疇"(category)。
1.1 範疇的概念
什麼是範疇呢?
維基百科的一句話定義如下。
"範疇就是使用箭頭連線的物體。"(In mathematics, a category is an algebraic structure that comprises "objects" that are linked by "arrows". )
也就是說,彼此之間存在某種關係的概念、事物、物件等等,都構成"範疇"。隨便什麼東西,只要能找出它們之間的關係,就能定義一個"範疇"。
上圖中,各個點與它們之間的箭頭,就構成一個範疇。
箭頭表示範疇成員之間的關係,正式的名稱叫做"態射"(morphism)。範疇論認為,同一個範疇的所有成員,就是不同狀態的"變形"(transformation)。透過"態射",一個成員可以變形成另一個成員。
1.2 數學模型
既然"範疇"是滿足某種變形關係的所有物件,就可以總結出它的數學模型。
- 所有成員是一個集合
- 變形關係是函式
也就是說,範疇論是集合論更上層的抽象,簡單的理解就是"集合 + 函式"。
理論上透過函式,就可以從範疇的一個成員,算出其他所有成員。
1.3 範疇與容器
我們可以把"範疇"想象成是一個容器,裡面包含兩樣東西。
- 值(value)
- 值的變形關係,也就是函式。
下面我們使用程式碼,定義一個簡單的範疇。
class Category { constructor(val) { this.val = val; } addOne(x) { return x + 1; } }
上面程式碼中,Category
是一個類,也是一個容器,裡面包含一個值(this.val
)和一種變形關係(addOne
)。你可能已經看出來了,這裡的範疇,就是所有彼此之間相差1
的數字。
注意,本文後面的部分,凡是提到"容器"的地方,全部都是指"範疇"。
1.4 範疇論與函數語言程式設計的關係
範疇論使用函式,表達範疇之間的關係。
伴隨著範疇論的發展,就發展出一整套函式的運算方法。這套方法起初只用於數學運算,後來有人將它在計算機上實現了,就變成了今天的"函數語言程式設計"。
本質上,函數語言程式設計只是範疇論的運算方法,跟數理邏輯、微積分、行列式是同一類東西,都是數學方法,只是碰巧它能用來寫程式。
所以,你明白了嗎,為什麼函數語言程式設計要求函式必須是純的,不能有副作用?因為它是一種數學運算,原始目的就是求值,不做其他事情,否則就無法滿足函式運演算法則了。
總之,在函數語言程式設計中,函式就是一個管道(pipe)。這頭進去一個值,那頭就會出來一個新的值,沒有其他作用。
二、函式的合成與柯里化
函數語言程式設計有兩個最基本的運算:合成和柯里化。
2.1 函式的合成
如果一個值要經過多個函式,才能變成另外一個值,就可以把所有中間步驟合併成一個函式,這叫做"函式的合成"(compose)。
上圖中,X
和Y
之間的變形關係是函式f
,Y
和Z
之間的變形關係是函式g
,那麼X
和Z
之間的關係,就是g
和f
的合成函式g·f
。
下面就是程式碼實現了,我使用的是 JavaScript 語言。注意,本文所有示例程式碼都是簡化過的,完整的 Demo 請看《參考連結》部分。
合成兩個函式的簡單程式碼如下。
const compose = function (f, g) { return function (x) { return f(g(x)); }; }
函式的合成還必須滿足結合律。
compose(f, compose(g, h)) // 等同於 compose(compose(f, g), h) // 等同於 compose(f, g, h)
合成也是函式必須是純的一個原因。因為一個不純的函式,怎麼跟其他函式合成?怎麼保證各種合成以後,它會達到預期的行為?
前面說過,函式就像資料的管道(pipe)。那麼,函式合成就是將這些管道連了起來,讓資料一口氣從多個管道中穿過。
2.2 柯里化
f(x)
和g(x)
合成為f(g(x))
,有一個隱藏的前提,就是f
和g
都只能接受一個引數。如果可以接受多個引數,比如f(x, y)
和g(a, b, c)
,函式合成就非常麻煩。
這時就需要函式柯里化了。所謂"柯里化",就是把一個多引數的函式,轉化為單引數函式。
// 柯里化之前 function add(x, y) { return x + y; } add(1, 2) // 3 // 柯里化之後 function addX(y) { return function (x) { return x + y; }; } addX(2)(1) // 3
有了柯里化以後,我們就能做到,所有函式只接受一個引數。後文的內容除非另有說明,都預設函式只有一個引數,就是所要處理的那個值。
三、函子
函式不僅可以用於同一個範疇之中值的轉換,還可以用於將一個範疇轉成另一個範疇。這就涉及到了函子(Functor)。
3.1 函子的概念
函子是函數語言程式設計裡面最重要的資料型別,也是基本的運算單位和功能單位。
它首先是一種範疇,也就是說,是一個容器,包含了值和變形關係。比較特殊的是,它的變形關係可以依次作用於每一個值,將當前容器變形成另一個容器。
上圖中,左側的圓圈就是一個函子,表示人名的範疇。外部傳入函式f
,會轉成右邊表示早餐的範疇。
下面是一張更一般的圖。
上圖中,函式f
完成值的轉換(a
到b
),將它傳入函子,就可以實現範疇的轉換(Fa
到Fb
)。
3.2 函子的程式碼實現
任何具有map
方法的資料結構,都可以當作函子的實現。
class Functor { constructor(val) { this.val = val; } map(f) { return new Functor(f(this.val)); } }
上面程式碼中,Functor
是一個函子,它的map
方法接受函式f
作為引數,然後返回一個新的函子,裡面包含的值是被f
處理過的(f(this.val)
)。
一般約定,函子的標誌就是容器具有map
方法。該方法將容器裡面的每一個值,對映到另一個容器。
下面是一些用法的示例。
(new Functor(2)).map(function (two) { return two + 2; }); // Functor(4) (new Functor('flamethrowers')).map(function(s) { return s.toUpperCase(); }); // Functor('FLAMETHROWERS') (new Functor('bombs')).map(_.concat(' away')).map(_.prop('length')); // Functor(10)
上面的例子說明,函數語言程式設計裡面的運算,都是透過函子完成,即運算不直接針對值,而是針對這個值的容器----函子。函子本身具有對外介面(map
方法),各種函式就是運算子,透過介面接入容器,引發容器裡面的值的變形。
因此,學習函數語言程式設計,實際上就是學習函子的各種運算。由於可以把運算方法封裝在函子裡面,所以又衍生出各種不同型別的函子,有多少種運算,就有多少種函子。函數語言程式設計就變成了運用不同的函子,解決實際問題。
四、of 方法
你可能注意到了,上面生成新的函子的時候,用了new
命令。這實在太不像函數語言程式設計了,因為new
命令是物件導向程式設計的標誌。
函數語言程式設計一般約定,函子有一個of
方法,用來生成新的容器。
下面就用of
方法替換掉new
。
Functor.of = function(val) { return new Functor(val); };
然後,前面的例子就可以改成下面這樣。
Functor.of(2).map(function (two) { return two + 2; }); // Functor(4)
這就更像函數語言程式設計了。
五、Maybe 函子
函子接受各種函式,處理容器內部的值。這裡就有一個問題,容器內部的值可能是一個空值(比如null
),而外部函式未必有處理空值的機制,如果傳入空值,很可能就會出錯。
Functor.of(null).map(function (s) { return s.toUpperCase(); }); // TypeError
上面程式碼中,函子裡面的值是null
,結果小寫變成大寫的時候就出錯了。
Maybe 函子就是為了解決這一類問題而設計的。簡單說,它的map
方法裡面設定了空值檢查。
class Maybe extends Functor { map(f) { return this.val ? Maybe.of(f(this.val)) : Maybe.of(null); } }
有了 Maybe 函子,處理空值就不會出錯了。
Maybe.of(null).map(function (s) { return s.toUpperCase(); }); // Maybe(null)
六、Either 函子
條件運算if...else
是最常見的運算之一,函數語言程式設計裡面,使用 Either 函子表達。
Either 函子內部有兩個值:左值(Left
)和右值(Right
)。右值是正常情況下使用的值,左值是右值不存在時使用的預設值。
class Either extends Functor { constructor(left, right) { this.left = left; this.right = right; } map(f) { return this.right ? Either.of(this.left, f(this.right)) : Either.of(f(this.left), this.right); } } Either.of = function (left, right) { return new Either(left, right); };
下面是用法。
var addOne = function (x) { return x + 1; }; Either.of(5, 6).map(addOne); // Either(5, 7); Either.of(1, null).map(addOne); // Either(2, null);
上面程式碼中,如果右值有值,就使用右值,否則使用左值。透過這種方式,Either 函子表達了條件運算。
Either 函子的常見用途是提供預設值。下面是一個例子。
Either .of({address: 'xxx'}, currentUser.address) .map(updateField);
上面程式碼中,如果使用者沒有提供地址,Either 函子就會使用左值的預設地址。
Either 函子的另一個用途是代替try...catch
,使用左值表示錯誤。
function parseJSON(json) { try { return Either.of(null, JSON.parse(json)); } catch (e: Error) { return Either.of(e, null); } }
上面程式碼中,左值為空,就表示沒有出錯,否則左值會包含一個錯誤物件e
。一般來說,所有可能出錯的運算,都可以返回一個 Either 函子。
七、ap 函子
函子裡面包含的值,完全可能是函式。我們可以想象這樣一種情況,一個函子的值是數值,另一個函子的值是函式。
function addTwo(x) { return x + 2; } const A = Functor.of(2); const B = Functor.of(addTwo)
上面程式碼中,函子A
內部的值是2
,函子B
內部的值是函式addTwo
。
有時,我們想讓函子B
內部的函式,可以使用函子A
內部的值進行運算。這時就需要用到 ap 函子。
ap 是 applicative(應用)的縮寫。凡是部署了ap
方法的函子,就是 ap 函子。
class Ap extends Functor { ap(F) { return Ap.of(this.val(F.val)); } }
注意,ap
方法的引數不是函式,而是另一個函子。
因此,前面例子可以寫成下面的形式。
Ap.of(addTwo).ap(Functor.of(2)) // Ap(4)
ap 函子的意義在於,對於那些多引數的函式,就可以從多個容器之中取值,實現函子的鏈式操作。
function add(x) { return function (y) { return x + y; }; } Ap.of(add).ap(Maybe.of(2)).ap(Maybe.of(3)); // Ap(5)
上面程式碼中,函式add
是柯里化以後的形式,一共需要兩個引數。透過 ap 函子,我們就可以實現從兩個容器之中取值。它還有另外一種寫法。
Ap.of(add(2)).ap(Maybe.of(3));
八、Monad 函子
函子是一個容器,可以包含任何值。函子之中再包含一個函子,也是完全合法的。但是,這樣就會出現多層巢狀的函子。
Maybe.of( Maybe.of( Maybe.of({name: 'Mulburry', number: 8402}) ) )
上面這個函子,一共有三個Maybe
巢狀。如果要取出內部的值,就要連續取三次this.val
。這當然很不方便,因此就出現了 Monad 函子。
Monad 函子的作用是,總是返回一個單層的函子。它有一個flatMap
方法,與map
方法作用相同,唯一的區別是如果生成了一個巢狀函子,它會取出後者內部的值,保證返回的永遠是一個單層的容器,不會出現巢狀的情況。
class Monad extends Functor { join() { return this.val; } flatMap(f) { return this.map(f).join(); } }
上面程式碼中,如果函式f
返回的是一個函子,那麼this.map(f)
就會生成一個巢狀的函子。所以,join
方法保證了flatMap
方法總是返回一個單層的函子。這意味著巢狀的函子會被鋪平(flatten)。
九、IO 操作
Monad 函子的重要應用,就是實現 I/O (輸入輸出)操作。
I/O 是不純的操作,普通的函數語言程式設計沒法做,這時就需要把 IO 操作寫成Monad
函子,透過它來完成。
var fs = require('fs'); var readFile = function(filename) { return new IO(function() { return fs.readFileSync(filename, 'utf-8'); }); }; var print = function(x) { return new IO(function() { console.log(x); return x; }); }
上面程式碼中,讀取檔案和列印本身都是不純的操作,但是readFile
和print
卻是純函式,因為它們總是返回 IO 函子。
如果 IO 函子是一個Monad
,具有flatMap
方法,那麼我們就可以像下面這樣呼叫這兩個函式。
readFile('./user.txt') .flatMap(print)
這就是神奇的地方,上面的程式碼完成了不純的操作,但是因為flatMap
返回的還是一個 IO 函子,所以這個表示式是純的。我們透過一個純的表示式,完成帶有副作用的操作,這就是 Monad 的作用。
由於返回還是 IO 函子,所以可以實現鏈式操作。因此,在大多數庫裡面,flatMap
方法被改名成chain
。
var tail = function(x) { return new IO(function() { return x[x.length - 1]; }); } readFile('./user.txt') .flatMap(tail) .flatMap(print) // 等同於 readFile('./user.txt') .chain(tail) .chain(print)
上面程式碼讀取了檔案user.txt
,然後選取最後一行輸出。
十、參考連結
- JS 函數語言程式設計指南
- Taking Things Out of Context: Functors in JavaScript
- Functor.js
- Maybe, Either & Try Functors in ES6
- Why Category Theory Matters
(正文完)
============================
感謝你讀完了全文。下面還有一個推廣,請再花一分鐘閱讀。
去年十月,我介紹了來自矽谷的技術學習平臺優達學城(Udacity),他們推出的奈米學位。
現在,他們進入中國市場快滿週年了,又有一個本地化課程釋出了。那就是由 Google 和 Github 合作製作的"前端開發工程師"認證課程。
這個課程完全是國際水準,講解深入淺出,示例豐富,貼近大公司開發實踐,幫助你牢牢掌握那些最實用的前端技術。
課程由矽谷工程師英語講授,配有全套中文字幕,以及全中文的學習輔導,還有首次引入中國的同步學習小組和導師監督服務,包含一對一的程式碼輔導。課程透過後,還能拿到 Google、Github 參與頒發的學習認證。
這門課程今天(2月22日)就開始報名了,現在就點選這裡,瞭解更多。我的讀者報名時,請使用優惠碼ruanyfFEND
。
最後,歡迎立即掃碼,關注優達學城(微訊號:youdaxue),跟蹤最新的 IT 線上學習和培訓資訊。
(完)