函數式編程

你可能據說過函數式編程(Functional programming),甚至已經使用了一段時間。javascript

可是,你能說清楚,它究竟是什麼嗎?html

網上搜索一下,你會輕鬆找到好多答案。前端

  • 與面向對象編程(Object-oriented programming)和過程式編程(Procedural programming)並列的編程範式。
  • 最主要的特徵是,函數是第一等公民
  • 強調將計算過程分解成可複用的函數,典型例子就是map方法和reduce方法組合而成 MapReduce 算法
  • 只有純的、沒有反作用的函數,纔是合格的函數。

上面這些說法都對,但還不夠,都沒有回答下面這個更深層的問題。java

爲何要這樣作?git

這就是,本文要解答的問題。我會經過最簡單的語言,幫你理解函數式編程,而且學會它那些基本寫法。算法

須要聲明的是,我不是專家,而是一個初學者,最近兩年才真正開始學習函數式編程。一直苦於看不懂各類資料,立志要寫一篇清晰易懂的教程。下面的內容確定不夠嚴密,甚至可能包含錯誤,可是我發現,像下面這樣解釋,初學者最容易懂。編程

另外,本文比較長,閱讀時請保持耐心。結尾還有 Udacity 的《前端工程師認證課程》的推廣,很是感謝他們對本文的贊助。json

1、範疇論

函數式編程的起源,是一門叫作範疇論(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、函數的合成與柯里化

函數式編程有兩個最基本的運算:合成和柯里化。

2.1 函數的合成

若是一個值要通過多個函數,才能變成另一個值,就能夠把全部中間步驟合併成一個函數,這叫作"函數的合成"(compose)。

上圖中,XY之間的變形關係是函數fYZ之間的變形關係是函數g,那麼XZ之間的關係,就是gf的合成函數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)),有一個隱藏的前提,就是fg都只能接受一個參數。若是能夠接受多個參數,好比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 

有了柯里化之後,咱們就能作到,全部函數只接受一個參數。後文的內容除非另有說明,都默認函數只有一個參數,就是所要處理的那個值。

3、函子

函數不只能夠用於同一個範疇之中值的轉換,還能夠用於將一個範疇轉成另外一個範疇。這就涉及到了函子(Functor)。

3.1 函子的概念

函子是函數式編程裏面最重要的數據類型,也是基本的運算單位和功能單位。

它首先是一種範疇,也就是說,是一個容器,包含了值和變形關係。比較特殊的是,它的變形關係能夠依次做用於每個值,將當前容器變造成另外一個容器。

上圖中,左側的圓圈就是一個函子,表示人名的範疇。外部傳入函數f,會轉成右邊表示早餐的範疇。

下面是一張更通常的圖。

上圖中,函數f完成值的轉換(ab),將它傳入函子,就能夠實現範疇的轉換(FaFb)。

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方法),各類函數就是運算符,經過接口接入容器,引起容器裏面的值的變形。

所以,學習函數式編程,實際上就是學習函子的各類運算。因爲能夠把運算方法封裝在函子裏面,因此又衍生出各類不一樣類型的函子,有多少種運算,就有多少種函子。函數式編程就變成了運用不一樣的函子,解決實際問題。

4、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) 

這就更像函數式編程了。

5、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) 

6、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 函子。

7、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)); 

8、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)。

9、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; }); } 

上面代碼中,讀取文件和打印自己都是不純的操做,可是readFileprint倒是純函數,由於它們老是返回 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,而後選取最後一行輸出。

相關文章
相關標籤/搜索
本站公眾號
   歡迎關注本站公眾號,獲取更多信息