前端基礎(chǔ)進(jìn)階(十):深入詳解函數(shù)的柯里化

配圖與本文無關(guān)

柯里化是函數(shù)的一個(gè)高級(jí)應(yīng)用,想要理解它并不簡單。因此我一直在思考應(yīng)該如何更加表達(dá)才能讓大家理解起來更加容易。

通過上一個(gè)章節(jié)的學(xué)習(xí)我們知道,接收函數(shù)作為參數(shù)的函數(shù),都可以叫做高階函數(shù)。我們常常利用高階函數(shù)來封裝一些公共的邏輯。

這一章我們要學(xué)習(xí)的柯里化,其實(shí)就是高階函數(shù)的一種特殊用法。

柯里化是指這樣一個(gè)函數(shù)(假設(shè)叫做createCurry),他接收函數(shù)A作為參數(shù),運(yùn)行后能夠返回一個(gè)新的函數(shù)。并且這個(gè)新的函數(shù)能夠處理函數(shù)A的剩余參數(shù)。

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

有一個(gè)接收三個(gè)參數(shù)的函數(shù)A。

function A(a, b, c) {
    // do something
}

假如,我們有一個(gè)已經(jīng)封裝好了的柯里化通用函數(shù)createCurry。他接收bar作為參數(shù),能夠?qū)轉(zhuǎn)化為柯里化函數(shù),返回結(jié)果就是這個(gè)被轉(zhuǎn)化之后的函數(shù)。

var _A = createCurry(A);

那么_A作為createCurry運(yùn)行的返回函數(shù),他能夠處理A的剩余參數(shù)。因此下面的運(yùn)行結(jié)果都是等價(jià)的。

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

函數(shù)A被createCurry轉(zhuǎn)化之后得到柯里化函數(shù)_A,_A能夠處理A的所有剩余參數(shù)。因此柯里化也被稱為部分求值。

在簡單的場景下,可以不用借助柯里化通用式來轉(zhuǎn)化得到柯里化函數(shù),我們憑借眼力自己封裝。

例如有一個(gè)簡單的加法函數(shù),他能夠?qū)⒆陨淼娜齻€(gè)參數(shù)加起來并返回計(jì)算結(jié)果。

function add(a, b, c) {
    return a + b + c;
}

那么add函數(shù)的柯里化函數(shù)_add則可以如下:

function _add(a) {
    return function(b) {
        return function(c) {
            return a + b + c;
        }
    }
}

下面的運(yùn)算方式是等價(jià)的。

add(1, 2, 3);
_add(1)(2)(3);

當(dāng)然,靠眼力封裝的柯里化函數(shù)自由度偏低,柯里化通用式具備更加強(qiáng)大的能力。因此我們需要知道如何去封裝這樣一個(gè)柯里化的通用式。

首先通過_add可以看出,柯里化函數(shù)的運(yùn)行過程其實(shí)是一個(gè)參數(shù)的收集過程,我們將每一次傳入的參數(shù)收集起來,并在最里層里面處理。在實(shí)現(xiàn)createCurry時(shí),可以借助這個(gè)思路來進(jìn)行封裝。

封裝如下:

// 簡單實(shí)現(xiàn),參數(shù)只能從右到左傳遞
function createCurry(func, args) {

    var arity = func.length;
    var args = args || [];

    return function() {
        var _args = [].slice.call(arguments);
        [].push.apply(_args, args);

        // 如果參數(shù)個(gè)數(shù)小于最初的func.length,則遞歸調(diào)用,繼續(xù)收集參數(shù)
        if (_args.length < arity) {
            return createCurry.call(this, func, _args);
        }

        // 參數(shù)收集完畢,則執(zhí)行func
        return func.apply(this, _args);
    }
}

盡管我已經(jīng)做了足夠詳細(xì)的注解,但是我想理解起來也并不是那么容易,因此建議大家用點(diǎn)耐心多閱讀幾遍。這個(gè)createCurry函數(shù)的封裝借助閉包與遞歸,實(shí)現(xiàn)了一個(gè)參數(shù)收集,并在收集完畢之后執(zhí)行所有參數(shù)的一個(gè)過程。

聰明的讀者可能已經(jīng)發(fā)現(xiàn),把函數(shù)經(jīng)過createCurry轉(zhuǎn)化為一個(gè)柯里化函數(shù),最后執(zhí)行的結(jié)果,不是正好相當(dāng)于執(zhí)行函數(shù)自身嗎?柯里化是不是把簡單的問題復(fù)雜化了?

如果你能夠提出這樣的問題,那么說明你確實(shí)已經(jīng)對柯里化有了一定的了解??吕锘_實(shí)是把簡答的問題復(fù)雜化了,但是復(fù)雜化的同時(shí),我們使用函數(shù)擁有了更加多的自由度。而這里對于函數(shù)參數(shù)的自由處理,正是柯里化的核心所在。

舉一個(gè)非常常見的例子。

如果我們想要驗(yàn)證一串?dāng)?shù)字是否是正確的手機(jī)號(hào),按照普通的思路來做,大家可能是這樣封裝,如下:

function checkPhone(phoneNumber) {
    return /^1[34578]\d{9}$/.test(phoneNumber);
}

而如果想要驗(yàn)證是否是郵箱呢?這么封裝:

function checkEmail(email) {
    return /^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/.test(email);
}

我們還可能會(huì)遇到驗(yàn)證身份證號(hào),驗(yàn)證密碼等各種驗(yàn)證信息,因此在實(shí)踐中,為了統(tǒng)一邏輯,我們就會(huì)封裝一個(gè)更為通用的函數(shù),將用于驗(yàn)證的正則與將要被驗(yàn)證的字符串作為參數(shù)傳入。

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

但是這樣封裝之后,在使用時(shí)又會(huì)稍微麻煩一點(diǎn),因?yàn)闀?huì)總是輸入一串正則,這樣就導(dǎo)致了使用時(shí)的效率低下。

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

這個(gè)時(shí)候,我們就可以借助柯里化,在check的基礎(chǔ)上再做一層封裝,以簡化使用。

var _check = createCurry(check);

var checkPhone = _check(/^1[34578]\d{9}$/);
var checkEmail = _check(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/);

最后在使用的時(shí)候就會(huì)變得更加直觀與簡潔了。

checkPhone('183888888');
checkEmail('xxxxx@test.com');

經(jīng)過這個(gè)過程我們發(fā)現(xiàn),柯里化能夠應(yīng)對更加復(fù)雜的邏輯封裝。當(dāng)情況變得多變,柯里化依然能夠應(yīng)付自如。

雖然柯里化確實(shí)在一定程度上將問題復(fù)雜化了,也讓代碼更加不容易理解,但是柯里化在面對復(fù)雜情況下的靈活性卻讓我們不得不愛。

當(dāng)然這個(gè)案例本身情況還算簡單,所以還不能夠特別明顯的凸顯柯里化的優(yōu)勢,我們的主要目的在于借助這個(gè)案例幫助大家了解柯里化在實(shí)踐中的用途。

繼續(xù)來思考一個(gè)例子。這個(gè)例子與map有關(guān)。在高階函數(shù)的章節(jié)中,我們分析了封裝map方法的思考過程。由于我們沒有辦法確認(rèn)一個(gè)數(shù)組在遍歷時(shí)會(huì)執(zhí)行什么操作,因此我們只能將調(diào)用for循環(huán)的這個(gè)統(tǒng)一邏輯封裝起來,而具體的操作則通過參數(shù)傳入的形式讓使用者自定義。這就是map函數(shù)。

但是,這是針對了所有的情況我們才會(huì)這樣想。

實(shí)踐中我們常常會(huì)發(fā)現(xiàn),在我們的某個(gè)項(xiàng)目中,針對于某一個(gè)數(shù)組的操作其實(shí)是固定的,也就是說,同樣的操作,可能會(huì)在項(xiàng)目的不同地方調(diào)用很多次。

于是,這個(gè)時(shí)候,我們就可以在map函數(shù)的基礎(chǔ)上,進(jìn)行二次封裝,以簡化我們在項(xiàng)目中的使用。假如這個(gè)在我們項(xiàng)目中會(huì)調(diào)用多次的操作是將數(shù)組的每一項(xiàng)都轉(zhuǎn)化為百分比 1 --> 100%。

普通思維下我們可以這樣來封裝。

function getNewArray(array) {
    return array.map(function(item) {
        return item * 100 + '%'
    })
}

getNewArray([1, 2, 3, 0.12]);   // ['100%', '200%', '300%', '12%'];

而如果借助柯里化來二次封裝這樣的邏輯,則會(huì)如下實(shí)現(xiàn):

function _map(func, array) {
    return array.map(func);
}

var _getNewArray = createCurry(_map);

var getNewArray = _getNewArray(function(item) {
    return item * 100 + '%'
})

getNewArray([1, 2, 3, 0.12]);   // ['100%', '200%', '300%', '12%'];
getNewArray([0.01, 1]); // ['1%', '100%']

如果我們的項(xiàng)目中的固定操作是希望對數(shù)組進(jìn)行一個(gè)過濾,找出數(shù)組中的所有Number類型的數(shù)據(jù)。借助柯里化思維我們可以這樣做。

function _filter(func, array) {
    return array.filter(func);
}

var _find = createCurry(_filter);

var findNumber = _find(function(item) {
    if (typeof item == 'number') {
        return item;
    }
})

findNumber([1, 2, 3, '2', '3', 4]); // [1, 2, 3, 4]

// 當(dāng)我們繼續(xù)封裝另外的過濾操作時(shí)就會(huì)變得非常簡單
// 找出數(shù)字為20的子項(xiàng)
var find20 = _find(function(item, i) {
    if (typeof item === 20) {
        return i;
    }
})
find20([1, 2, 3, 30, 20, 100]);  // 4

// 找出數(shù)組中大于100的所有數(shù)據(jù)
var findGreater100 = _find(function(item) {
    if (item > 100) {
        return item;
    }
})
findGreater100([1, 2, 101, 300, 2, 122]); // [101, 300, 122]

我采用了與check例子不一樣的思維方向來想大家展示我們在使用柯里化時(shí)的想法。目的是想告訴大家,柯里化能夠幫助我們應(yīng)對更多更復(fù)雜的場景。

當(dāng)然不得不承認(rèn),這些例子都太簡單了,簡單到如果使用柯里化的思維來處理他們顯得有一點(diǎn)多此一舉,而且變得難以理解。因此我想讀者朋友們也很難從這些例子中感受到柯里化的魅力。不過沒關(guān)系,如果我們能夠通過這些例子掌握到柯里化的思維,那就是最好的結(jié)果了。在未來你的實(shí)踐中,如果你發(fā)現(xiàn)用普通的思維封裝一些邏輯慢慢變得困難,不妨想一想在這里學(xué)到的柯里化思維,應(yīng)用起來,柯里化足夠強(qiáng)大的自由度一定能給你一個(gè)驚喜。

當(dāng)然也并不建議在任何情況下以炫技為目的的去使用柯里化,在柯里化的實(shí)現(xiàn)中,我們知道柯里化雖然具有了更多的自由度,但同時(shí)柯里化通用式里調(diào)用了arguments對象,使用了遞歸與閉包,因此柯里化的自由度是以犧牲了一定的性能為代價(jià)換來的。只有在情況變得復(fù)雜時(shí),才是柯里化大顯身手的時(shí)候。

額外知識(shí)補(bǔ)充

無限參數(shù)的柯里化。

該部分內(nèi)容可忽略

在前端面試中,你可能會(huì)遇到這樣一個(gè)涉及到柯里化的題目。

// 實(shí)現(xiàn)一個(gè)add方法,使計(jì)算結(jié)果能夠滿足如下預(yù)期:
add(1)(2)(3) = 6;
add(1, 2, 3)(4) = 10;
add(1)(2)(3)(4)(5) = 15;

這個(gè)題目的目的是想讓add執(zhí)行之后返回一個(gè)函數(shù)能夠繼續(xù)執(zhí)行,最終運(yùn)算的結(jié)果是所有出現(xiàn)過的參數(shù)之和。而這個(gè)題目的難點(diǎn)則在于參數(shù)的不固定。我們不知道函數(shù)會(huì)執(zhí)行幾次。因此我們不能使用上面我們封裝的createCurry的通用公式來轉(zhuǎn)換一個(gè)柯里化函數(shù)。只能自己封裝,那么怎么辦呢?在此之前,補(bǔ)充2個(gè)非常重要的知識(shí)點(diǎn)。

一個(gè)是ES6函數(shù)的不定參數(shù)。假如我們有一個(gè)數(shù)組,希望把這個(gè)數(shù)組中所有的子項(xiàng)展開傳遞給一個(gè)函數(shù)作為參數(shù)。那么我們應(yīng)該怎么做?

// 大家可以思考一下,如果將args數(shù)組的子項(xiàng)展開作為add的參數(shù)傳入
function add(a, b, c, d) {
    return a + b + c + d;
}
var args = [1, 3, 100, 1];

在ES5中,我們可以借助之前學(xué)過的apply來達(dá)到我們的目的。

add.apply(null, args);  // 105

而在ES6中,提供了一種新的語法來解決這個(gè)問題,那就是不定參。寫法如下:

add(...args);  // 105

這兩種寫法是等效的。OK,先記在這里。在接下的實(shí)現(xiàn)中,我們會(huì)用到不定參數(shù)的特性。

第二個(gè)要補(bǔ)充的知識(shí)點(diǎn)是函數(shù)的隱式轉(zhuǎn)換。當(dāng)我們直接將函數(shù)參與其他的計(jì)算時(shí),函數(shù)會(huì)默認(rèn)調(diào)用toString方法,直接將函數(shù)體轉(zhuǎn)換為字符串參與計(jì)算。

function fn() { return 20 }
console.log(fn + 10);     // 輸出結(jié)果 function fn() { return 20 }10

我們可以重寫函數(shù)的toString方法,讓函數(shù)參與計(jì)算時(shí),輸出我們想要的結(jié)果。

function fn() { return 20; }
fn.toString = function() { return 30 }

console.log(fn + 10); // 40

除此之外,當(dāng)我們重寫函數(shù)的valueOf方法也能夠改變函數(shù)的隱式轉(zhuǎn)換結(jié)果。

function fn() { return 20; }
fn.valueOf = function() { return 60 }

console.log(fn + 10); // 70

當(dāng)我們同時(shí)重寫函數(shù)的toString方法與valueOf方法時(shí),最終的結(jié)果會(huì)取valueOf方法的返回結(jié)果。

function fn() { return 20; }
fn.valueOf = function() { return 50 }
fn.toString = function() { return 30 }

console.log(fn + 10); // 60

補(bǔ)充了這兩個(gè)知識(shí)點(diǎn)之后,我們可以來嘗試完成之前的題目了。add方法的實(shí)現(xiàn)仍然會(huì)是一個(gè)參數(shù)的收集過程。當(dāng)add函數(shù)執(zhí)行到最后時(shí),仍然返回的是一個(gè)函數(shù),但是我們可以通過定義toString/valueOf的方式,讓這個(gè)函數(shù)可以直接參與計(jì)算,并且轉(zhuǎn)換的結(jié)果是我們想要的。而且它本身也仍然可以繼續(xù)執(zhí)行接收新的參數(shù)。實(shí)現(xiàn)方式如下。

function add() {
    // 第一次執(zhí)行時(shí),定義一個(gè)數(shù)組專門用來存儲(chǔ)所有的參數(shù)
    var _args = [].slice.call(arguments);

    // 在內(nèi)部聲明一個(gè)函數(shù),利用閉包的特性保存_args并收集所有的參數(shù)值
    var adder = function () {
        var _adder = function() {
            // [].push.apply(_args, [].slice.call(arguments));
            _args.push(...arguments);
            return _adder;
        };

        // 利用隱式轉(zhuǎn)換的特性,當(dāng)最后執(zhí)行時(shí)隱式轉(zhuǎn)換,并計(jì)算最終的值返回
        _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);   // f 10
var b = add(1, 2, 3, 4);   // f 10
var c = add(1, 2)(3, 4);   // f 10
var d = add(1, 2, 3)(4);   // f 10

// 可以利用隱式轉(zhuǎn)換的特性參與計(jì)算
console.log(a + 10); // 20
console.log(b + 20); // 30
console.log(c + 30); // 40
console.log(d + 40); // 50

// 也可以繼續(xù)傳入?yún)?shù),得到的結(jié)果再次利用隱式轉(zhuǎn)換參與計(jì)算
console.log(a(10) + 100);  // 120
console.log(b(10) + 100);  // 120
console.log(c(10) + 100);  // 120
console.log(d(10) + 100);  // 120
// 其實(shí)上栗中的add方法,就是下面這個(gè)函數(shù)的柯里化函數(shù),只不過我們并沒有使用通用式來轉(zhuǎn)化,而是自己封裝
function add(...args) {
    return args.reduce((a, b) => a + b);
}

下一篇:前端基礎(chǔ)進(jìn)階(十一):詳解面向?qū)ο?、?gòu)造函數(shù)、原型與原型鏈
上一篇:前端基礎(chǔ)進(jìn)階(九):函數(shù)與函數(shù)式編程
前端基礎(chǔ)進(jìn)階目錄

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容