首頁 > 軟體

前端JavaScript徹底弄懂函數柯里化curry

2021-09-15 16:01:02

一、什麼是柯里化( curry)

在數學和電腦科學中,柯里化是一種將使用多個引數的一個函數轉換成一系列使用一個引數的函數的技術。

舉例來說,一個接收3個引數的普通函數,在進行柯里化後, 柯里化版本的函數接收一個引數並返回接收下一個引數的函數, 該函數返回一個接收第三個引數的函數。 最後一個函數在接收第三個引數後, 將之前接收到的三個引數應用於原普通函數中,並返回最終結果。

數學和計算科學中的柯里化:

// 數學和計算科學中的柯里化:

//一個接收三個引數的普通函數
function sum(a,b,c) {
    console.log(a+b+c)
}

//用於將普通函數轉化為柯里化版本的工具函數
function curry(fn) {
  //...內部實現省略,返回一個新函數
}

//獲取一個柯里化後的函數
let _sum = curry(sum);

//返回一個接收第二個引數的函數
let A = _sum(1);
//返回一個接收第三個引數的函數
let B = A(2);
//接收到最後一個引數,將之前所有的引數應用到原函數中,並執行
B(3)    // print : 6

而對於Javascript語言來說,我們通常說的柯里化函數的概念,與數學和電腦科學中的柯里化的概念並不完全一樣。

在數學和電腦科學中的柯里化函數,一次只能傳遞一個引數;

而我們Javascript實際應用中的柯里化函數,可以傳遞一個或多個引數。

來看這個例子:

//普通函數
function fn(a,b,c,d,e) {
  console.log(a,b,c,d,e)
}
//生成的柯里化函數
let _fn = curry(fn);

_fn(1,2,3,4,5);     // print: 1,2,3,4,5
_fn(1)(2)(3,4,5);   // print: 1,2,3,4,5
_fn(1,2)(3,4)(5);   // print: 1,2,3,4,5
_fn(1)(2)(3)(4)(5); // print: 1,2,3,4,5

對於已經柯里化後的 _fn 函數來說,當接收的引數數量與原函數的形引數量相同時,執行原函數; 當接收的引數數量小於原函數的形引數量時,返回一個函數用於接收剩餘的引數,直至接收的引數數量與形引數量一致,執行原函數。

當我們知道柯里化是什麼了的時候,我們來看看柯里化到底有什麼用?

二、柯里化的用途

柯里化實際是把簡答的問題複雜化了,但是複雜化的同時,我們在使用函數時擁有了更加多的自由度。 而這裡對於函數引數的自由處理,正是柯里化的核心所在。 柯里化本質上是降低通用性,提高適用性。來看一個例子:

我們工作中會遇到各種需要通過正則檢驗的需求,比如校驗電話號碼、校驗郵箱、校驗身份證號、校驗密碼等, 這時我們會封裝一個通用函數 checkByRegExp ,接收兩個引數,校驗的正則物件和待校驗的字串

function checkByRegExp(regExp,string) {
    return regExp.test(string);  
}

checkByRegExp(/^1d{10}$/, '18642838455'); // 校驗電話號碼
checkByRegExp(/^(w)+(.w+)*@(w)+((.w+)+)$/, '[email protected]'); // 校驗郵箱

上面這段程式碼,乍一看沒什麼問題,可以滿足我們所有通過正則檢驗的需求。 但是我們考慮這樣一個問題,如果我們需要校驗多個電話號碼或者校驗多個郵箱呢?

我們可能會這樣做:

checkByRegExp(/^1d{10}$/, '18642838455'); // 校驗電話號碼
checkByRegExp(/^1d{10}$/, '13109840560'); // 校驗電話號碼
checkByRegExp(/^1d{10}$/, '13204061212'); // 校驗電話號碼

checkByRegExp(/^(w)+(.w+)*@(w)+((.w+)+)$/, '[email protected]'); // 校驗郵箱
checkByRegExp(/^(w)+(.w+)*@(w)+((.w+)+)$/, '[email protected]'); // 校驗郵箱
checkByRegExp(/^(w)+(.w+)*@(w)+((.w+)+)$/, '[email protected]'); // 校驗郵箱

我們每次進行校驗的時候都需要輸入一串正則,再校驗同一型別的資料時,相同的正則我們需要寫多次, 這就導致我們在使用的時候效率低下,並且由於 checkByRegExp 函數本身是一個工具函數並沒有任何意義, 一段時間後我們重新來看這些程式碼時,如果沒有註釋,我們必須通過檢查正則的內容, 我們才能知道我們校驗的是電話號碼還是郵箱,還是別的什麼。

此時,我們可以藉助柯里化對 checkByRegExp 函數進行封裝,以簡化程式碼書寫,提高程式碼可讀性。

//進行柯里化
let _check = curry(checkByRegExp);
//生成工具函數,驗證電話號碼
let checkCellPhone = _check(/^1d{10}$/);
//生成工具函數,驗證郵箱
let checkEmail = _check(/^(w)+(.w+)*@(w)+((.w+)+)$/);

checkCellPhone('18642838455'); // 校驗電話號碼
checkCellPhone('13109840560'); // 校驗電話號碼
checkCellPhone('13204061212'); // 校驗電話號碼

checkEmail('[email protected]'); // 校驗郵箱
checkEmail('[email protected]'); // 校驗郵箱
checkEmail('[email protected]'); // 校驗郵箱

再來看看通過柯里化封裝後,我們的程式碼是不是變得又簡潔又直觀了呢。

經過柯里化後,我們生成了兩個函數 checkCellPhone 和 checkEmail, checkCellPhone 函數只能驗證傳入的字串是否是電話號碼, checkEmail 函數只能驗證傳入的字串是否是郵箱, 它們與 原函數 checkByRegExp 相比,從功能上通用性降低了,但適用性提升了。 柯里化的這種用途可以被理解為:引數複用

我們再來看一個例子

假定我們有這樣一段資料:

let list = [
    {
        name:'lucy'
    },
    {
        name:'jack'
    }
]

我們需要獲取資料中的所有 name 屬性的值,常規思路下,我們會這樣實現:

let names = list.map(function(item) {
  return item.name;
})

那麼我們如何用柯里化的思維來實現呢

let prop = curry(function(key,obj) {
    return obj[key];
})
let names = list.map(prop('name'))

看到這裡,可能會有疑問,這麼簡單的例子,僅僅只是為了獲取 name 的屬性值,為何還要實現一個 prop 函數呢,這樣太麻煩了吧。

我們可以換個思路,prop 函數實現一次後,以後是可以多次使用的,所以我們在考慮程式碼複雜程度的時候,是可以將 prop 函數的實現去掉的。

我們實際的程式碼可以理解為只有一行 let names = list.map(prop('name'))

這麼看來,通過柯里化的方式,我們的程式碼是不是變得更精簡了,並且可讀性更高了呢。

三、如何封裝柯里化工具函數

接下來,我們來思考如何實現 curry 函數。

回想之前我們對於柯里化的定義,接收一部分引數,返回一個函數接收剩餘引數,接收足夠引數後,執行原函數。

我們已經知道了,當柯里化函數接收到足夠引數後,就會執行原函數,那麼我們如何去確定何時達到足夠的引數呢?

我們有兩種思路:

  1. 通過函數的 length 屬性,獲取函數的形參個數,形參的個數就是所需的引數個數
  2. 在呼叫柯里化工具函數時,手動指定所需的引數個數

我們將這兩點結合以下,實現一個簡單 curry 函數:

/**
 * 將函數柯里化
 * @param fn    待柯里化的原函數
 * @param len   所需的引數個數,預設為原函數的形參個數
 */
function curry(fn,len = fn.length) {
    return _curry.call(this,fn,len)
}

/**
 * 中轉函數
 * @param fn    待柯里化的原函數
 * @param len   所需的引數個數
 * @param args  已接收的參數列
 */
function _curry(fn,len,...args) {
    return function (...params) {
        let _args = [...args,...params];
        if(_args.length >= len){
            return fn.apply(this,_args);
        }else{
            return _curry.call(this,fn,len,..._args)
        }
    }
}

我們來驗證一下:

let _fn = curry(function(a,b,c,d,e){
    console.log(a,b,c,d,e)
});

_fn(1,2,3,4,5);     // print: 1,2,3,4,5
_fn(1)(2)(3,4,5);   // print: 1,2,3,4,5
_fn(1,2)(3,4)(5);   // print: 1,2,3,4,5
_fn(1)(2)(3)(4)(5); // print: 1,2,3,4,5

我們常用的工具庫 lodash 也提供了 curry 方法,並且增加了非常好玩的 placeholder 功能,通過預留位置的方式來改變傳入引數的順序。

比如說,我們傳入一個預留位置,本次呼叫傳遞的引數略過預留位置, 預留位置所在的位置由下次呼叫的引數來填充,比如這樣:

直接看一下官網的例子:

接下來我們來思考,如何實現預留位置的功能。

對於 lodash curry 函數來說,curry 函數掛載在 lodash 物件上,所以將 lodash 物件當做預設預留位置來使用。

而我們的自己實現的 curry 函數,本身並沒有掛載在任何物件上,所以將 curry 函數當做預設預留位置

使用預留位置,目的是改變引數傳遞的順序,所以在 curry 函數實現中,每次需要記錄是否使用了預留位置,並且記錄預留位置所代表的引數位置。

直接上程式碼:

/**
 * @param  fn           待柯里化的函數
 * @param  length       需要的引數個數,預設為函數的形參個數
 * @param  holder       預留位置,預設當前柯里化函數
 * @return {Function}   柯里化後的函數
 */
function curry(fn,length = fn.length,holder = curry){
    return _curry.call(this,fn,length,holder,[],[])
}
/**
 * 中轉函數
 * @param fn            柯里化的原函數
 * @param length        原函數需要的引數個數
 * @param holder        接收的預留位置
 * @param args          已接收的參數列
 * @param holders       已接收的預留位置位置列表
 * @return {Function}   繼續柯里化的函數 或 最終結果
 */
function _curry(fn,length,holder,args,holders){
    return function(..._args){
        //將引數複製一份,避免多次操作同一函數導致引數混亂
        let params = args.slice();
        //將預留位置位置列表複製一份,新增加的預留位置增加至此
        let _holders = holders.slice();
        //迴圈入參,追加引數 或 替換預留位置
        _args.forEach((arg,i)=>{
            //真實引數 之前存在預留位置 將預留位置替換為真實引數
            if (arg !== holder && holders.length) {
                let index = holders.shift();
                _holders.splice(_holders.indexOf(index),1);
                params[index] = arg;
            }
            //真實引數 之前不存在預留位置 將引數追加到參數列中
            else if(arg !== holder && !holders.length){
                params.push(arg);
            }
            //傳入的是預留位置,之前不存在預留位置 記錄預留位置的位置
            else if(arg === holder && !holders.length){
                params.push(arg);
                _holders.push(params.length - 1);
            }
            //傳入的是預留位置,之前存在預留位置 刪除原預留位置位置
            else if(arg === holder && holders.length){
                holders.shift();
            }
        });
        // params 中前 length 條記錄中不包含預留位置,執行函數
        if(params.length >= length && params.slice(0,length).every(i=>i!==holder)){
            return fn.apply(this,params);
        }else{
            return _curry.call(this,fn,length,holder,params,_holders)
        }
    }
}

驗證一下:

let fn = function(a, b, c, d, e) {
    console.log([a, b, c, d, e]);
}

let _ = {}; // 定義預留位置
let _fn = curry(fn,5,_);  // 將函數柯里化,指定所需的引數個數,指定所需的預留位置

_fn(1, 2, 3, 4, 5);                 // print: 1,2,3,4,5
_fn(_, 2, 3, 4, 5)(1);              // print: 1,2,3,4,5
_fn(1, _, 3, 4, 5)(2);              // print: 1,2,3,4,5
_fn(1, _, 3)(_, 4,_)(2)(5);         // print: 1,2,3,4,5
_fn(1, _, _, 4)(_, 3)(2)(5);        // print: 1,2,3,4,5
_fn(_, 2)(_, _, 4)(1)(3)(5);        // print: 1,2,3,4,5

我們已經完整實現了一個 curry 函數~~

到此這篇關於前端JavaScript徹底弄懂函數柯里化的文章就介紹到這了,更多相關JavaScript函數柯里化內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com