接上篇 JS 函数式编程的理解(1)
命令式与声明式数据流
假设有三行代码,我需要逐行阅读、理解计算中间态和主流程之间的逻辑关系,才能推导出程序的意图。这样的代码是命令式的。
const filteredArr = arr.filter(biggerThan2);
const multipledArr = filteredArr.map(multi2);
const sum = multipledArr.reduce(add, 0);
以上三行代码并非严格绑定的,filteredArr
和multipledArr
是计算sum
的两个关键的计算中间态,他们作为引用类型,完全有可能在运行过程中被修改。
与其寄望于禁止打断、绝不篡改
的 flag,不如一开始就不把计算中间态暴露出去。
链式调用
借助链式调用可以实现声明式的数据流。
const sum = arr.filter(biggerThan2).map(multi2).reduce(add, 0);
此时,我们只需要观察一个函数调用链。即使不清楚数据如何在传送带上流转,也可以通过函数名去理解程序的意图。
这样的代码,是声明式的。基于此构建出的数据流,就是声明式的数据流。
链式调用的限制
链式调用的本质,是通过在方法中返回对象实例本身的this
/ 与实例this
相同类型的对象,达到多次调用其原型(链)上方法的目的。
要对函数执行链式调用,前提是函数挂载在一个靠谱的宿主 Object 上。
所以对于那些没有挂载在对象上的函数(下称独立函数
)而言,需要通过组合
来实现声明式数据流。
函数组合
函数组合即反复嵌套各种回调函数。形如:
const add = (num) => num + 1;
const divide = (num) => num / 2;
const plus = (num) => num * 3;
const sum = add(divide(plus(num)));
然而如果函数嵌套较多,将形成回调地狱。
为解决这个问题,我们可以利用reduce
。只要想办法让reduce
工作流里的计算单元从一个函数转变为 N 个函数,就可以达到函数组合的目的。
具体而言,我们可以将待组合的函数放入一个函数里,然后调用这个函数数组的reduce
方法,就可以创建一个由多个函数组成的工作流。
而这就是市面上主流的函数式库实现compose/pipe
函数的思路。
使用reduce
实现组合
以下实现的是一元入参函数的组合。具体如何让不同入参个数的函数组合起来,需要依靠后面介绍的偏函数
和柯里化
。
- pipe 正序组合
function pipe(...funcs) {
return (params) => funcs.reduce((input, func) => func(input), param);
}
const compute = pipe(add, plus, divide);
console.log(compute(10));
- compose 倒序组合
只需将reduce
替换成reduceRight
function compose(...funcs) {
return (params) => funcs.reduceRight((input, func) => func(input), param);
}
const compute = compose(add, plus, divide);
console.log(compute(10));
偏函数与柯里化
偏函数和柯里化解决的最核心的问题有两个,分别是:
- 函数组合链中的多元参数问题
- 函数逻辑复用的问题
函数组合链中的多元参数对齐问题
函数参数的元数
,指的是函数参数的数量。比如,单个入参的函数为一元函数。
之前介绍的函数组合只实现了一元函数的组合,但如果调用链中的函数元数并不相同,则需要进行参数对齐
。
任何时候,只要想对函数的入参数量进行改造,必须想到偏函数
和柯里化
。
对于柯里化来说,不仅函数的元发生了变化,函数的数量也发生了变化(1 个变成 n 个)。
对于偏函数来说,仅有函数的元发生了变化(减少了),函数的数量是不变的。
偏函数
偏函数是将1个n元函数
变成一个1个m元函数(m<n)
的过程,实现思路是固定一部分函数参数。
以下是一个偏函数的例子。它通过固定multiply
函数的第一个入参x
,得到了一个一元函数multiply3
,使函数入参个数从n
变成m(m<n)
。
// 定义一个包装函数,专门用来处理偏函数逻辑
function wrapFunc(func, fixedValue) {
// 包装函数的目标输出是一个新的函数
function wrappedFunc(input) {
// 这个函数会固定 fixedValue,然后把 input 作为动态参数读取
const newFunc = func(input, fixedValue);
return newFunc;
}
return wrappedFunc;
}
const multiply3 = wrapFunc(multiply, 3);
// 输出6
multiply3(2);
偏函数这种固定参数得到新函数的思路,在缩减函数元数的同时,也可以减少函数调用时的重复传参。
实际上,通用函数为确保其自身的灵活性,往往都具备多元参数
的特征。但在一些特定的业务场景下,真正需要动态变化的只是其中的一部分的参数。这时候函数的一部分灵活性对我们来说是多余的,我们反而希望他的功能具体一点。
假设有代码如下,不难看出type
和area
两个参数是固定的,我们显然可以在generateOrderData
的基础上做一个偏函数,固定type
和area
字段,用于帮助我们避免大量的重复代码。
// 文件 a
const res = generateOrderData("food", "hunan", settelment);
// 文件 b
const UIData = generateOrderData("food", "hunan", settelment);
// 文件 c
const result = generateOrderData("food", "hunan", settelment);
柯里化
柯里化是把1个n元函数
改造成n个相互嵌套的一元函数
的过程。它的特征在于它是嵌套定义的多个函数,也就是套娃
。
以下是柯里化的一个例子。
// 定义高阶函数 curry
function curry(addThreeNum) {
// 返回一个嵌套了三层的函数
return function addA(a) {
// 第一层“记住”参数a
return function addB(b) {
// 第二层“记住”参数b
return function addC(c) {
// 第三层直接调用现有函数 addThreeNum
return addThreeNum(a, b, c);
};
};
};
}
// 借助 curry 函数将 add
const curriedAddThreeNum = curry(addThreeNum);
// 输出6,输出结果符合预期
curriedAddThreeNum(1)(2)(3);
柯里化
的实现思路,就是套娃之路
。套娃
的层数有多深,取决于原函数的参数个数。比如以上代码中,它是三元函数,就相应地需要套三层函数。
如果想实现一个通用的curry
,它应该能分析出参数的数量,并动态地根据入参数量自动进行函数嵌套。因此,它需要做如下工作:
- 获取函数入参数量
- 自动分层嵌套函数。有多少参数,就有多少层嵌套
- 在嵌套的最后一层调用回调函数,传入所有入参
获取函数的入参数量,可以通过访问函数的length
属性。
而对于函数自动嵌套,基本逻辑就是利用递归,先判断当前层级是否已经到达了嵌套的上限。若达到,则执行回调函数;否则继续嵌套。而如何认定递归边界,可以借助柯里化过程中传入的参数数量。因为柯里化的过程,是层层记忆每个参数的过程。每一层嵌套函数都有它需要记住的参数。若递归到某一层时,发现此时没有需要记忆的参数了,就可以认为已经到达了递归边界。
柯里化具体编码实现如下:
const curry = (func, arity = func.length) => {
const generateCurried = (prevArgs) => (nextArg) => {
// 统计当前`已记忆`+`未记忆`的参数
const args = [...prevArgs, nextArg];
// 若总参数数量 >= 回调函数元素个数,则认为已经记忆了所有参数
if (args.length >= arity) {
// 触碰递归边界,传入所有参数,调用回调函数
return func(...args);
}
// 未触碰递归边界,则继续递归调用generateCurried本身,创建一层新的嵌套
return generateCurried(args);
};
// 起始传参未空数组,表示`目前还未记住任何参数`
return generateCurried([]);
};
柯里化解决组合链的元数问题
若需要将不同元数的函数组合调用,可以先将所有函数柯里化,重构其传参方式,再逐个传参,传至每个函数只剩下一个待传参数为止,这样就将所有函数变成了一元函数,可供组合函数逐个调用。
const add = (a, b) => a + b;
const plus = (a, b, c) => a * b * c;
const curriedAdd = curry(add);
const curriedPlus = curry(plus);
const compute = pipe(curriedAdd(1), curriedPlus(1)(2));
console.log(compute(3));
参考