接上篇 JS 函数式编程的理解(1)

命令式与声明式数据流

假设有三行代码,我需要逐行阅读、理解计算中间态和主流程之间的逻辑关系,才能推导出程序的意图。这样的代码是命令式的。

const filteredArr = arr.filter(biggerThan2);
const multipledArr = filteredArr.map(multi2);
const sum = multipledArr.reduce(add, 0);

以上三行代码并非严格绑定的,filteredArrmultipledArr是计算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实现组合

以下实现的是一元入参函数的组合。具体如何让不同入参个数的函数组合起来,需要依靠后面介绍的偏函数柯里化

  1. pipe 正序组合
function pipe(...funcs) {
  return (params) => funcs.reduce((input, func) => func(input), param);
}
const compute = pipe(add, plus, divide);
console.log(compute(10));
  1. 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);

偏函数这种固定参数得到新函数的思路,在缩减函数元数的同时,也可以减少函数调用时的重复传参。

实际上,通用函数为确保其自身的灵活性,往往都具备多元参数的特征。但在一些特定的业务场景下,真正需要动态变化的只是其中的一部分的参数。这时候函数的一部分灵活性对我们来说是多余的,我们反而希望他的功能具体一点。

假设有代码如下,不难看出typearea两个参数是固定的,我们显然可以在generateOrderData的基础上做一个偏函数,固定typearea字段,用于帮助我们避免大量的重复代码。

// 文件 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,它应该能分析出参数的数量,并动态地根据入参数量自动进行函数嵌套。因此,它需要做如下工作:

  1. 获取函数入参数量
  2. 自动分层嵌套函数。有多少参数,就有多少层嵌套
  3. 在嵌套的最后一层调用回调函数,传入所有入参

获取函数的入参数量,可以通过访问函数的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));

参考

JavaScript 函数式编程实践指南