# 函数柯里化

# 什么是柯里化

柯里化是一种把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。

这是维基百科 (opens new window)的说法

在数学和计算机科学中,柯里化是一种将使用多个参数的一个函数转换成一系列使用一个参数的函数的技术。

而对于Javascript语言来说,我们通常说的柯里化函数的概念,与数学和计算机科学中的柯里化的概念并不完全一样。

  • 在数学和计算机科学中的柯里化函数,一次只能传递一个参数;
  • 而我们Javascript实际应用中的柯里化函数,可以传递一个或多个参数。

是不是有点难理解,有点绕, 我们用代码例子说明下,比如:

function add (a, b, c) {
    console.log(a + b + c);
}
add(1, 2, 3); // 6

这个add(1, 2, 3)函数接收三个参数, 把add转成柯里化函数呢,就是把它转成可以 add(1)(2)(3),或者add(1, 2)(3)这样来调用

简单来说,柯里化是一种把一个 n个参数函数,转成可以把参数分开调用的多个函数,每调用一次会返回一个新的函数,直到你调用的参数达到n个,最终返回结果。

# 为什么要柯里化

上面说到柯里化就是把一个 n个参数函数,转成可以把参数分开调用的多个函数,那我明明可以一次调用完就可以了,你还转这么多次调用,这么麻烦,有什么好处?

  • 参数复用
  • 增加代码可读性

我们还是来举例子看好处:

比如我们工作中会遇到各种需要通过正则检验的需求,比如校验电话号码、校验邮箱、校验身份证号、校验密码等, 这时我们会封装一个通用函数 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'); // 校验邮箱

再来看看通过柯里化封装后,我们的代码是不是变得又简洁又直观了呢。

经过柯里化后,我们生成了两个函数 checkCellPhonecheckEmail

  • checkCellPhone 函数只能验证传入的字符串是否是电话号码,
  • checkEmail 函数只能验证传入的字符串是否是邮箱,

它们与 原函数 checkByRegExp 相比,从功能上通用性降低了,但适用性提升了

柯里化的这种用途可以被理解为:参数复用

# 柯里化函数的实现

接下来,我们来思考如何实现 curry 函数。

回想之前我们对于柯里化的定义,接收一部分参数,返回一个函数接收剩余参数,接收足够参数后,执行原函数。

我们已经知道了,当柯里化函数接收到足够参数后,就会执行原函数,那么我们如何去确定何时达到足够的参数呢?

我们有两种思路:

通过函数的 length 属性,获取函数的形参个数,形参的个数就是所需的参数个数

在调用柯里化工具函数时,手动指定所需的参数个数

我们将这两点结合以下,实现一个简单 curry 函数:

/**
 * 将函数柯里化
 * @param fn    待柯里化的原函数
 * @param len   所需的参数个数,默认为原函数的形参个数
 * @return {Function}   柯里化后的函数
 */
function curry(fn,len = fn.length) {
    return _curry.call(this,fn,len)
}

/**
 * 中转函数
 * @param fn    待柯里化的原函数
 * @param len   所需的参数个数
 * @param args  已接收的参数列表
 * @return {Function}   继续柯里化的函数 或 最终结果
 */
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

看到这里,理解上面的原理,和知道什么场景使用的话,恭喜大家,柯里化高阶函数已经掌握了。

按照我的理解,柯里化函数,就是当一个函数有多个行参的时候,而且我们频繁调用这个函数,有些参数都一样的时候,我们可以使用柯里化技术,把这个函数柯里化掉,缓存下来相同的参数,返回一个新的函数来方便使用。 就是参数复用了。(和我们使用vue开发组件原理一样,当我们有重复使用的代码多的时候,就会想办法抽取复用)

上面我们实现的柯里化函数,是不是只能按照函数行参的顺序来缓存复用,现在我们再进阶下,想想,如果我们想随意缓存复用任意一个形参呢?

这时我们可以使用行参占位符,使用占位符,目的是改变参数传递的顺序,所以在 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 函数!!!

大家多多好好理一理柯里化的原理,作用,使用场景,然后看如何实现的,自己能手写出来的话,思路就会进一步了。真不错,加油!

参考文章:

「前端进阶」彻底弄懂函数柯里化

柯里化(Currying)

更新时间: 2022年7月12日星期二上午11点08分