「前端進階」徹底弄懂函式柯里化

雲中橋發表於2019-07-08

你知道的越多,你不知道的越多
點贊再看,手留餘香,與有榮焉

前言

隨著主流JavaScript中函數語言程式設計的迅速發展, 函式柯里化在許多應用程式中已經變得很普遍。 瞭解它們是什麼,它們如何工作以及如何充分利用它們非常重要。

什麼是柯里化( 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(/^1\d{10}$/, '18642838455'); // 校驗電話號碼
checkByRegExp(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/, 'test@163.com'); // 校驗郵箱
複製程式碼

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

我們可能會這樣做:

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

checkByRegExp(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/, 'test@163.com'); // 校驗郵箱
checkByRegExp(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/, 'test@qq.com'); // 校驗郵箱
checkByRegExp(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/, 'test@gmail.com'); // 校驗郵箱
複製程式碼

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

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

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

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

checkEmail('test@163.com'); // 校驗郵箱
checkEmail('test@qq.com'); // 校驗郵箱
checkEmail('test@gmail.com'); // 校驗郵箱
複製程式碼

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

經過柯里化後,我們生成了兩個函式 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 函式~~

系列文章推薦

寫在最後

  • 文中如有錯誤,歡迎在評論區指正,如果這篇文章幫到了你,歡迎點贊關注
  • 本文同步首發與github,可在github中找到更多精品文章,歡迎Watch & Star ★
  • 後續文章參見:計劃

歡迎關注微信公眾號【前端小黑屋】,每週1-3篇精品優質文章推送,助你走上進階之旅

「前端進階」徹底弄懂函式柯里化

相關文章