你有必要知道的函數式編程

原文:http://www.ruanyifeng.com/blog/2017/02/fp-tutorial.html

你可能聽說過函數式編程(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)。

上圖中,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

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

三、函子

函數不僅可以用於同一個範疇之中值的轉換,還可以用於將一個範疇轉成另一個範疇。這就涉及到了函子(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方法),各種函數就是運算符,通過接口接入容器,引發容器裏面的值的變形。

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

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

上面代碼中,讀取文件和打印本身都是不純的操作,但是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,然後選取最後一行輸出。

十、參考鏈接

(正文完)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章