由淺入深,深入詳解 JavaScript 柯里化
面對現實好難,終究還是沒能戰勝自己
柯里化是函數的一個比較高級的應用,想要理解它并不簡單。因此我一直在思考應該如何更加表達才能讓大家理解起來更加容易。想了很久,決定先拋開柯里化這個概念不管,補充兩個重要、但是容易被忽略的知識點。
一、補充知識點之函數的隱式轉換
JavaScript作為一種弱類型語言,它的隱式轉換是非常靈活有趣的。當我們沒有深入了解隱式轉換的時候可能會對一些運算的結果會感動困惑,比如 4 + true = 5 , !!undefined = false 等。當然,如果你對隱式轉換了解足夠深刻,肯定是能夠很大程度上提高你對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); // 輸出結果是多少?
首先我們要知道,當使用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) = 10
add(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)); // 40
console.log(sum(10, 5)); // 25
五、柯里化與bind
Object.prototype.bind = function(context) {
var _this = this;
var args = [].prototype.slice.call(arguments, 1);
return function() {
return _this.apply(context, args)
}
}
這個例子利用call與apply的靈活運用,實現了bind的功能。
在前面的幾個例子中,我們可以總結一下柯里化的特點:
- 接收單一參數,將更多的參數通過回調函數來搞定?
- 返回一個新函數,用于處理所有的想要傳入的參數;
- 需要利用call/apply與arguments對象收集參數;
- 返回的這個函數正是用來處理收集起來的參數。
希望大家讀完之后都能夠大概明白柯里化的概念了吧,如果想要熟練使用它,可能就需要我們掌握更多的實際經驗才行。
來自:http://www.jianshu.com/p/5e1899fe7d6b