函數語言程式設計入門教程

阮一峰發表於2017-02-22

你可能聽說過函數語言程式設計(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,然後選取最後一行輸出。

十、參考連結

(正文完)

============================

感謝你讀完了全文。下面還有一個推廣,請再花一分鐘閱讀。

去年十月,我介紹了來自矽谷的技術學習平臺優達學城(Udacity),他們推出的奈米學位

現在,他們進入中國市場快滿週年了,又有一個本地化課程釋出了。那就是由 Google 和 Github 合作製作的"前端開發工程師"認證課程。

這個課程完全是國際水準,講解深入淺出,示例豐富,貼近大公司開發實踐,幫助你牢牢掌握那些最實用的前端技術。

課程由矽谷工程師英語講授,配有全套中文字幕,以及全中文的學習輔導,還有首次引入中國的同步學習小組和導師監督服務,包含一對一的程式碼輔導。課程透過後,還能拿到 Google、Github 參與頒發的學習認證。

這門課程今天(2月22日)就開始報名了,現在就點選這裡,瞭解更多。我的讀者報名時,請使用優惠碼ruanyfFEND

最後,歡迎立即掃碼,關注優達學城(微訊號:youdaxue),跟蹤最新的 IT 線上學習和培訓資訊。

(完)

相關文章