
探索函數式程式設計,通過它讓你的程式更具有可讀性和易于調試
當 Brendan Eich 在 1995 年創造 JavaScript 時,他原本打算将 Scheme 移植到浏覽器裡 。Scheme 作為 Lisp 的方言,是一種函數式程式設計語言。而當 Eich 被告知新的語言應該是一種可以與 Java 相比的腳本語言後,他最終确立了一種擁有 C 風格文法的語言(也和 Java 一樣),但将函數視作一等公民。而 Java 直到版本 8 才從技術上将函數視為一等公民,雖然你可以用匿名類來模拟它。這個特性允許 JavaScript 通過函數式範式程式設計。
JavaScript 是一個多範式語言,允許你自由地混合和使用面向對象式、過程式和函數式的程式設計範式。最近,函數式程式設計越來越火熱。在諸如 Angular 和 React 這樣的架構中,通過使用不可變資料結構可以切實提高性能。不可變是函數式程式設計的核心原則,它以及純函數使得編寫和調試程式變得更加容易。使用函數來代替程式的循環可以提高程式的可讀性并使它更加優雅。總之,函數式程式設計擁有很多優點。
什麼不是函數式程式設計
在讨論什麼是函數式程式設計前,讓我們先排除那些不屬于函數式程式設計的東西。實際上它們是你需要丢棄的語言元件(再見,老朋友):
循環:
while
do...while
for
for...of
for...in
用 var 或者 let 來聲明變量
沒有傳回值的函數
改變對象的屬性 (比如: o.x = 5;)
改變數組本身的方法:
copyWithin
fill
pop
push
reverse
shift
sort
splice
unshift
改變映射本身的方法:
clear
delete
set
改變集合本身的方法:
add
脫離這些特性應該如何編寫程式呢?這是我們将在後面探索的問題。
純函數
你的程式中包含函數不一定意味着你正在進行函數式程式設計。函數式範式将純函數pure function和非純函數impure function區分開。鼓勵你編寫純函數。純函數必須滿足下面的兩個屬性:
引用透明:函數在傳入相同的參數後永遠傳回相同的傳回值。這意味着該函數不依賴于任何可變狀态。
無副作用:函數不能導緻任何副作用。副作用可能包括 I/O(比如向終端或者日志檔案寫入),改變一個不可變的對象,對變量重新指派等等。
我們來看一些例子。首先,multiply 就是一個純函數的例子,它在傳入相同的參數後永遠傳回相同的傳回值,并且不會導緻副作用。
function multiply(a, b) { return a * b;}
下面是非純函數的例子。canRide 函數依賴捕獲的 heightRequirement 變量。被捕獲的變量不一定導緻一個函數是非純函數,除非它是一個可變的變量(或者可以被重新指派)。這種情況下使用 let 來聲明這個變量,意味着可以對它重新指派。multiply 函數是非純函數,因為它會導緻在 console 上輸出。
let heightRequirement = 46;
// Impure because it relies on a mutable (reassignable) variable.
function canRide(height) {
return height >= heightRequirement;
}
// Impure because it causes a side-effect by logging to the console.
function multiply(a, b) {
console.log('Arguments: ', a, b);
return a * b;
下面的清單包含着 JavaScript 内置的非純函數。你可以指出它們不滿足兩個屬性中的哪個嗎?
console.log
element.addEventListener
Math.random
Date.now
$.ajax (這裡 $ 代表你使用的 Ajax 庫)
理想的程式中所有的函數都是純函數,但是從上面的函數清單可以看出,任何有意義的程式都将包含非純函數。大多時候我們需要進行 AJAX 調用,檢查目前日期或者擷取一個随機數。一個好的經驗法則是遵循 80/20 規則:函數中有 80% 應該是純函數,剩下的 20% 的必要性将不可避免地是非純函數。
使用純函數有幾個優點:
它們很容易導出和調試,因為它們不依賴于可變的狀态。
傳回值可以被緩存或者“記憶”來避免以後重複計算。
它們很容易測試,因為沒有需要模拟(mock)的依賴(比如日志,AJAX,資料庫等等)。
你編寫或者使用的函數傳回空(換句話說它沒有傳回值),那代表它是非純函數。
不變性
讓我們回到捕獲變量的概念上。來看看 canRide 函數。我們認為它是一個非純函數,因為 heightRequirement 變量可以被重新指派。下面是一個構造出來的例子來說明如何用不可預測的值來對它重新指派。
// Every half second, set heightRequirement to a random number between 0 and 200.
setInterval(() => heightRequirement = Math.floor(Math.random() * 201), 500);
const mySonsHeight = 47;
// Every half second, check if my son can ride.
// Sometimes it will be true and sometimes it will be false.
setInterval(() => console.log(canRide(mySonsHeight)), 500);
我要再次強調被捕獲的變量不一定會使函數成為非純函數。我們可以通過隻是簡單地改變 heightRequirement 的聲明方式來使 canRide 函數成為純函數。
const heightRequirement = 46;
通過用 const 來聲明變量意味着它不能被再次指派。如果嘗試對它重新指派,運作時引擎将抛出錯誤;那麼,如果用對象來代替數字來存儲所有的“常量”怎麼樣?
const constants = {
heightRequirement: 46,
// ... other constants go here
};
return height >= constants.heightRequirement;
我們用了 const ,是以這個變量不能被重新指派,但是還有一個問題:這個對象可以被改變。下面的代碼展示了,為了真正使其不可變,你不僅需要防止它被重新指派,你也需要不可變的資料結構。JavaScript 語言提供了 Object.freeze 方法來阻止對象被改變。
'use strict';
// CASE 1: 對象的屬性是可變的,并且變量可以被再次指派。
let o1 = { foo: 'bar' };
// 改變對象的屬性
o1.foo = 'something different';
// 對變量再次指派
o1 = { message: "I'm a completely new object" };
// CASE 2: 對象的屬性還是可變的,但是變量不能被再次指派。
const o2 = { foo: 'baz' };
// 仍然能改變對象
o2.foo = 'Something different, yet again';
// 不能對變量再次指派
// o2 = { message: 'I will cause an error if you uncomment me' }; // Error!
// CASE 3: 對象的屬性是不可變的,但是變量可以被再次指派。
let o3 = Object.freeze({ foo: "Can't mutate me" });
// 不能改變對象的屬性
// o3.foo = 'Come on, uncomment me. I dare ya!'; // Error!
// 還是可以對變量再次指派
o3 = { message: "I'm some other object, and I'm even mutable -- so take that!" };
// CASE 4: 對象的屬性是不可變的,并且變量不能被再次指派。這是我們想要的!!!!!!!!
const o4 = Object.freeze({ foo: 'never going to change me' });
// o4.foo = 'talk to the hand' // Error!
// o4 = { message: "ain't gonna happen, sorry" }; // Error
不變性适用于所有的資料結構,包括數組、映射和集合。它意味着不能調用例如 Array.prototype.push 等會導緻本身改變的方法,因為它會改變已經存在的數組。可以通過建立一個含有原來元素和新加元素的新數組,而不是将新元素加入一個已經存在的數組。其實所有會導緻數組本身被修改的方法都可以通過一個傳回修改好的新數組的函數代替。
const a = Object.freeze([4, 5, 6]);
// Instead of: a.push(7, 8, 9);
const b = a.concat(7, 8, 9);
// Instead of: a.pop();
const c = a.slice(0, -1);
// Instead of: a.unshift(1, 2, 3);
const d = [1, 2, 3].concat(a);
// Instead of: a.shift();
const e = a.slice(1);
// Instead of: a.sort(myCompareFunction);
const f = R.sort(myCompareFunction, a); // R = Ramda
// Instead of: a.reverse();
const g = R.reverse(a); // R = Ramda
// 留給讀者的練習:
// copyWithin
// fill
// splice
映射 和 集合 也很相似。可以通過傳回一個新的修改好的映射或者集合來代替使用會修改其本身的函數。
const map = new Map([
[1, 'one'],
[2, 'two'],
[3, 'three']
]);
// Instead of: map.set(4, 'four');
const map2 = new Map([...map, [4, 'four']]);
// Instead of: map.delete(1);
const map3 = new Map([...map].filter(([key]) => key !== 1));
// Instead of: map.clear();
const map4 = new Map();
const set = new Set(['A', 'B', 'C']);
// Instead of: set.add('D');
const set2 = new Set([...set, 'D']);
// Instead of: set.delete('B');
const set3 = new Set([...set].filter(key => key !== 'B'));
// Instead of: set.clear();
const set4 = new Set();
我想提一句如果你在使用 TypeScript(我非常喜歡 TypeScript),你可以用 Readonly<T>、ReadonlyArray<T>、ReadonlyMap<K, V> 和 ReadonlySet<T> 接口來在編譯期檢查你是否嘗試更改這些對象,有則抛出編譯錯誤。如果在對一個對象字面量或者數組調用 Object.freeze,編譯器會自動推斷它是隻讀的。由于映射和集合在其内部表達,是以在這些資料結構上調用 Object.freeze 不起作用。但是你可以輕松地告訴編譯器它們是隻讀的變量。
TypeScript 隻讀接口
好,是以我們可以通過建立新的對象來代替修改原來的對象,但是這樣不會導緻性能損失嗎?當然會。確定在你自己的應用中做了性能測試。如果你需要提高性能,可以考慮使用 Immutable.js。Immutable.js 用持久的資料結構 實作了連結清單、堆棧、映射、集合和其他資料結構。使用了如同 Clojure 和 Scala 這樣的函數式語言中相同的技術。
// Use in place of `[]`.
const list1 = Immutable.List(['A', 'B', 'C']);
const list2 = list1.push('D', 'E');
console.log([...list1]); // ['A', 'B', 'C']
console.log([...list2]); // ['A', 'B', 'C', 'D', 'E']
// Use in place of `new Map()`
const map1 = Immutable.Map([
['one', 1],
['two', 2],
['three', 3]
const map2 = map1.set('four', 4);
console.log([...map1]); // [['one', 1], ['two', 2], ['three', 3]]
console.log([...map2]); // [['one', 1], ['two', 2], ['three', 3], ['four', 4]]
// Use in place of `new Set()`
const set1 = Immutable.Set([1, 2, 3, 3, 3, 3, 3, 4]);
const set2 = set1.add(5);
console.log([...set1]); // [1, 2, 3, 4]
console.log([...set2]); // [1, 2, 3, 4, 5]
函數組合
記不記得在中學時我們學過一些像 (f ° g)(x) 的東西?你那時可能想,“我什麼時候會用到這些?”,好了,現在就用到了。你準備好了嗎?f ° g讀作 “函數 f 和函數 g 組合”。對它的了解有兩種等價的方式,如等式所示: (f ° g)(x) = f(g(x))。你可以認為 f ° g 是一個單獨的函數,或者視作将調用函數 g 的結果作為參數傳給函數 f。注意這些函數是從右向左依次調用的,先執行 g,接下來執行 f。
關于函數組合的幾個要點:
我們可以組合任意數量的函數(不僅限于 2 個)。
組合函數的一個方式是簡單地把一個函數的輸出作為下一個函數的輸入(比如 f(g(x)))。
// h(x) = x + 1
// number -> number
function h(x) {
return x + 1;
// g(x) = x^2
function g(x) {
return x * x;
// f(x) = convert x to string
// number -> string
function f(x) {
return x.toString();
// y = (f ° g ° h)(1)
const y = f(g(h(1)));
console.log(y); // '4'
Ramda 和 lodash 之類的庫提供了更優雅的方式來組合函數。我們可以在更多的在數學意義上處理函數組合,而不是簡單地将一個函數的傳回值傳遞給下一個函數。我們可以建立一個由這些函數組成的單一複合函數(就是 (f ° g)(x))。
// R = Ramda
// composite = (f ° g ° h)
const composite = R.compose(f, g, h);
// Execute single function to get the result.
const y = composite(1);
好了,我們可以在 JavaScript 中組合函數了。接下來呢?好,如果你已經入門了函數式程式設計,理想中你的程式将隻有函數的組合。代碼裡沒有循環(for, for...of, for...in, while, do),基本沒有。你可能覺得那是不可能的。并不是這樣。我們下面的兩個話題是:遞歸和高階函數。
遞歸
假設你想實作一個計算數字的階乘的函數。 讓我們回顧一下數學中階乘的定義:
n! = n * (n-1) * (n-2) * ... * 1.
n! 是從 n 到 1 的所有整數的乘積。我們可以編寫一個循環輕松地計算出結果。
function iterativeFactorial(n) {
let product = 1;
for (let i = 1; i <= n; i++) {
product *= i;
}
return product;
注意 product 和 i 都在循環中被反複重新指派。這是解決這個問題的标準過程式方法。如何用函數式的方法解決這個問題呢?我們需要消除循環,確定沒有變量被重新指派。遞歸是函數式程式員的最有力的工具之一。遞歸需要我們将整體問題分解為類似整體問題的子問題。
計算階乘是一個很好的例子,為了計算 n! 我們需要将 n 乘以所有比它小的正整數。它的意思就相當于:
n! = n * (n-1)!
啊哈!我們發現了一個解決 (n-1)! 的子問題,它類似于整個問題 n!。還有一個需要注意的地方就是基礎條件。基礎條件告訴我們何時停止遞歸。 如果我們沒有基礎條件,那麼遞歸将永遠持續。 實際上,如果有太多的遞歸調用,程式會抛出一個堆棧溢出錯誤。啊哈!
function recursiveFactorial(n) {
// Base case -- stop the recursion
if (n === 0) {
return 1; // 0! is defined to be 1.
return n * recursiveFactorial(n - 1);
然後我們來計算 recursiveFactorial(20000) 因為……,為什麼不呢?當我們這樣做的時候,我們得到了這個結果:
堆棧溢出錯誤
這裡發生了什麼?我們得到一個堆棧溢出錯誤!這不是無窮的遞歸導緻的。我們已經處理了基礎條件(n === 0 的情況)。那是因為浏覽器的堆棧大小是有限的,而我們的代碼使用了越過了這個大小的堆棧。每次對 recursiveFactorial 的調用導緻了新的幀被壓入堆棧中,就像一個盒子壓在另一個盒子上。每當 recursiveFactorial 被調用,一個新的盒子被放在最上面。下圖展示了在計算 recursiveFactorial(3) 時堆棧的樣子。注意在真實的堆棧中,堆棧頂部的幀将存儲在執行完成後應該傳回的記憶體位址,但是我選擇用變量 r 來表示傳回值,因為 JavaScript 開發者一般不需要考慮記憶體位址。
遞歸計算 3! 的堆棧(三次乘法)
你可能會想象當計算 n = 20000 時堆棧會更高。我們可以做些什麼優化它嗎?當然可以。作為 ES2015 (又名 ES6) 标準的一部分,有一個優化用來解決這個問題。它被稱作尾調用優化proper tail calls optimization(PTC)。當遞歸函數做的最後一件事是調用自己并傳回結果的時候,它使得浏覽器删除或者忽略堆棧幀。實際上,這個優化對于互相遞歸函數也是有效的,但是為了簡單起見,我們還是來看單一遞歸函數。
你可能會注意到,在遞歸函數調用之後,還要進行一次額外的計算(n * r)。那意味着浏覽器不能通過 PTC 來優化遞歸;然而,我們可以通過重寫函數使最後一步變成遞歸調用以便優化。一個竅門是将中間結果(在這裡是 product)作為參數傳遞給函數。
// Optimized for tail call optimization.
function factorial(n, product = 1) {
return product;
return factorial(n - 1, product * n)
讓我們來看看優化後的計算 factorial(3) 時的堆棧。如下圖所示,堆棧不會增長到超過兩層。原因是我們把必要的資訊都傳到了遞歸函數中(比如 product)。是以,在 product 被更新後,浏覽器可以丢棄掉堆棧中原先的幀。你可以在圖中看到每次最上面的幀下沉變成了底部的幀,原先底部的幀被丢棄,因為不再需要它了。
遞歸計算 3! 的堆棧(三次乘法)使用 PTC
現在選一個浏覽器運作吧,假設你在使用 Safari,你會得到 Infinity(它是比在 JavaScript 中能表達的最大值更大的數)。但是我們沒有得到堆棧溢出錯誤,那很不錯!現在在其他的浏覽器中呢怎麼樣呢?Safari 可能現在乃至将來是實作 PTC 的唯一一個浏覽器。看看下面的相容性表格:
PTC 相容性
其他浏覽器提出了一種被稱作文法級尾調用syntactic tail calls(STC)的競争标準。“文法級”意味着你需要用新的文法來辨別你想要執行尾遞歸優化的函數。即使浏覽器還沒有廣泛支援,但是把你的遞歸函數寫成支援尾遞歸優化的樣子還是一個好主意。
高階函數
我們已經知道 JavaScript 将函數視作一等公民,可以把函數像其他值一樣傳遞。是以,把一個函數傳給另一個函數也很常見。我們也可以讓函數傳回一個函數。就是它!我們有高階函數。你可能已經很熟悉幾個在 Array.prototype 中的高階函數。比如 filter、map 和 reduce 就在其中。對高階函數的一種了解是:它是接受(一般會調用)一個回調函數參數的函數。讓我們來看看一些内置的高階函數的例子:
const vehicles = [
{ make: 'Honda', model: 'CR-V', type: 'suv', price: 24045 },
{ make: 'Honda', model: 'Accord', type: 'sedan', price: 22455 },
{ make: 'Mazda', model: 'Mazda 6', type: 'sedan', price: 24195 },
{ make: 'Mazda', model: 'CX-9', type: 'suv', price: 31520 },
{ make: 'Toyota', model: '4Runner', type: 'suv', price: 34210 },
{ make: 'Toyota', model: 'Sequoia', type: 'suv', price: 45560 },
{ make: 'Toyota', model: 'Tacoma', type: 'truck', price: 24320 },
{ make: 'Ford', model: 'F-150', type: 'truck', price: 27110 },
{ make: 'Ford', model: 'Fusion', type: 'sedan', price: 22120 },
{ make: 'Ford', model: 'Explorer', type: 'suv', price: 31660 }
];
const averageSUVPrice = vehicles
.filter(v => v.type === 'suv')
.map(v => v.price)
.reduce((sum, price, i, array) => sum + price / array.length, 0);
console.log(averageSUVPrice); // 33399
注意我們在一個數組對象上調用其方法,這是面向對象程式設計的特性。如果我們想要更函數式一些,我們可以用 Rmmda 或者 lodash/fp 提供的函數。注意如果我們使用 R.compose 的話,需要倒轉函數的順序,因為它從右向左依次調用函數(從底向上);然而,如果我們想從左向右調用函數就像上面的例子,我們可以用 R.pipe。下面兩個例子用了 Rmmda。注意 Rmmda 有一個 mean 函數用來代替 reduce 。
// Using `pipe` executes the functions from top-to-bottom.
const averageSUVPrice1 = R.pipe(
R.filter(v => v.type === 'suv'),
R.map(v => v.price),
R.mean
)(vehicles);
console.log(averageSUVPrice1); // 33399
// Using `compose` executes the functions from bottom-to-top.
const averageSUVPrice2 = R.compose(
R.mean,
R.filter(v => v.type === 'suv')
console.log(averageSUVPrice2); // 33399
使用函數式方法的優點是清楚地分開了資料(vehicles)和邏輯(函數 filter,map 和 reduce)。面向對象的代碼相比之下把資料和函數用以方法的對象的形式混合在了一起。
柯裡化
不規範地說,柯裡化currying是把一個接受 n 個參數的函數變成 n 個每個接受單個參數的函數的過程。函數的 arity 是它接受參數的個數。接受一個參數的函數是 unary,兩個的是 binary,三個的是 ternary,n 個的是 n-ary。那麼,我們可以把柯裡化定義成将一個 n-ary 函數轉換成 n 個 unary 函數的過程。讓我們通過簡單的例子開始,一個計算兩個向量點積的函數。回憶一下線性代數,兩個向量 [a, b, c] 和 [x, y, z] 的點積是 ax + by + cz。
function dot(vector1, vector2) {
return vector1.reduce((sum, element, index) => sum += element * vector2[index], 0);
const v1 = [1, 3, -5];
const v2 = [4, -2, -1];
console.log(dot(v1, v2)); // 1(4) + 3(-2) + (-5)(-1) = 4 - 6 + 5 = 3
dot 函數是 binary,因為它接受兩個參數;然而我們可以将它手動轉換成兩個 unary 函數,就像下面的例子。注意 curriedDot 是一個 unary 函數,它接受一個向量并傳回另一個接受第二個向量的 unary 函數。
function curriedDot(vector1) {
return function(vector2) {
return vector1.reduce((sum, element, index) => sum += element * vector2[index], 0);
// Taking the dot product of any vector with [1, 1, 1]
// is equivalent to summing up the elements of the other vector.
const sumElements = curriedDot([1, 1, 1]);
console.log(sumElements([1, 3, -5])); // -1
console.log(sumElements([4, -2, -1])); // 1
很幸運,我們不需要把每一個函數都手動轉換成柯裡化以後的形式。Ramda 和 lodash 等庫可以為我們做這些工作。實際上,它們是柯裡化的混合形式。你既可以每次傳遞一個參數,也可以像原來一樣一次傳遞所有參數。
// Use Ramda to do the currying for us!
const curriedDot = R.curry(dot);
console.log(sumElements(v1)); // -1
console.log(sumElements(v2)); // 1
// This works! You can still call the curried function with two arguments.
console.log(curriedDot(v1, v2)); // 3
Ramda 和 lodash 都允許你“跳過”一些變量之後再指定它們。它們使用置位符來做這些工作。因為點積的計算可以交換兩項。傳入向量的順序不影響結果。讓我們換一個例子來闡述如何使用一個置位符。Ramda 使用雙下劃線作為其置位符。
const giveMe3 = R.curry(function(item1, item2, item3) {
return `
1: ${item1}
2: ${item2}
3: ${item3}
`;
});
const giveMe2 = giveMe3(R.__, R.__, 'French Hens'); // Specify the third argument.
const giveMe1 = giveMe2('Partridge in a Pear Tree'); // This will go in the first slot.
const result = giveMe1('Turtle Doves'); // Finally fill in the second argument.
console.log(result);
// 1: Partridge in a Pear Tree
// 2: Turtle Doves
// 3: French Hens
在我們結束探讨柯裡化之前最後的議題是偏函數應用partial application。偏函數應用和柯裡化經常同時出場,盡管它們實際上是不同的概念。一個柯裡化的函數還是柯裡化的函數,即使沒有給它任何參數。偏函數應用,另一方面是僅僅給一個函數傳遞部分參數而不是所有參數。柯裡化是偏函數應用常用的方法之一,但是不是唯一的。
JavaScript 擁有一個内置機制可以不依靠柯裡化來做偏函數應用。那就是 function.prototype.bind 方法。這個方法的一個特殊之處在于,它要求你将 this 作為第一個參數傳入。 如果你不進行面向對象程式設計,那麼你可以通過傳入 null 來忽略 this。
1function giveMe3(item1, item2, item3) {
const giveMe2 = giveMe3.bind(null, 'rock');
const giveMe1 = giveMe2.bind(null, 'paper');
const result = giveMe1('scissors');
// 1: rock
// 2: paper
// 3: scissors
總結
我希望你享受探索 JavaScript 中函數式程式設計的過程。對一些人來說,它可能是一個全新的程式設計範式,但我希望你能嘗試它。你會發現你的程式更易于閱讀和調試。不變性還将允許你優化 Angular 和 React 的性能。
原文釋出時間為:2017-10-26
本文作者:佚名
本文來自雲栖社群合作夥伴“51CTO”,了解相關資訊可以關注。