JavaScript的函數(shù)式編程,你了解嗎?
探索函數(shù)式編程,通過它讓你的程序更具有可讀性和易于調(diào)試
當(dāng) Brendan Eich 在 1995 年創(chuàng)造 JavaScript 時,他原本打算將 Scheme 移植到瀏覽器里 。Scheme 作為 Lisp 的方言,是一種函數(shù)式編程語言。而當(dāng) Eich 被告知新的語言應(yīng)該是一種可以與 Java 相比的腳本語言后,他最終確立了一種擁有 C 風(fēng)格語法的語言(也和 Java 一樣),但將函數(shù)視作一等公民。而 Java 直到版本 8 才從技術(shù)上將函數(shù)視為一等公民,雖然你可以用匿名類來模擬它。這個特性允許 JavaScript 通過函數(shù)式范式編程。
JavaScript 是一個多范式語言,允許你自由地混合和使用面向?qū)ο笫?、過程式和函數(shù)式的編程范式。最近,函數(shù)式編程越來越火熱。在諸如 Angular 和 React 這樣的框架中,通過使用不可變數(shù)據(jù)結(jié)構(gòu)可以切實提高性能。不可變是函數(shù)式編程的核心原則,它以及純函數(shù)使得編寫和調(diào)試程序變得更加容易。使用函數(shù)來代替程序的循環(huán)可以提高程序的可讀性并使它更加優(yōu)雅??傊?,函數(shù)式編程擁有很多優(yōu)點。
什么不是函數(shù)式編程
在討論什么是函數(shù)式編程前,讓我們先排除那些不屬于函數(shù)式編程的東西。實際上它們是你需要丟棄的語言組件(再見,老朋友):
- 循環(huán):
- while
- do...while
- for
- for...of
- for...in
- 用 var 或者 let 來聲明變量
- 沒有返回值的函數(shù)
- 改變對象的屬性 (比如: o.x = 5;)
- 改變數(shù)組本身的方法:
- copyWithin
- fill
- pop
- push
- reverse
- shift
- sort
- splice
- unshift
- 改變映射本身的方法:
- clear
- delete
- set
- 改變集合本身的方法:
- add
- clear
- delete
脫離這些特性應(yīng)該如何編寫程序呢?這是我們將在后面探索的問題。
純函數(shù)
你的程序中包含函數(shù)不一定意味著你正在進(jìn)行函數(shù)式編程。函數(shù)式范式將純函數(shù)pure function和非純函數(shù)impure function區(qū)分開。鼓勵你編寫純函數(shù)。純函數(shù)必須滿足下面的兩個屬性:
- 引用透明:函數(shù)在傳入相同的參數(shù)后永遠(yuǎn)返回相同的返回值。這意味著該函數(shù)不依賴于任何可變狀態(tài)。
- 無副作用:函數(shù)不能導(dǎo)致任何副作用。副作用可能包括 I/O(比如向終端或者日志文件寫入),改變一個不可變的對象,對變量重新賦值等等。
我們來看一些例子。首先,multiply 就是一個純函數(shù)的例子,它在傳入相同的參數(shù)后永遠(yuǎn)返回相同的返回值,并且不會導(dǎo)致副作用。
- function multiply(a, b) { return a * b;}
下面是非純函數(shù)的例子。canRide 函數(shù)依賴捕獲的 heightRequirement 變量。被捕獲的變量不一定導(dǎo)致一個函數(shù)是非純函數(shù),除非它是一個可變的變量(或者可以被重新賦值)。這種情況下使用 let 來聲明這個變量,意味著可以對它重新賦值。multiply 函數(shù)是非純函數(shù),因為它會導(dǎo)致在 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 內(nèi)置的非純函數(shù)。你可以指出它們不滿足兩個屬性中的哪個嗎?
- console.log
- element.addEventListener
- Math.random
- Date.now
- $.ajax (這里 $ 代表你使用的 Ajax 庫)
理想的程序中所有的函數(shù)都是純函數(shù),但是從上面的函數(shù)列表可以看出,任何有意義的程序都將包含非純函數(shù)。大多時候我們需要進(jìn)行 AJAX 調(diào)用,檢查當(dāng)前日期或者獲取一個隨機(jī)數(shù)。一個好的經(jīng)驗法則是遵循 80/20 規(guī)則:函數(shù)中有 80% 應(yīng)該是純函數(shù),剩下的 20% 的必要性將不可避免地是非純函數(shù)。
使用純函數(shù)有幾個優(yōu)點:
- 它們很容易導(dǎo)出和調(diào)試,因為它們不依賴于可變的狀態(tài)。
- 返回值可以被緩存或者“記憶”來避免以后重復(fù)計算。
- 它們很容易測試,因為沒有需要模擬(mock)的依賴(比如日志,AJAX,數(shù)據(jù)庫等等)。
你編寫或者使用的函數(shù)返回空(換句話說它沒有返回值),那代表它是非純函數(shù)。
不變性
讓我們回到捕獲變量的概念上。來看看 canRide 函數(shù)。我們認(rèn)為它是一個非純函數(shù),因為 heightRequirement 變量可以被重新賦值。下面是一個構(gòu)造出來的例子來說明如何用不可預(yù)測的值來對它重新賦值。
- let heightRequirement = 46;
- function canRide(height) {
- return height >= 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);
我要再次強(qiáng)調(diào)被捕獲的變量不一定會使函數(shù)成為非純函數(shù)。我們可以通過只是簡單地改變 heightRequirement 的聲明方式來使 canRide 函數(shù)成為純函數(shù)。
- const heightRequirement = 46;
- function canRide(height) {
- return height >= heightRequirement;
- }
通過用 const 來聲明變量意味著它不能被再次賦值。如果嘗試對它重新賦值,運行時引擎將拋出錯誤;那么,如果用對象來代替數(shù)字來存儲所有的“常量”怎么樣?
- const constants = {
- heightRequirement: 46,
- // ... other constants go here
- };
- function canRide(height) {
- return height >= constants.heightRequirement;
- }
我們用了 const ,所以這個變量不能被重新賦值,但是還有一個問題:這個對象可以被改變。下面的代碼展示了,為了真正使其不可變,你不僅需要防止它被重新賦值,你也需要不可變的數(shù)據(jù)結(jié)構(gòu)。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
不變性適用于所有的數(shù)據(jù)結(jié)構(gòu),包括數(shù)組、映射和集合。它意味著不能調(diào)用例如 Array.prototype.push 等會導(dǎo)致本身改變的方法,因為它會改變已經(jīng)存在的數(shù)組??梢酝ㄟ^創(chuàng)建一個含有原來元素和新加元素的新數(shù)組,而不是將新元素加入一個已經(jīng)存在的數(shù)組。其實所有會導(dǎo)致數(shù)組本身被修改的方法都可以通過一個返回修改好的新數(shù)組的函數(shù)代替。
- 'use strict';
- 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
- // 留給讀者的練習(xí):
- // copyWithin
- // fill
- // splice
映射 和 集合 也很相似。可以通過返回一個新的修改好的映射或者集合來代替使用會修改其本身的函數(shù)。
- 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> 接口來在編譯期檢查你是否嘗試更改這些對象,有則拋出編譯錯誤。如果在對一個對象字面量或者數(shù)組調(diào)用 Object.freeze,編譯器會自動推斷它是只讀的。由于映射和集合在其內(nèi)部表達(dá),所以在這些數(shù)據(jù)結(jié)構(gòu)上調(diào)用 Object.freeze 不起作用。但是你可以輕松地告訴編譯器它們是只讀的變量。
TypeScript 只讀接口
好,所以我們可以通過創(chuàng)建新的對象來代替修改原來的對象,但是這樣不會導(dǎo)致性能損失嗎?當(dāng)然會。確保在你自己的應(yīng)用中做了性能測試。如果你需要提高性能,可以考慮使用 Immutable.js。Immutable.js 用持久的數(shù)據(jù)結(jié)構(gòu) 實現(xiàn)了鏈表、堆棧、映射、集合和其他數(shù)據(jù)結(jié)構(gòu)。使用了如同 Clojure 和 Scala 這樣的函數(shù)式語言中相同的技術(shù)。
- // 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]
函數(shù)組合
記不記得在中學(xué)時我們學(xué)過一些像 (f ° g)(x) 的東西?你那時可能想,“我什么時候會用到這些?”,好了,現(xiàn)在就用到了。你準(zhǔn)備好了嗎?f ° g讀作 “函數(shù) f 和函數(shù) g 組合”。對它的理解有兩種等價的方式,如等式所示: (f ° g)(x) = f(g(x))。你可以認(rèn)為 f ° g 是一個單獨的函數(shù),或者視作將調(diào)用函數(shù) g 的結(jié)果作為參數(shù)傳給函數(shù) f。注意這些函數(shù)是從右向左依次調(diào)用的,先執(zhí)行 g,接下來執(zhí)行 f。
關(guān)于函數(shù)組合的幾個要點:
- 我們可以組合任意數(shù)量的函數(shù)(不僅限于 2 個)。
- 組合函數(shù)的一個方式是簡單地把一個函數(shù)的輸出作為下一個函數(shù)的輸入(比如 f(g(x)))。
- // h(x) = x + 1
- // number -> number
- function h(x) {
- return x + 1;
- }
- // g(x) = x^2
- // number -> number
- 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 之類的庫提供了更優(yōu)雅的方式來組合函數(shù)。我們可以在更多的在數(shù)學(xué)意義上處理函數(shù)組合,而不是簡單地將一個函數(shù)的返回值傳遞給下一個函數(shù)。我們可以創(chuàng)建一個由這些函數(shù)組成的單一復(fù)合函數(shù)(就是 (f ° g)(x))。
- // h(x) = x + 1
- // number -> number
- function h(x) {
- return x + 1;
- }
- // g(x) = x^2
- // number -> number
- function g(x) {
- return x * x;
- }
- // f(x) = convert x to string
- // number -> string
- function f(x) {
- return x.toString();
- }
- // R = Ramda
- // composite = (f ° g ° h)
- const composite = R.compose(f, g, h);
- // Execute single function to get the result.
- const y = composite(1);
- console.log(y); // '4'
好了,我們可以在 JavaScript 中組合函數(shù)了。接下來呢?好,如果你已經(jīng)入門了函數(shù)式編程,理想中你的程序?qū)⒅挥泻瘮?shù)的組合。代碼里沒有循環(huán)(for, for...of, for...in, while, do),基本沒有。你可能覺得那是不可能的。并不是這樣。我們下面的兩個話題是:遞歸和高階函數(shù)。
遞歸
假設(shè)你想實現(xiàn)一個計算數(shù)字的階乘的函數(shù)。 讓我們回顧一下數(shù)學(xué)中階乘的定義:
n! = n * (n-1) * (n-2) * ... * 1.
n! 是從 n 到 1 的所有整數(shù)的乘積。我們可以編寫一個循環(huán)輕松地計算出結(jié)果。
- function iterativeFactorial(n) {
- let product = 1;
- for (let i = 1; i <= n; i++) {
- product *= i;
- }
- return product;
- }
注意 product 和 i 都在循環(huán)中被反復(fù)重新賦值。這是解決這個問題的標(biāo)準(zhǔn)過程式方法。如何用函數(shù)式的方法解決這個問題呢?我們需要消除循環(huán),確保沒有變量被重新賦值。遞歸是函數(shù)式程序員的最有力的工具之一。遞歸需要我們將整體問題分解為類似整體問題的子問題。
計算階乘是一個很好的例子,為了計算 n! 我們需要將 n 乘以所有比它小的正整數(shù)。它的意思就相當(dāng)于:
n! = n * (n-1)!
啊哈!我們發(fā)現(xiàn)了一個解決 (n-1)! 的子問題,它類似于整個問題 n!。還有一個需要注意的地方就是基礎(chǔ)條件。基礎(chǔ)條件告訴我們何時停止遞歸。 如果我們沒有基礎(chǔ)條件,那么遞歸將永遠(yuǎn)持續(xù)。 實際上,如果有太多的遞歸調(diào)用,程序會拋出一個堆棧溢出錯誤。啊哈!
- 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) 因為……,為什么不呢?當(dāng)我們這樣做的時候,我們得到了這個結(jié)果:
堆棧溢出錯誤
這里發(fā)生了什么?我們得到一個堆棧溢出錯誤!這不是無窮的遞歸導(dǎo)致的。我們已經(jīng)處理了基礎(chǔ)條件(n === 0 的情況)。那是因為瀏覽器的堆棧大小是有限的,而我們的代碼使用了越過了這個大小的堆棧。每次對 recursiveFactorial 的調(diào)用導(dǎo)致了新的幀被壓入堆棧中,就像一個盒子壓在另一個盒子上。每當(dāng) recursiveFactorial 被調(diào)用,一個新的盒子被放在最上面。下圖展示了在計算 recursiveFactorial(3) 時堆棧的樣子。注意在真實的堆棧中,堆棧頂部的幀將存儲在執(zhí)行完成后應(yīng)該返回的內(nèi)存地址,但是我選擇用變量 r 來表示返回值,因為 JavaScript 開發(fā)者一般不需要考慮內(nèi)存地址。
遞歸計算 3! 的堆棧(三次乘法)
你可能會想象當(dāng)計算 n = 20000 時堆棧會更高。我們可以做些什么優(yōu)化它嗎?當(dāng)然可以。作為 ES2015 (又名 ES6) 標(biāo)準(zhǔn)的一部分,有一個優(yōu)化用來解決這個問題。它被稱作尾調(diào)用優(yōu)化proper tail calls optimization(PTC)。當(dāng)遞歸函數(shù)做的***一件事是調(diào)用自己并返回結(jié)果的時候,它使得瀏覽器刪除或者忽略堆棧幀。實際上,這個優(yōu)化對于相互遞歸函數(shù)也是有效的,但是為了簡單起見,我們還是來看單一遞歸函數(shù)。
你可能會注意到,在遞歸函數(shù)調(diào)用之后,還要進(jìn)行一次額外的計算(n * r)。那意味著瀏覽器不能通過 PTC 來優(yōu)化遞歸;然而,我們可以通過重寫函數(shù)使***一步變成遞歸調(diào)用以便優(yōu)化。一個竅門是將中間結(jié)果(在這里是 product)作為參數(shù)傳遞給函數(shù)。
- 'use strict';
- // Optimized for tail call optimization.
- function factorial(n, product = 1) {
- if (n === 0) {
- return product;
- }
- return factorial(n - 1, product * n)
- }
讓我們來看看優(yōu)化后的計算 factorial(3) 時的堆棧。如下圖所示,堆棧不會增長到超過兩層。原因是我們把必要的信息都傳到了遞歸函數(shù)中(比如 product)。所以,在 product 被更新后,瀏覽器可以丟棄掉堆棧中原先的幀。你可以在圖中看到每次最上面的幀下沉變成了底部的幀,原先底部的幀被丟棄,因為不再需要它了。
遞歸計算 3! 的堆棧(三次乘法)使用 PTC
現(xiàn)在選一個瀏覽器運行吧,假設(shè)你在使用 Safari,你會得到 Infinity(它是比在 JavaScript 中能表達(dá)的***值更大的數(shù))。但是我們沒有得到堆棧溢出錯誤,那很不錯!現(xiàn)在在其他的瀏覽器中呢怎么樣呢?Safari 可能現(xiàn)在乃至將來是實現(xiàn) PTC 的唯一一個瀏覽器??纯聪旅娴募嫒菪员砀瘢?/p>
PTC 兼容性
其他瀏覽器提出了一種被稱作語法級尾調(diào)用syntactic tail calls(STC)的競爭標(biāo)準(zhǔn)。“語法級”意味著你需要用新的語法來標(biāo)識你想要執(zhí)行尾遞歸優(yōu)化的函數(shù)。即使瀏覽器還沒有廣泛支持,但是把你的遞歸函數(shù)寫成支持尾遞歸優(yōu)化的樣子還是一個好主意。
高階函數(shù)
我們已經(jīng)知道 JavaScript 將函數(shù)視作一等公民,可以把函數(shù)像其他值一樣傳遞。所以,把一個函數(shù)傳給另一個函數(shù)也很常見。我們也可以讓函數(shù)返回一個函數(shù)。就是它!我們有高階函數(shù)。你可能已經(jīng)很熟悉幾個在 Array.prototype 中的高階函數(shù)。比如 filter、map 和 reduce 就在其中。對高階函數(shù)的一種理解是:它是接受(一般會調(diào)用)一個回調(diào)函數(shù)參數(shù)的函數(shù)。讓我們來看看一些內(nèi)置的高階函數(shù)的例子:
- 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
注意我們在一個數(shù)組對象上調(diào)用其方法,這是面向?qū)ο缶幊痰奶匦?。如果我們想要更函?shù)式一些,我們可以用 Rmmda 或者 lodash/fp 提供的函數(shù)。注意如果我們使用 R.compose 的話,需要倒轉(zhuǎn)函數(shù)的順序,因為它從右向左依次調(diào)用函數(shù)(從底向上);然而,如果我們想從左向右調(diào)用函數(shù)就像上面的例子,我們可以用 R.pipe。下面兩個例子用了 Rmmda。注意 Rmmda 有一個 mean 函數(shù)用來代替 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 }
- ];
- // 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.map(v => v.price),
- R.filter(v => v.type === 'suv')
- )(vehicles);
- console.log(averageSUVPrice2); // 33399
使用函數(shù)式方法的優(yōu)點是清楚地分開了數(shù)據(jù)(vehicles)和邏輯(函數(shù) filter,map 和 reduce)。面向?qū)ο蟮拇a相比之下把數(shù)據(jù)和函數(shù)用以方法的對象的形式混合在了一起。
柯里化
不規(guī)范地說,柯里化currying是把一個接受 n 個參數(shù)的函數(shù)變成 n 個每個接受單個參數(shù)的函數(shù)的過程。函數(shù)的 arity 是它接受參數(shù)的個數(shù)。接受一個參數(shù)的函數(shù)是 unary,兩個的是 binary,三個的是 ternary,n 個的是 n-ary。那么,我們可以把柯里化定義成將一個 n-ary 函數(shù)轉(zhuǎn)換成 n 個 unary 函數(shù)的過程。讓我們通過簡單的例子開始,一個計算兩個向量點積的函數(shù)?;貞浺幌戮€性代數(shù),兩個向量 [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 函數(shù)是 binary,因為它接受兩個參數(shù);然而我們可以將它手動轉(zhuǎn)換成兩個 unary 函數(shù),就像下面的例子。注意 curriedDot 是一個 unary 函數(shù),它接受一個向量并返回另一個接受第二個向量的 unary 函數(shù)。
- 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
很幸運,我們不需要把每一個函數(shù)都手動轉(zhuǎn)換成柯里化以后的形式。Ramda 和 lodash 等庫可以為我們做這些工作。實際上,它們是柯里化的混合形式。你既可以每次傳遞一個參數(shù),也可以像原來一樣一次傳遞所有參數(shù)。
- 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];
- // Use Ramda to do the currying for us!
- const curriedDot = R.curry(dot);
- const sumElements = curriedDot([1, 1, 1]);
- 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 都允許你“跳過”一些變量之后再指定它們。它們使用置位符來做這些工作。因為點積的計算可以交換兩項。傳入向量的順序不影響結(jié)果。讓我們換一個例子來闡述如何使用一個置位符。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
在我們結(jié)束探討柯里化之前***的議題是偏函數(shù)應(yīng)用partial application。偏函數(shù)應(yīng)用和柯里化經(jīng)常同時出場,盡管它們實際上是不同的概念。一個柯里化的函數(shù)還是柯里化的函數(shù),即使沒有給它任何參數(shù)。偏函數(shù)應(yīng)用,另一方面是僅僅給一個函數(shù)傳遞部分參數(shù)而不是所有參數(shù)??吕锘瞧瘮?shù)應(yīng)用常用的方法之一,但是不是唯一的。
JavaScript 擁有一個內(nèi)置機(jī)制可以不依靠柯里化來做偏函數(shù)應(yīng)用。那就是 function.prototype.bind 方法。這個方法的一個特殊之處在于,它要求你將 this 作為***個參數(shù)傳入。 如果你不進(jìn)行面向?qū)ο缶幊蹋敲茨憧梢酝ㄟ^傳入 null 來忽略 this。
- 1function giveMe3(item1, item2, item3) {
- return `
- 1: ${item1}
- 2: ${item2}
- 3: ${item3}
- `;
- }
- const giveMe2 = giveMe3.bind(null, 'rock');
- const giveMe1 = giveMe2.bind(null, 'paper');
- const result = giveMe1('scissors');
- console.log(result);
- // 1: rock
- // 2: paper
- // 3: scissors
總結(jié)
我希望你享受探索 JavaScript 中函數(shù)式編程的過程。對一些人來說,它可能是一個全新的編程范式,但我希望你能嘗試它。你會發(fā)現(xiàn)你的程序更易于閱讀和調(diào)試。不變性還將允許你優(yōu)化 Angular 和 React 的性能。