1. 程式人生 > 程式設計 >深入詳解JS函式的柯里化

深入詳解JS函式的柯里化

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

來一個簡單的思考題。

functiohttp://www.cppcns.comn 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的第一個引數(回撥函式)中進行運算處理後返回計算結果。返回一個由所有計算結果組成的新陣列。

// 回撥函式中有三個引數
//NFerlc 第一個引數表示newArr的每一項,第二個引數表示該項在陣列中的索引值
// 第三個表示陣列本身
// 除此之外,回撥函式中的this,當map不存在第二引數時,this指向丟失,當存在第二個引數時,指向改引數所設定的物件
var newArr = [1,2,3,4].map(function(item,i,arr) {
    console.log(item,arr,this);  // 可執行試試看
    return item + 1;  // 每一項加1
},{ a: 1 })
 
console.log(newArr); // [2,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,4]._map(function(item) {
    return item + 1;
})
// [2,5]

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

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

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

三、由淺入深的柯里化

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

實現一個add方法,使計算結果能夠滿足如下預期:

add(1)(2)(3) = 6
add(1,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,5));  // 15
console.log(add(1,4)(5));  // 15
console.log(add(1)(2)(3)(4)(5));  // 15

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

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

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

  • 接收單一引數,因為要攜帶不少資訊,因此常常以回撥函式的理由來解決。
  • 將部分引數通過回撥函式等方式傳入函式中
  • 返回一個新函式,用於處理所有的想要傳入的引數

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

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

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

var arr = [1,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,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,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封裝同樣的道理,既然我們事先並不確定我們將要對每一項資料進行怎麼樣的處理,我只是知道我們需要將他們處理之後然後用字元連起來,所以不妨將處理內容儲存在一個函式裡。而僅僅固定http://www.cppcns.com封裝連起來的這一部分需求。

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

// 封裝很簡單,一句話搞NFerlc定
Array.prototype.merge = function(fn,chars) {
    return this.map(fn).join(chars);
}
 
var arr = [1,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物件收集引數;
  • 返回的這個函式正是用來處理收集起來的引數。

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

以上就是深入詳解js函式的柯里化的詳細內容,更多關於JS函式的柯里化的資料請關注我們其它相關文章!