八、JavaScript专题之函数柯里化

函数式编程是一种编程风格,它尝试将函数作为参数传递(回调)并返回没有副作用的函数。因此带来了一些其他东西,比如纯函数、柯里化、高阶函数。
这里我们主要讨论函数柯里化的实现与应用场景。

什么是柯里化

柯里化是函数式编程中的一个过程,我们可以将具有多个参数的函数转换为一系列嵌套函数。它返回一个新函数,该函数能接受下一个参数。

柯里化是将具有多元数的函数变成具有较少元数的函数的过程 - Kristina Brainwave

举个例子:

1
2
3
4
5
function multiply(a, b, c) {
return a * b * c;
}
// 执行multiply方法,传入三个参数
multiply(1, 2, 3); // 6

现在我们创建一个柯里化函数版本:

1
2
3
4
5
6
7
8
function multiply(a) {
return (b) => {
return (c) => {
return a * b * c;
};
};
}
multiply(1)(2)(3); // 6

我们已经把 multiply(1,2,3) 函数调用变成了 multiply(1)(2)(3)多个函数的调用。

而对于 Javascript 语言来说,我们通常说的柯里化函数的概念,与数学和计算机科学中的柯里化的概念并不完全一样。
在数学和计算机科学中的柯里化函数,一次只能传递一个参数;
而我们 Javascript 实际应用中的柯里化函数,可以传递一个或多个参数。
来看这个例子。

1
2
3
4
5
6
7
8
9
10
11
//普通函数
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 ,接收两个参数,校验的正则对象和待校验的字符串:

1
2
3
4
5
6
function checkByRegExp(regExp, string) {
return regExp.test(string);
}

checkByRegExp(/^1\d{10}$/, "18642838455"); // 校验电话号码
checkByRegExp(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/, "test@163.com"); // 校验邮箱

我们每次进行校验的时候都需要输入一串正则,再校验同一类型的数据时,相同的正则我们需要写多次,
这就导致我们在使用的时候效率低下,并且由于 checkByRegExp 函数本身是一个工具函数并没有任何意义,
一段时间后我们重新来看这些代码时,如果没有注释,我们必须通过检查正则的内容,
我们才能知道我们校验的是电话号码还是邮箱,还是别的什么。
此时,我们可以借助柯里化对 checkByRegExp 函数进行封装,以简化代码书写,提高代码可读性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//进行柯里化
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 相比,从功能上通用性降低了,但适用性提升了。
柯里化的这种用途可以被理解为:参数复用。

我们再来看一个例子,比如我们有这样一段数据:

1
var person = [{ name: "lilei" }, { name: "hanmeimei" }];

如果我们要获取那么属性,我们可以这样:

1
var names = person.map((item) => item.name);

如果我们有 curry 函数:

1
2
3
4
5
var prop = curry(function (key, obj) {
return obj[key];
});

var name = person.map(prop("name"));

这里我们为了获取 name 属性编写一个 prop 函数,你可能会觉得太麻烦了。
但是要注意,prop 函数编写一次后,以后可以多次使用,我们在考虑代码复杂的的时候,是可以将 prop 函数的实现去掉的,实际上代码从原本的三行精简成了一行。

柯里化工具函数封装

常见 curry 函数的实现为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var curry = function (fn) {
var args = [].slice.call(arguments);
return function () {
var newArgs = args.concat([].slice.call(arguments));
return fn.apply(this, newArgs);
};
};
// 使用
function add(a, b) {
return a + b;
}
var addCurry = curry(add, 1, 2);
addCurry(); // 3
//或者
var addCurry = curry(add, 1);
addCurry(2); // 3
//或者
var addCurry = curry(add);
addCurry(1, 2); // 3

已经有柯里化的感觉了,但是还没有达到要求,不过我们可以把这个函数用作辅助函数,帮助我们写真正的 curry 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
function sub_curry(fn) {
var args = [].slice.call(arguments, 1);
return function () {
return fn.apply(this, args.concat([].slice.call(arguments)));
};
}

function curry(fn, length) {
length = length || fn.length;

var slice = Array.prototype.slice;

return function () {
if (arguments.length < length) {
var combined = [fn].concat(slice.call(arguments));
var sub_fn = sub_curry.apply(this, combined);
return curry(sub_fn, length - arguments.length);
} else {
return fn.apply(this, arguments);
}
};
}

// 测试
var fn = curry(function (a, b, c) {
return [a, b, c];
});

fn("a", "b", "c"); // ["a", "b", "c"]
fn("a", "b")("c"); // ["a", "b", "c"]
fn("a")("b")("c"); // ["a", "b", "c"]
fn("a")("b", "c"); // ["a", "b", "c"]

如果上面的实现不好理解的话,我们换一种写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function sun_curry(fn, ...args) {
return function (...params) {
return fn.apply(this, [...args, ...params]);
};
}

function curry(fn, length = fn.length) {
return function (...params) {
if (params.length < length) {
var sub_fn = sub_curry.apply(this, [fn, ...params]);
return curry(sub_fn, length - params.length);
} else {
fn.apply(this, params);
}
};
}

sun_curry 方法的作用在与接收一个函数和若干参数,然后返回一个函数,该函数能接收后续若干参数。
curry 方法的作用就是递归调用辅助函数 sub_curry,实现函数柯里化。

参考文献

https://juejin.cn/post/6844903882208837645