前端JS進階系列-八-柯里化

前端JS進階系列-八-柯里化

柯里化是函數的一個比較高級的應用,想要理解它並不簡單。因此我一直在思考應該如何更加表達才能讓大家理解起來更加容易。

以下是新版本講解。高階函數章節由於一些原因並未公開,大家可以自行搜索學習

通過上一個章節的學習我們知道,接收函數作為參數的函數,都可以叫做高階函數。我們常常利用高階函數來封裝一些公共的邏輯。

這一章我們要學習的柯里化,其實就是高階函數的一種特殊用法。

柯里化是指這樣一個函數(假設叫做createCurry),他接收函數A作為參數,運行後能夠返回一個新的函數。並且這個新的函數能夠處理函數A的剩餘參數。

這樣的定義可能不太好理解,我們可以通過下面的例子配合理解。

假如有一個接收三個參數的函數A。

function A(a, b, c) { // do something}

又假如我們有一個已經封裝好了的柯里化通用函數createCurry。他接收bar作為參數,能夠將A轉化為柯里化函數,返回結果就是這個被轉化之後的函數。

var _A = createCurry(A);

那麼_A作為createCurry運行的返回函數,他能夠處理A的剩餘參數。因此下面的運行結果都是等價的。

_A(1, 2, 3);
_A(1, 2)(3);
_A(1)(2, 3);
_A(1)(2)(3);
A(1, 2, 3);

函數A被createCurry轉化之後得到柯里化函數_A,_A能夠處理A的所有剩餘參數。因此柯里化也被稱為部分求值。

在簡單的場景下,我們可以不用藉助柯里化通用式來轉化得到柯里化函數,我們可以憑藉眼力自己封裝。

例如有一個簡單的加法函數,他能夠將自身的三個參數加起來並返回計算結果。

function add(a, b, c) { return a + b + c;
}

那麼add函數的柯里化函數_add則可以如下:

function _add(a) { return function(b) { return function(c) { return a + b + c;
}
}
}

因此下面的運算方式是等價的。

add(1, 2, 3);
_add(1)(2)(3);

當然,柯里化通用式具備更加強大的能力,我們靠眼力自己封裝的柯里化函數則自由度偏低。因此我們仍然需要知道自己如何去封裝這樣一個柯里化的通用式。

首先通過_add可以看出,柯里化函數的運行過程其實是一個參數的收集過程,我們將每一次傳入的參數收集起來,並在最裡層裡面處理。因此我們在實現createCurry時,可以藉助這個思路來進行封裝。

封裝如下:

// 簡單實現,參數只能從右到左傳遞function createCurry(func, args) { var arity = func.length; var args = args || []; return function() { var _args = [].slice.call(arguments);
[].push.apply(_args, args); // 如果參數個數小於最初的func.length,則遞歸調用,繼續收集參數
if (_args.length < arity) { return createCurry.call(this, func, _args);
} // 參數收集完畢,則執行func
return func.apply(this, _args);
}
}

儘管我已經做了足夠詳細的註解,但是我想理解起來也並不是那麼容易,因此建議大家用點耐心多閱讀幾遍。這個createCurry函數的封裝藉助閉包與遞歸,實現了一個參數收集,並在收集完畢之後執行所有參數的一個過程。

因此聰明的讀者可能已經發現,把函數經過createCurry轉化為一個柯里化函數,最後執行的結果,不是正好相當於執行函數自身嗎?柯里化是不是把簡單的問題複雜化了?

如果你能夠提出這樣的問題,那麼說明你確實已經對柯里化有了一定的瞭解。柯里化確實是把簡答的問題複雜化了,但是複雜化的同時,我們在使用函數時擁有了更加多的自由度。而這裡對於函數參數的自由處理,正是柯里化的核心所在。

我們來舉一個非常常見的例子。

如果我們想要驗證一串數字是否是正確的手機號,那麼按照普通的思路來做,大家可能是這樣封裝,如下:

function checkPhone(phoneNumber) { return /^1[34578]\d{9}$/.test(phoneNumber);
}

而如果我們想要驗證是否是郵箱呢?這麼封裝:

function checkEmail(email) { return /^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/.test(email);
}

我們還可能會遇到驗證身份證號,驗證密碼等各種驗證信息,因此在實踐中,為了統一邏輯,,我們就會封裝一個更為通用的函數,將用於驗證的正則與將要被驗證的字符串作為參數傳入。

function check(targetString, reg) { return reg.test(targetString);
}

但是這樣封裝之後,在使用時又會稍微麻煩一點,因為會總是輸入一串正則,這樣就導致了使用時的效率低下。

check(/^1[34578]\d{9}$/, '14900000088');
check(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/, '[email protected]');

那麼這個時候,我們就可以藉助柯里化,在check的基礎上再做一層封裝,以簡化使用。

var _check = createCurry(check);var checkPhone = _check(/^1[34578]\d{9}$/);var checkEmail = _check(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/);

最後在使用的時候就會變得更加直觀與簡潔了。

checkPhone('183888888');
checkEmail('[email protected]');

經過這個過程我們發現,柯里化能夠應對更加複雜的邏輯封裝。當情況變得多變,柯里化依然能夠應付自如。

雖然柯里化確實在一定程度上將問題複雜化了,也讓代碼更加不容易理解,但是柯里化在面對複雜情況下的靈活性卻讓我們不得不愛。

當然這個案例本身情況還算簡單,所以還不能夠特別明顯的凸顯柯里化的優勢,我們的主要目的在於藉助這個案例幫助大家瞭解柯里化在實踐中的用途。

我們繼續來思考一個例子。這個例子與map有關。在高階函數的章節中,我們分析了封裝map方法的思考過程。由於我們沒有辦法確認一個數組在遍歷時會執行什麼操作,因此我們只能將調用for循環的這個統一邏輯封裝起來,而具體的操作則通過參數傳入的形式讓使用者自定義。這就是map函數。

但是,這是針對了所有的情況我們才會這樣想。

實踐中我們常常會發現,在我們的某個項目中,針對於某一個數組的操作其實是固定的,也就是說,同樣的操作,可能會在項目的不同地方調用很多次。

於是,這個時候,我們就可以在map函數的基礎上,進行二次封裝,以簡化我們在項目中的使用。假如這個在我們項目中會調用多次的操作是將數組的每一項都轉化為百分比 1 --> 100%。

普通思維下我們可以這樣來封裝。

function getNewArray(array) { return array.map(function(item) { return item * 100 + '%'
})
}
getNewArray([1, 2, 3, 0.12]); // ['100%', '200%', '300%', '12%'];

而如果藉助柯里化來二次封裝這樣的邏輯,則會如下實現:

function _map(func, array) { return array.map(func);
}var _getNewArray = createCurry(_map);var getNewArray = _getNewArray(function(item) { return item * 100 + '%'})
getNewArray([1, 2, 3, 0.12]); // ['100%', '200%', '300%', '12%'];getNewArray([0.01, 1]); // ['1%', '100%']

如果我們的項目中的固定操作是希望對數組進行一個過濾,找出數組中的所有Number類型的數據。藉助柯里化思維我們可以這樣做。

function _filter(func, array) { return array.filter(func);
}var _find = createCurry(_filter);var findNumber = _find(function(item) { if (typeof item == 'number') { return item;
}

})
findNumber([1, 2, 3, '2', '3', 4]); // [1, 2, 3, 4]// 當我們繼續封裝另外的過濾操作時就會變得非常簡單// 找出數字為20的子項var find20 = _find(function(item, i) { if (typeof item === 20) { return i;
}
})
find20([1, 2, 3, 30, 20, 100]); // 4// 找出數組中大於100的所有數據var findGreater100 = _find(function(item) { if (item > 100) { return item;
}
})
findGreater100([1, 2, 101, 300, 2, 122]); // [101, 300, 122]

我採用了與check例子不一樣的思維方向來想大家展示我們在使用柯里化時的想法。目的是想告訴大家,柯里化能夠幫助我們應對更多更復雜的場景。

當然不得不承認,這些例子都太簡單了,簡單到如果使用柯里化的思維來處理他們顯得有一點多此一舉,而且變得難以理解。因此我想讀者朋友們也很難從這些例子中感受到柯里化的魅力。不過沒關係,如果我們能夠通過這些例子掌握到柯里化的思維,那就是最好的結果了。在未來你的實踐中,如果你發現用普通的思維封裝一些邏輯慢慢變得困難,不妨想一想在這裡學到的柯里化思維,應用起來,柯里化足夠強大的自由度一定能給你一個驚喜。

當然也並不建議在任何情況下以炫技為目的的去使用柯里化,在柯里化的實現中,我們知道柯里化雖然具有了更多的自由度,但同時柯里化通用式裡調用了arguments對象,使用了遞歸與閉包,因此柯里化的自由度是以犧牲了一定的性能為代價換來的。只有在情況變得複雜時,才是柯里化大顯身手的時候。

額外知識補充

無限參數的柯里化。

該部分內容可忽略

在前端面試中,你可能會遇到這樣一個涉及到柯里化的題目。

// 實現一個add方法,使計算結果能夠滿足如下預期:add(1)(2)(3) = 6;
add(1, 2, 3)(4) = 10;
add(1)(2)(3)(4)(5) = 15;

這個題目的目的是想讓add執行之後返回一個函數能夠繼續執行,最終運算的結果是所有出現過的參數之和。而這個題目的難點則在於參數的不固定。我們不知道函數會執行幾次。因此我們不能使用上面我們封裝的createCurry的通用公式來轉換一個柯里化函數。只能自己封裝,那麼怎麼辦呢?在此之前,補充2個非常重要的知識點。

一個是ES6函數的不定參數。假如我們有一個數組,希望把這個數組中所有的子項展開傳遞給一個函數作為參數。那麼我們應該怎麼做?

// 大家可以思考一下,如果將args數組的子項展開作為add的參數傳入function add(a, b, c, d) { return a + b + c + d;
}var args = [1, 3, 100, 1];

在ES5中,我們可以藉助之前學過的apply來達到我們的目的。

add.apply(null, args); // 105

而在ES6中,提供了一種新的語法來解決這個問題,那就是不定參。寫法如下:

add(...args); // 105

這兩種寫法是等效的。OK,先記在這裡。在接下的實現中,我們會用到不定參數的特性。

第二個要補充的知識點是函數的隱式轉換。當我們直接將函數參與其他的計算時,函數會默認調用toString方法,直接將函數體轉換為字符串參與計算。

function fn() { return 20 }console.log(fn + 10); // 輸出結果 function fn() { return 20 }10

但是我們可以重寫函數的toString方法,讓函數參與計算時,輸出我們想要的結果。

function fn() { return 20; }
fn.toString = function() { return 30 }console.log(fn + 10); // 40

除此之外,當我們重寫函數的valueOf方法也能夠改變函數的隱式轉換結果。

function fn() { return 20; }
fn.valueOf = function() { return 60 }console.log(fn + 10); // 70

當我們同時重寫函數的toString方法與valueOf方法時,最終的結果會取valueOf方法的返回結果。

function fn() { return 20; }
fn.valueOf = function() { return 50 }

fn.toString = function() { return 30 }console.log(fn + 10); // 60

補充了這兩個知識點之後,我們可以來嘗試完成之前的題目了。add方法的實現仍然會是一個參數的收集過程。當add函數執行到最後時,仍然返回的是一個函數,但是我們可以通過定義toString/valueOf的方式,讓這個函數可以直接參與計算,並且轉換的結果是我們想要的。而且它本身也仍然可以繼續執行接收新的參數。實現方式如下。

function add() { // 第一次執行時,定義一個數組專門用來存儲所有的參數
var _args = [].slice.call(arguments); // 在內部聲明一個函數,利用閉包的特性保存_args並收集所有的參數值
var adder = function () { var _adder = function() { // [].push.apply(_args, [].slice.call(arguments));
_args.push(...arguments); return _adder;
}; // 利用隱式轉換的特性,當最後執行時隱式轉換,並計算最終的值返回
_adder.toString = function () { return _args.reduce(function (a, b) { return a + b;
});
} return _adder;
} // return adder.apply(null, _args);
return adder(..._args);
}var a = add(1)(2)(3)(4); // f 10var b = add(1, 2, 3, 4); // f 10var c = add(1, 2)(3, 4); // f 10var d = add(1, 2, 3)(4); // f 10// 可以利用隱式轉換的特性參與計算console.log(a + 10); // 20console.log(b + 20); // 30console.log(c + 30); // 40console.log(d + 40); // 50// 也可以繼續傳入參數,得到的結果再次利用隱式轉換參與計算console.log(a(10) + 100); // 120console.log(b(10) + 100); // 120console.log(c(10) + 100); // 120console.log(d(10) + 100); // 120
// 其實上慄中的add方法,就是下面這個函數的柯里化函數,只不過我們並沒有使用通用式來轉化,而是自己封裝function add(...args) { return args.reduce((a, b) => a + b);
}

以下為老版本講解,請勿閱讀學習,因為部分思維並不完全正確。

一、補充知識點之函數的隱式轉換

JavaScript作為一種弱類型語言,它的隱式轉換是非常靈活有趣的。當我們沒有深入瞭解隱式轉換的時候可能會對一些運算的結果會感動困惑,比如4 + true = 5。當然,如果對隱式轉換了解足夠深刻,肯定是能夠很大程度上提高對js的使用能力。只是我沒有打算將所有的隱式轉換規則分享給大家,這裡暫時只分享一下,函數在隱式轉換中的一些規則。

來一個簡單的思考題。

function fn() {
return 20;
}
console.log(fn + 10); // 輸出結果是多少?

稍微修改一下,再想想輸出結果會是什麼?

function fn() {
return 20;
}
fn.toString = function() {
return 10;
}
console.log(fn + 10); // 輸出結果是多少?

還可以繼續修改一下。

function fn() {
return 20;
}

fn.toString = function() {
return 10;
}
fn.valueOf = function() {
return 5;
}
console.log(fn + 10); // 輸出結果是多少?
// 輸出結果分別為
function fn() {
return 20;
}10
20
15

當使用console.log,或者進行運算時,隱式轉換就可能會發生。從上面三個例子中我們可以得出一些關於函數隱式轉換的結論。

當我們沒有重新定義toString與valueOf時,函數的隱式轉換會調用默認的toString方法,它會將函數的定義內容作為字符串返回。而當我們主動定義了toString/vauleOf方法時,那麼隱式轉換的返回結果則由我們自己控制了。其中valueOf會比toString後執行

因此上面例子的結論就很容易理解了。建議大家動手嘗試一下。

二、補充知識點之利用call/apply封數組的map方法

map(): 對數組中的每一項運行給定函數,返回每次函數調用的結果組成的數組。

通俗來說,就是遍歷數組的每一項元素,並且在map的第一個參數(回調函數)中進行運算處理後返回計算結果。返回一個由所有計算結果組成的新數組。

// 回調函數中有三個參數
// 第一個參數表示newArr的每一項,第二個參數表示該項在數組中的索引值
// 第三個表示數組本身
// 除此之外,回調函數中的this,當map不存在第二參數時,this指向丟失,當存在第二個參數時,指向改參數所設定的對象
var newArr = [1, 2, 3, 4].map(function(item, i, arr) {
console.log(item, i, arr, this); // 可運行試試看
return item + 1; // 每一項加1
}, { a: 1 })
console.log(newArr); // [2, 3, 4, 5]

在上面例子的註釋中詳細闡述了map方法的細節。現在要面臨一個難題,就是如何封裝map。

可以先想想for循環。我們可以使用for循環來實現一個map,但是在封裝的時候,我們會考慮一些問題。我們在使用for循環的時候,一個循環過程確實很好封裝,但是我們在for循環裡面要對每一項做的事情卻很難用一個固定的東西去把它封裝起來。因為每一個場景,for循環裡對數據的處理肯定都是不一樣的。

於是大家就想了一個很好的辦法,將這些不一樣的操作單獨用一個函數來處理,讓這個函數成為map方法的第一個參數,具體這個回調函數中會是什麼樣的操作,則由我們自己在使用時決定。因此,根據這個思路的封裝實現如下。

Array.prototype._map = function(fn, context) {
var temp = [];
if(typeof fn == 'function') {
var k = 0;
var len = this.length;
// 封裝for循環過程
for(; k < len; k++) {
// 將每一項的運算操作丟進fn裡,利用call方法指定fn的this指向與具體參數
temp.push(fn.call(context, this[k], k, this))
}
} else {
console.error('TypeError: '+ fn +' is not a function.');
}
// 返回每一項運算結果組成的新數組
return temp;
}
var newArr = [1, 2, 3, 4]._map(function(item) {
return item + 1;
})
// [2, 3, 4, 5]

在上面的封裝中,我首先定義了一個空的temp數組,該數組用來存儲最終的返回結果。在for循環中,每循環一次,就執行一次參數fn函數,fn的參數則使用call方法傳入。

在理解了map的封裝過程之後,我們就能夠明白為什麼我們在使用map時,總是期望能夠在第一個回調函數中有一個返回值了。在eslint的規則中,如果我們在使用map時沒有設置一個返回值,就會被判定為錯誤。

ok,明白了函數的隱式轉換規則與call/apply在這種場景的使用方式,我們就可以嘗試通過簡單的例子來了解一下柯里化了。

三、由淺入深的柯里化

在前端面試中有一個關於柯里化的面試題,流傳甚廣。

實現一個add方法,使計算結果能夠滿足如下預期: add(1)(2)(3) = 6 add(1, 2, 3)(4) = 10add(1)(2)(3)(4)(5) = 15

很明顯,計算結果正是所有參數的和,add方法每運行一次,肯定返回了一個同樣的函數,繼續計算剩下的參數。

我們可以從最簡單的例子一步一步尋找解決方案。

當我們只調用兩次時,可以這樣封裝。

function add(a) {
return function(b) {
return a + b;
}
}
console.log(add(1)(2)); // 3

如果只調用三次:

function add(a) {
return function(b) {
return function (c) {
return a + b + c;
}
}
}

console.log(add(1)(2)(3)); // 6

上面的封裝看上去跟我們想要的結果有點類似,但是參數的使用被限制得很死,因此並不是我們想要的最終結果,我們需要通用的封裝。應該怎麼辦?總結一下上面2個例子,其實我們是利用閉包的特性,將所有的參數,集中到最後返回的函數里進行計算並返回結果。因此我們在封裝時,主要的目的,就是將參數集中起來計算。

來看看具體實現。

function add() {
// 第一次執行時,定義一個數組專門用來存儲所有的參數
var _args = [].slice.call(arguments);
// 在內部聲明一個函數,利用閉包的特性保存_args並收集所有的參數值
var adder = function () {
var _adder = function() {
[].push.apply(_args, [].slice.call(arguments));
return _adder;
};
// 利用隱式轉換的特性,當最後執行時隱式轉換,並計算最終的值返回
_adder.toString = function () {
return _args.reduce(function (a, b) {
return a + b;
});
}
return _adder;
}
return adder.apply(null, [].slice.call(arguments));
}
// 輸出結果,可自由組合的參數

console.log(add(1, 2, 3, 4, 5)); // 15
console.log(add(1, 2, 3, 4)(5)); // 15
console.log(add(1)(2)(3)(4)(5)); // 15

上面的實現,利用閉包的特性,主要目的是想通過一些巧妙的方法將所有的參數收集在一個數組裡,並在最終隱式轉換時將數組裡的所有項加起來。因此我們在調用add方法的時候,參數就顯得非常靈活。當然,也就很輕鬆的滿足了我們的需求。

那麼讀懂了上面的demo,然後我們再來看看柯里化的定義,相信大家就會更加容易理解了。

柯里化(英語:Currying),又稱為部分求值,是把接受多個參數的函數變換成接受一個單一參數(最初函數的第一個參數)的函數,並且返回一個新的函數的技術,新函數接受餘下參數並返回運算結果。

  • 接收單一參數,因為要攜帶不少信息,因此常常以回調函數的理由來解決。

  • 將部分參數通過回調函數等方式傳入函數中

  • 返回一個新函數,用於處理所有的想要傳入的參數

在上面的例子中,我們可以將add(1, 2, 3, 4)轉換為add(1)(2)(3)(4)。這就是部分求值。每次傳入的參數都只是我們想要傳入的所有參數中的一部分。當然實際應用中,並不會常常這麼複雜的去處理參數,很多時候也僅僅只是分成兩部分而已。

咱們再來一起思考一個與柯里化相關的問題。

假如有一個計算要求,需要我們將數組裡面的每一項用我們自己想要的字符給連起來。我們應該怎麼做?想到使用join方法,就很簡單。

var arr = [1, 2, 3, 4, 5];
// 實際開發中並不建議直接給Array擴展新的方法
// 只是用這種方式演示能夠更加清晰一點
Array.prototype.merge = function(chars) {
return this.join(chars);
}
var string = arr.merge('-')
console.log(string); // 1-2-3-4-5

增加難度,將每一項加一個數後再連起來。那麼這裡就需要map來幫助我們對每一項進行特殊的運算處理,生成新的數組然後用字符連接起來了。實現如下:

var arr = [1, 2, 3, 4, 5];
Array.prototype.merge = function(chars, number) {
return this.map(function(item) {
return item + number;
}).join(chars);
}
var string = arr.merge('-', 1);
console.log(string); // 2-3-4-5-6

但是如果我們又想要讓數組每一項都減去一個數之後再連起來呢?當然和上面的加法操作一樣的實現。

var arr = [1, 2, 3, 4, 5];
Array.prototype.merge = function(chars, number) {
return this.map(function(item) {
return item - number;
}).join(chars);
}
var string = arr.merge('~', 1);
console.log(string); // 0~1~2~3~4

機智的小夥伴肯定發現困惑所在了。我們期望封裝一個函數,能同時處理不同的運算過程,但是我們並不能使用一個固定的套路將對每一項的操作都封裝起來。於是問題就變成了和封裝map的時候所面臨的問題一樣了。我們可以藉助柯里化來搞定。

與map封裝同樣的道理,既然我們事先並不確定我們將要對每一項數據進行怎麼樣的處理,我只是知道我們需要將他們處理之後然後用字符連起來,所以不妨將處理內容保存在一個函數里。而僅僅固定封裝連起來的這一部分需求。

於是我們就有了以下的封裝。

// 封裝很簡單,一句話搞定
Array.prototype.merge = function(fn, chars) {
return this.map(fn).join(chars);
}
var arr = [1, 2, 3, 4];
// 難點在於,在實際使用的時候,操作怎麼來定義,利用閉包保存於傳遞num參數
var add = function(num) {
return function(item) {
return item + num;
}
}
var red = function(num) {
return function(item) {
return item - num;
}
}
// 每一項加2後合併
var res1 = arr.merge(add(2), '-');
// 每一項減2後合併
var res2 = arr.merge(red(1), '-');
// 也可以直接使用回調函數,每一項乘2後合併
var res3 = arr.merge((function(num) {
return function(item) {
return item * num
}
})(2), '-')
console.log(res1); // 3-4-5-6
console.log(res2); // 0-1-2-3
console.log(res3); // 2-4-6-8

大家能從上面的例子,發現柯里化的特徵嗎?

四、柯里化通用式

通用的柯里化寫法其實比我們上邊封裝的add方法要簡單許多。

var currying = function(fn) { var args = [].slice.call(arguments, 1); return function() { // 主要還是收集所有需要的參數到一個數組中,便於統一計算
var _args = args.concat([].slice.call(arguments)); return fn.apply(null, _args);
}
}var sum = currying(function() { var args = [].slice.call(arguments); return args.reduce(function(a, b) { return a + b;
})
}, 10)console.log(sum(20, 10)); // 40console.log(sum(10, 5)); // 25

五、柯里化與bind

Object.prototype.bind = function(context) {
var _this = this;
var args = [].slice.call(arguments, 1);
return function() {
return _this.apply(context, args)
}
}

這個例子利用call與apply的靈活運用,實現了bind的功能。

在前面的幾個例子中,我們可以總結一下柯里化的特點:

  • 接收單一參數,將更多的參數通過回調函數來搞定?

  • 返回一個新函數,用於處理所有的想要傳入的參數;

  • 需要利用call/apply與arguments對象收集參數;

  • 返回的這個函數正是用來處理收集起來的參數。

希望大家讀完之後都能夠大概明白柯里化的概念,如果想要熟練使用它,就需要我們掌握更多的實際經驗才行。


分享到:


相關文章: