前端基础进阶(八):深刻详解函数的柯里化

配图与本文无关

柯里化是函数的一个比较高级的应用,想要理解它并不简单。所以我一直在思考应该如何更加表达才能让你们理解起来更加容易。javascript

如下是新版本讲解。高阶函数章节因为一些缘由并未公开,你们能够自行搜索学习

经过上一个章节的学习咱们知道,接收函数做为参数的函数,均可以叫作高阶函数。咱们经常利用高阶函数来封装一些公共的逻辑。前端

这一章咱们要学习的柯里化,其实就是高阶函数的一种特殊用法。java

柯里化是指这样一个函数(假设叫作createCurry),他接收函数A做为参数,运行后可以返回一个新的函数。而且这个新的函数可以处理函数A的剩余参数。面试

这样的定义可能不太好理解,咱们能够经过下面的例子配合理解。segmentfault

假若有一个接收三个参数的函数A。数组

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

又假如咱们有一个已经封装好了的柯里化通用函数createCurry。他接收bar做为参数,可以将A转化为柯里化函数,返回结果就是这个被转化以后的函数。闭包

var _A = createCurry(A);

那么_A做为createCurry运行的返回函数,他可以处理A的剩余参数。所以下面的运行结果都是等价的。app

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

函数A被createCurry转化以后获得柯里化函数_A,_A可以处理A的全部剩余参数。所以柯里化也被称为部分求值。函数

在简单的场景下,咱们能够不用借助柯里化通用式来转化获得柯里化函数,咱们能够凭借眼力本身封装。性能

例若有一个简单的加法函数,他可以将自身的三个参数加起来并返回计算结果。

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

那么add函数的柯里化函数_add则能够以下:

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

所以下面的运算方式是等价的。

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

固然,柯里化通用式具有更增强大的能力,咱们靠眼力本身封装的柯里化函数则自由度偏低。所以咱们仍然须要知道本身如何去封装这样一个柯里化的通用式。

首先经过_add能够看出,柯里化函数的运行过程实际上是一个参数的收集过程,咱们将每一次传入的参数收集起来,并在最里层里面处理。所以咱们在实现createCurry时,能够借助这个思路来进行封装。

封装以下:

// 简单实现,参数只能从右到左传递
function createCurry(func, args) {

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

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

        // 若是参数个数小于最初的func.length,则递归调用,继续收集参数
        if (_args.length < arity) {
            return createCurry.call(this, func, _args);
        }

        // 参数收集完毕,则执行func
        return func.apply(this, _args);
    }
}

尽管我已经作了足够详细的注解,可是我想理解起来也并非那么容易,所以建议你们用点耐心多阅读几遍。这个createCurry函数的封装借助闭包与递归,实现了一个参数收集,并在收集完毕以后执行全部参数的一个过程。

所以聪明的读者可能已经发现,把函数通过createCurry转化为一个柯里化函数,最后执行的结果,不是正好至关于执行函数自身吗?柯里化是否是把简单的问题复杂化了?

若是你可以提出这样的问题,那么说明你确实已经对柯里化有了必定的了解。柯里化确实是把简答的问题复杂化了,可是复杂化的同时,咱们在使用函数时拥有了更加多的自由度。而这里对于函数参数的自由处理,正是柯里化的核心所在。

咱们来举一个很是常见的例子。

若是咱们想要验证一串数字是不是正确的手机号,那么按照普通的思路来作,你们多是这样封装,以下:

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

而若是咱们想要验证是不是邮箱呢?这么封装:

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

咱们还可能会遇到验证身份证号,验证密码等各类验证信息,所以在实践中,为了统一逻辑,,咱们就会封装一个更为通用的函数,将用于验证的正则与将要被验证的字符串做为参数传入。

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

可是这样封装以后,在使用时又会稍微麻烦一点,由于会老是输入一串正则,这样就致使了使用时的效率低下。

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

那么这个时候,咱们就能够借助柯里化,在check的基础上再作一层封装,以简化使用。

var _check = createCurry(check);

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

最后在使用的时候就会变得更加直观与简洁了。

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

通过这个过程咱们发现,柯里化可以应对更加复杂的逻辑封装。当状况变得多变,柯里化依然可以应付自如。

虽然柯里化确实在必定程度上将问题复杂化了,也让代码更加不容易理解,可是柯里化在面对复杂状况下的灵活性却让咱们不得不爱。

固然这个案例自己状况还算简单,因此还不可以特别明显的凸显柯里化的优点,咱们的主要目的在于借助这个案例帮助你们了解柯里化在实践中的用途。

咱们继续来思考一个例子。这个例子与map有关。在高阶函数的章节中,咱们分析了封装map方法的思考过程。因为咱们没有办法确认一个数组在遍历时会执行什么操做,所以咱们只能将调用for循环的这个统一逻辑封装起来,而具体的操做则经过参数传入的形式让使用者自定义。这就是map函数。

可是,这是针对了全部的状况咱们才会这样想。

实践中咱们经常会发现,在咱们的某个项目中,针对于某一个数组的操做实际上是固定的,也就是说,一样的操做,可能会在项目的不一样地方调用不少次。

因而,这个时候,咱们就能够在map函数的基础上,进行二次封装,以简化咱们在项目中的使用。假如这个在咱们项目中会调用屡次的操做是将数组的每一项都转化为百分比 1 --> 100%。

普通思惟下咱们能够这样来封装。

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

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

而若是借助柯里化来二次封装这样的逻辑,则会以下实现:

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%']

若是咱们的项目中的固定操做是但愿对数组进行一个过滤,找出数组中的全部Number类型的数据。借助柯里化思惟咱们能够这样作。

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]

// 当咱们继续封装另外的过滤操做时就会变得很是简单
// 找出数字为20的子项
var find20 = _find(function(item, i) {
    if (typeof item === 20) {
        return i;
    }
})
find20([1, 2, 3, 30, 20, 100]);  // 4

// 找出数组中大于100的全部数据
var findGreater100 = _find(function(item) {
    if (item > 100) {
        return item;
    }
})
findGreater100([1, 2, 101, 300, 2, 122]); // [101, 300, 122]

我采用了与check例子不同的思惟方向来想你们展现咱们在使用柯里化时的想法。目的是想告诉你们,柯里化可以帮助咱们应对更多更复杂的场景。

固然不得不认可,这些例子都太简单了,简单到若是使用柯里化的思惟来处理他们显得有一点画蛇添足,并且变得难以理解。所以我想读者朋友们也很难从这些例子中感觉到柯里化的魅力。不过不要紧,若是咱们可以经过这些例子掌握到柯里化的思惟,那就是最好的结果了。在将来你的实践中,若是你发现用普通的思惟封装一些逻辑慢慢变得困难,不妨想想在这里学到的柯里化思惟,应用起来,柯里化足够强大的自由度必定能给你一个惊喜。

固然也并不建议在任何状况下以炫技为目的的去使用柯里化,在柯里化的实现中,咱们知道柯里化虽然具备了更多的自由度,但同时柯里化通用式里调用了arguments对象,使用了递归与闭包,所以柯里化的自由度是以牺牲了必定的性能为代价换来的。只有在状况变得复杂时,才是柯里化大显身手的时候。

额外知识补充

无限参数的柯里化。

该部份内容可忽略

在前端面试中,你可能会遇到这样一个涉及到柯里化的题目。

// 实现一个add方法,使计算结果可以知足以下预期:
add(1)(2)(3) = 6;
add(1, 2, 3)(4) = 10;
add(1)(2)(3)(4)(5) = 15;

这个题目的目的是想让add执行以后返回一个函数可以继续执行,最终运算的结果是全部出现过的参数之和。而这个题目的难点则在于参数的不固定。咱们不知道函数会执行几回。所以咱们不能使用上面咱们封装的createCurry的通用公式来转换一个柯里化函数。只能本身封装,那么怎么办呢?在此以前,补充2个很是重要的知识点。

一个是ES6函数的不定参数。假如咱们有一个数组,但愿把这个数组中全部的子项展开传递给一个函数做为参数。那么咱们应该怎么作?

// 你们能够思考一下,若是将args数组的子项展开做为add的参数传入
function add(a, b, c, d) {
    return a + b + c + d;
}
var args = [1, 3, 100, 1];

在ES5中,咱们能够借助以前学过的apply来达到咱们的目的。

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

而在ES6中,提供了一种新的语法来解决这个问题,那就是不定参。写法以下:

add(...args);  // 105

这两种写法是等效的。OK,先记在这里。在接下的实现中,咱们会用到不定参数的特性。

第二个要补充的知识点是函数的隐式转换。当咱们直接将函数参与其余的计算时,函数会默认调用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

当咱们同时重写函数的toString方法与valueOf方法时,最终的结果会取valueOf方法的返回结果。

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

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

补充了这两个知识点以后,咱们能够来尝试完成以前的题目了。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);   // 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

// 能够利用隐式转换的特性参与计算
console.log(a + 10); // 20
console.log(b + 20); // 30
console.log(c + 30); // 40
console.log(d + 40); // 50

// 也能够继续传入参数,获得的结果再次利用隐式转换参与计算
console.log(a(10) + 100);  // 120
console.log(b(10) + 100);  // 120
console.log(c(10) + 100);  // 120
console.log(d(10) + 100);  // 120
// 其实上栗中的add方法,就是下面这个函数的柯里化函数,只不过咱们并无使用通用式来转化,而是本身封装
function add(...args) {
    return args.reduce((a, b) => a + b);
}
如下为老版本讲解,请勿阅读学习,由于部分思惟并不彻底正确。
1、补充知识点之函数的隐式转换

JavaScript做为一种弱类型语言,它的隐式转换是很是灵活有趣的。当咱们没有深刻了解隐式转换的时候可能会对一些运算的结果会感动困惑,好比4 + true = 5。固然,若是对隐式转换了解足够深入,确定是可以很大程度上提升对js的使用能力。只是我没有打算将全部的隐式转换规则分享给你们,这里暂时只分享一下,函数在隐式转换中的一些规则。

来一个简单的思考题。

function 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后执行

所以上面例子的结论就很容易理解了。建议你们动手尝试一下。

2、补充知识点之利用call/apply封数组的map方法
map(): 对数组中的每一项运行给定函数,返回每次函数调用的结果组成的数组。

通俗来讲,就是遍历数组的每一项元素,而且在map的第一个参数(回调函数)中进行运算处理后返回计算结果。返回一个由全部计算结果组成的新数组。

// 回调函数中有三个参数
// 第一个参数表示newArr的每一项,第二个参数表示该项在数组中的索引值
// 第三个表示数组自己
// 除此以外,回调函数中的this,当map不存在第二参数时,this指向丢失,当存在第二个参数时,指向改参数所设定的对象
var newArr = [1, 2, 3, 4].map(function(item, i, arr) {
    console.log(item, i, arr, this);  // 可运行试试看
    return item + 1;  // 每一项加1
}, { a: 1 })

console.log(newArr); // [2, 3, 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, 2, 3, 4]._map(function(item) {
    return item + 1;
})
// [2, 3, 4, 5]

在上面的封装中,我首先定义了一个空的temp数组,该数组用来存储最终的返回结果。在for循环中,每循环一次,就执行一次参数fn函数,fn的参数则使用call方法传入。

在理解了map的封装过程以后,咱们就可以明白为何咱们在使用map时,老是指望可以在第一个回调函数中有一个返回值了。在eslint的规则中,若是咱们在使用map时没有设置一个返回值,就会被断定为错误。

ok,明白了函数的隐式转换规则与call/apply在这种场景的使用方式,咱们就能够尝试经过简单的例子来了解一下柯里化了。

3、由浅入深的柯里化

在前端面试中有一个关于柯里化的面试题,流传甚广。

实现一个add方法,使计算结果可以知足以下预期:
add(1)(2)(3) = 6
add(1, 2, 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, 2, 3, 4, 5));  // 15
console.log(add(1, 2, 3, 4)(5));  // 15
console.log(add(1)(2)(3)(4)(5));  // 15

上面的实现,利用闭包的特性,主要目的是想经过一些巧妙的方法将全部的参数收集在一个数组里,并在最终隐式转换时将数组里的全部项加起来。所以咱们在调用add方法的时候,参数就显得很是灵活。固然,也就很轻松的知足了咱们的需求。

那么读懂了上面的demo,而后咱们再来看看柯里化的定义,相信你们就会更加容易理解了。

柯里化(英语:Currying),又称为部分求值,是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,而且返回一个新的函数的技术,新函数接受余下参数并返回运算结果。
  • 接收单一参数,由于要携带很多信息,所以经常以回调函数的理由来解决。
  • 将部分参数经过回调函数等方式传入函数中
  • 返回一个新函数,用于处理全部的想要传入的参数

在上面的例子中,咱们能够将add(1, 2, 3, 4)转换为add(1)(2)(3)(4)。这就是部分求值。每次传入的参数都只是咱们想要传入的全部参数中的一部分。固然实际应用中,并不会经常这么复杂的去处理参数,不少时候也仅仅只是分红两部分而已。

我们再来一块儿思考一个与柯里化相关的问题。

假若有一个计算要求,须要咱们将数组里面的每一项用咱们本身想要的字符给连起来。咱们应该怎么作?想到使用join方法,就很简单。
var arr = [1, 2, 3, 4, 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, 2, 3, 4, 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, 2, 3, 4, 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); // 0~1~2~3~4

机智的小伙伴确定发现困惑所在了。咱们指望封装一个函数,能同时处理不一样的运算过程,可是咱们并不能使用一个固定的套路将对每一项的操做都封装起来。因而问题就变成了和封装map的时候所面临的问题同样了。咱们能够借助柯里化来搞定。

与map封装一样的道理,既然咱们事先并不肯定咱们将要对每一项数据进行怎么样的处理,我只是知道咱们须要将他们处理以后而后用字符连起来,因此不妨将处理内容保存在一个函数里。而仅仅固定封装连起来的这一部分需求。

因而咱们就有了如下的封装。

// 封装很简单,一句话搞定
Array.prototype.merge = function(fn, chars) {
    return this.map(fn).join(chars);
}

var arr = [1, 2, 3, 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

你们能从上面的例子,发现柯里化的特征吗?

4、柯里化通用式

通用的柯里化写法其实比咱们上边封装的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
5、柯里化与bind
Object.prototype.bind = function(context) {
    var _this = this;
    var args = [].slice.call(arguments, 1);

    return function() {
        return _this.apply(context, args)
    }
}

这个例子利用call与apply的灵活运用,实现了bind的功能。

在前面的几个例子中,咱们能够总结一下柯里化的特色:

  • 接收单一参数,将更多的参数经过回调函数来搞定?
  • 返回一个新函数,用于处理全部的想要传入的参数;
  • 须要利用call/apply与arguments对象收集参数;
  • 返回的这个函数正是用来处理收集起来的参数。

但愿你们读完以后都可以大概明白柯里化的概念,若是想要熟练使用它,就须要咱们掌握更多的实际经验才行。

前端基础进阶系列目录

clipboard.png

相关文章
相关标签/搜索