JS函式柯里化

gintoryzzz發表於2020-12-02

函式柯里化

一、定義

在電腦科學中,柯里化(英語:Currying),指的是把“接受多個引數的函式”變換成“接受單一引數(最初函式的第一個引數) 的函式”,並返回“接受餘下的引數而且返回結果的新函式“的技術。

顧名思義,柯里化其實本身是固定一個可以預期的引數,並返回一個特定的函式,處理特定的需求。這增加了函式的適用性,但同時也降低了函式的通用性。

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

function A(a, b) {
    return a + b
}

將函式A轉化為柯里化函式 _A:

function _A(a){
    return function(b){
        return a + b
    }
}

那麼 _A 作為柯里化函式,他能夠處理A的剩餘引數。因此下面的執行結果是等價的。

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

_A能夠處理A的所有剩餘引數,因此柯里化也被稱為部分求值。

實際上就是把A函式的a,b兩個引數變成了先用一個函式接收a然後返回一個函式去處理b引數。

現在思路應該就比較清晰了,只傳遞給函式一部分引數來呼叫,讓它返回一個函式去處理剩下的引數

二、柯里化的通用實現

柯里化,是函數語言程式設計的一個重要概念。它既能減少程式碼冗餘,也能增加可讀性。

現在看一個更復雜一點的例子,sum 是個簡單的累加函式,接受3個引數,輸出累加的結果:

function sum (a, b, c) {
    console.log(a + b + c);
}

假設有這樣的需求,sum的前2個引數保持不變,最後一個引數可以隨意。那麼就會想到,在函式內,是否可以把前2個引數的相加過程,給抽離出來,因為引數都是相同的,沒必要每次都做運算。呼叫的寫法可以是這樣: sum(1, 2)(3)sum(1, 2)(10),先把前2個引數的運算結果拿到後,再與第3個引數相加。這其實就是函式柯里化的簡單應用。

sum(1, 2)(3)這樣的寫法,並不常見。拆開來看sum(1, 2) 返回的應該還是個函式,因為後面還有 (3) 需要執行。那麼反過來,從最後一個引數,從右往左看,它的左側必然是一個函式。以此類推,如果前面有n個(),那就是有n個函式返回了結果,只是返回的結果還是一個函式。是不是有點遞迴的意思?

function curry (fn, currArgs) {
    return function() {
        let args = [].slice.call(arguments);

        // 首次呼叫時,若未提供最後一個引數currArgs,則不用進行args的拼接
        if (currArgs !== undefined) {
            args = args.concat(currArgs);
        }

        // 遞迴呼叫
        if (args.length < fn.length) {
            return curry(fn, args);
        }

        // 遞迴出口
        return fn.apply(null, args);
    }
}

說明

  • curry有 2 個引數,fn 指的就是源處理函式 ;currArgs 是呼叫 curry 時傳入的引數列表
  • 將 arguments 陣列化,arguments 是一個類陣列的結構,它並不是一個真的陣列,所以沒法使用陣列的方法。我們用了 call 的方法,就能愉快地對 args 使用陣列的原生方法了。
  • args指的是當前柯里化的函式內已獲得的所有引數,這個函式最終會傳給fn
  • 判斷 args 的個數,是否與 fn (也就是 sum )的引數個數相等,相等了就可以把引數都傳給 fn,進行輸出;否則,繼續遞迴呼叫,直到兩者相等。
    在這裡插入圖片描述

三、柯里化的作用

  • 引數複用
  • 提前確認
  • 延遲執行
1.引數複用

案例:正則驗證字串

按照普通的思路,驗證字串是否是正確的手機號或者郵箱

//驗證手機號
function checkPhone(phoneNumber) {
    return /^1[34578]\d{9}$/.test(phoneNumber);
}
//驗證郵箱
function checkEmail(email) {
    return /^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/.test(email);
}

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

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

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

但是這樣封裝之後,在使用時又會稍微麻煩一點,因為會總是輸入一串正則,這樣就導致了使用時的效率低下。這個時候,我們就可以藉助柯里化,在check的基礎上再做一層封裝,以簡化使用。

var _check = curry(check)

var checkPhone = _check(/^1[34578]\d{9}$/)
var checkEmail = _check(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/)
checkPhone('183888888');
checkEmail('xxxxx@test.com');
2.提前確認

案例:相容IE瀏覽器事件的監聽方法

傳統的方法:

/*
* @param    el         Object      DOM元素物件
* @param    type       String      事件型別
* @param    fn         Function    事件處理函式
* @param    Capture    Boolean     是否捕獲
*/
var addEvent = function(el, type, fn, capture) {
    if(window.addEventListener) {
        el.addEventListener(type, function(e) {
            fn.call(el, e)
        }, capture)
    } else {
        el.attachEvent('on'+type, function(e) {
            fn.call(el, e)
        })
    }
}

缺陷就是,每次對DOM元素進行事件繫結時都需要重新進行判斷,其實對於事件監聽網頁一發布瀏覽器已經確定了,就可以知曉瀏覽器到底是需要哪一種監聽方式。所以我們可以讓判斷只執行一次。

var curEvent = (function() {
    if(window.addEventListener) {
        return function(el, sType, fn, capture) { // return funtion
            el.addEventListener(sType, function() {
                fn.call(el)
            }, capture)
        }
    } else {
        return function(el, sType, fn) {
            el.attachEvent('on'+sType, function() {
                fn.call(el)
            })
        }
    }
})

var addEvent = curEvent();  
addEvent(el,"click",fn)
3.延遲執行

案例:釣魚統計重量

var fishWeight = 0;
var addWeight = function(weight) {
    fishWeight += weight;
};

addWeight(2.3);
addWeight(6.5);
addWeight(1.2);
addWeight(2.5);

console.log(fishWeight);  

柯里化 :

function curryWeight(fn) {
    var _fishWeight = [];
    return function() {
        if (arguments.length === 0) {
            return fn.apply(null, _fishWeight);
        } else {
            _fishWeight = _fishWeight.concat([].slice.call(arguments));
        }
    }
}

function addWeight(){
    var fishWeight = 0;
    for (var i = 0, len = arguments.length; i<len; i++) {
        fishWeight += arguments[i];
    }
    return fishWeight
}

var _addWeight = curryWeight(addWeight);
_addWeight(6.5)
_addWeight(1.2)
_addWeight(2.3)
_addWeight(2.5)
_addWeight()   

四、柯里化總結

函式的柯里化,需要依賴引數以及遞迴,通過拆分引數的方式,來呼叫一個多引數的函式方法,以達到減少程式碼冗餘,增加可讀性的目的。

效能方面

  • 存取arguments物件通常要比存取命名引數要慢一點
  • 一些老版本的瀏覽器在arguments.length的實現上是相當慢的
  • 使用fn.apply( … ) 和 fn.call( … )通常比直接呼叫fn( … ) 稍微慢點
  • 建立大量巢狀作用域和閉包函式會帶來花銷,無論是在記憶體還是速度上

應用場景:

  • 減少重複傳遞不變的部分引數
  • 將柯里化後的callback引數傳遞給其他函式

個人想法:

柯里化這個概念以及實現本身都非常難懂,平時寫程式碼幾乎也很少使用,能使用的場景真的不太多,大多數情況都選擇了其它簡單的方式實現了。在get到這個技能後,我認為以後可以在專案中適當使用這個方法,儘量減少重複程式碼。

五、經典面試題擴充套件

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

add(1)(2)(3) = 6;
add(1, 2, 3)(4) = 10;
add(1)(2)(3)(4)(5) = 15;

這個題目的目的是想讓add執行之後返回一個函式能夠繼續執行,最終運算的結果是所有出現過的引數之和。而這個題目的難點則在於引數的不固定。我們不知道函式會執行幾次。在此之前,補充2個非常重要的知識點。

  • 不定參

    // 將args陣列的子項展開作為add的引數傳入
    function add(a, b, c, d) {
        return a + b + c + d;
    }
    var args = [1, 3, 100, 1];
    
    //ES5
    add.apply(null, args);  
    //ES6
    add(...args); 
    
  • 函式的隱式轉換

當我們直接將函式參與其他的計算時,函式會預設呼叫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

補充了這兩個知識點之後,我們可以來嘗試完成之前的題目了。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); 
var b = add(1, 2)(3, 4); 

// 可以利用隱式轉換的特性參與計算
console.log(a + 10); // 20

// 也可以繼續傳入引數,得到的結果再次利用隱式轉換參與計算
console.log(a(10) + 100);  // 120

參考資料:
好早之前做的筆記,找不到了

相關文章