reduce与函数柯里化实践| 青训营笔记

134 阅读6分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 19 天

有这么一个数组:

const arr = [1, 2, 3, 4, 5, 6, 7, 8]  

接下来我想做一个求和操作:

  1. 先筛选出arr大于2的数字
  2. 然后将这些数字逐个乘以2
  3. 最后对这些数组做求和
// 用于筛选大于2的数组元素
const biggerThan2 = num => num > 2  
// 用于做乘以2计算
const multi2 = num => num * 2    
// 用于求和
const add = (a, b) => a + b   
​
// 完成步骤 1
const filteredArr = arr.filter(biggerThan2)    
// 完成步骤 2
const multipledArr = filteredArr.map(multi2)    
// 完成步骤 3
const sum = multipledArr.reduce(add, 0)

乍一看,这些代码没有问题,运行结果也符合预期,但是这代码真多安全了吗?我们把这些比作流水线上的操作,假设流水线中的产品有某道工序出现了问题,那么就会即使后面的操作正常,这个产品依然是有问题的。

而且,我们的目标是就求和,可是多了中间的筛选步骤,而且,万一有人在这些筛选操作中投毒,例如修改变量之类的,则跟上面举的例子一样,整个求和的数据是有问题的。

所以,借用链式调用构建声明式数据流

借助链式调用构建声明式数据流

目标:把上面的代码改造成一下代码

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

这样子,我们在使用中间代码时,链式调用就帮我们解决了被修改的风险。

链式调用的实现痛点

map()、reduce()、filter() 这些方法之间,之所以能够进行链式调用,是因为:

  1. 它们都挂载在 Array 原型的 Array.prototype
  2. 它们在计算结束后都会 return 一个新的 Array
  3. 既然 return 出来的也是 Array,那么自然可以继续访问原型 Array.prototype 上的方法

也就是说,链式调用是有前提的。

链式调用的本质 ,是通过在方法中返回对象实例本身的 this/ 与实例 this 相同类型的对象,达到多次调用其原型(链)上方法的目的。

要对函数执行链式调用,前提是函数挂载在一个靠谱的宿主 Object 上。

借助reduce

下面是reduce的实现,我们可以看到,我们先把他的初始值 进行一个缓存,然后通过一个将要执行的封装为闭包函数,更新外面的缓存

function reduce(arr, callback, initValue) {
  let result = initValue
  for (let i = 0; i < arr.length; i++) {
    result = callback(result, arr[i])
  }
  return result
}
​

我们可以借用这个思想,先考虑这么一个函数数组

const funcs = [func1, func2, func3]

然后对这些数组做reduce操作

const funcs = [func1, func2, func3]  
​
funs.reduce(callback, 0)
// 执行流程 => callback(func1,0) + callback(func2, acc) +  callback(func3, acc)

接下来是callback的执行

// 上一次执行的结果,和这一次要执行的value,这里是func
function callback(input, func) {
  func(input)
}  
​
funcs.reduce(callback,0)

最终包装为

// 使用展开符来获取数组格式的 pipe 参数
function pipe(...funcs) {
  function callback(input, func) {
         // 前一个函数执行的结果为input,等价于reduce((pre,next)=>{})
    return func(input)
  }  
​
  return function(param) {
    return funcs.reduce(callback,param)
  }
}

使用

const compute = pipe(add4, multiply3, divide2)
// 输出 21
console.log(compute(10))

至此,我们便实现了一个通用的 pipe 函数。

compose:倒序的 pipe

pipe 用于创建一个正序的函数传送带,而 compose 则用于创建一个倒序的函数传送带。

我们把 pipe 函数里的 reduce 替换为 reduceRight,就能够得到一个 compose:

// 使用展开符来获取数组格式的 pipe 参数
function compose(...funcs) {
  function callback(input, func) {
    return func(input)
  }  
​
  return function(param) {
    return funcs.reduceRight(callback,param)
  }
}

借用大佬的图片

img

求解多元参数

我们可以从上面的例子中看出来,callback返回的func只能传入一个入参,万一我们的函数有多个参数呢,那不是报错了?,所以我们要通过柯里化把多元参数变成一元参数,例如:fn(a, b, c) 转化为fn(a)(b)(c)

柯里化和偏函数

举例,这里有个函数,目标转换成addThreeNum(1)(2)(3)

function addThreeNum(a, b, c) {
  return a+b+c
}

那这又和偏函数有什么关系呢?

偏函数是指通过固定函数的一部分参数,生成一个参数数量更少的函数的过程。

柯里化:一个 n 元函数变成 n 个一元函数。

偏函数:一个 n 元函数变成一个 m(m < n) 元函数。

对于柯里化来说,不仅函数的元发生了变化,函数的数量也发生了变化(1个变成n个)。

对于偏函数来说,仅有函数的元发生了变化(减少了),函数的数量是不变的。

偏函数

我们可以看到,通过下面的一个函数,固定 multiply 函数的第一个入参 x,得到了一个一元函数 multiply3

// 定义一个包装函数,专门用来处理偏函数逻辑
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)

柯里化

传统的柯里化

function curry(addThreeNum) {
  // 返回一个嵌套了三层的函数
  return function addA(a) {
    // 第一层“记住”参数a
    return function addB(b) {
      // 第二层“记住”参数b
      return function addC(c) {
        // 第三层直接调用现有函数 addThreeNum
        return addThreeNum(a, b, c)
      }
    }
  }
}

当然,我们实际的开发中不会写那么多嵌套代码,而是自动生成。

也就是说,柯里化函数的特征,在于它是嵌套定义的多个函数,也就是“套娃”。通用的 curry 函数应该具备哪些能力?答案:根据根据参数自动的做嵌套

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

思路

  1. 获取函数的长度length,因为函数的长度就是他的参数个数
  2. 递🐢
  3. 存储递🐢的参数并做边界判定
// curry 函数借助 Function.length 读取函数元数
function curry(func, arity=func.length) {
  // 定义一个递归式 generateCurried
  function generateCurried(prevArgs) {
    // generateCurried 函数必定返回一层嵌套
    return function curried(nextArg) {
      // 统计目前“已记忆”+“未记忆”的参数
      const args = [...prevArgs, nextArg]  
      // 若 “已记忆”+“未记忆”的参数数量 >= 回调函数元数,则认为已经记忆了所有的参数
      if(args.length >= arity) {
        // 触碰递归边界,传入所有参数,调用回调函数
        return func(...args)
      } else {
        // 未触碰递归边界,则递归调用 generateCurried 自身,创造新一层的嵌套
        return generateCurried(args)
      }
    }
  }
  // 调用 generateCurried,起始传参为空数组,表示“目前还没有记住任何参数”
  return generateCurried([])
}

我们可以看到,这里的递🐢其实也用到reduce的思想,将前面的值缓存,然后将参数缓存,直到最后一层循环直接执行。

简洁的写法

const curry2 = (fn)=> {
  const len = fn.length;
  return function _(...args) {
    // reduce 思想,将前一个的参数和后一个参数用扩展运算符结合起来
    return args.length >= len ? fn(...args) : (...params) => _(...args, ...params)
  }
}

测试

const curry = (fn)=> {
  const len = fn.length;
  return function _(...args) {
    // reduce 思想,将前一个的参数和后一个参数用扩展运算符结合起来
    return args.length >= len ? fn(...args) : (...params) => _(...args, ...params)
  }
}
​
​
function add(a, b) {
  return a + b
}
​
function multiply(a, b, c) {
  return a * b * c
}
​
function addMore(a, b, c, d) {
  return a + b + c + d
}
​
function divide(a, b) {
  return a / b
}
​
const curriedAdd = curry(add)
const curriedMultiply = curry(multiply)
const curriedAddMore = curry(addMore)
const curriedDivide = curry(divide)
​
// 使用展开符来获取数组格式的 pipe 参数
function pipe(...funcs) {
  function callback(input, func) {
    console.log(input,func)
    // 前一个函数执行的结果为input
    return func(input)
  }
​
  return function (param) {
    return funcs.reduce(callback, param)
  }
}
const compute = pipe(curriedAdd(1), curriedMultiply(2)(3), curriedAddMore(1)(2)(3), curriedDivide(300))
​
console.log(compute(3))
​
// 执行顺序:(3+1)*2*3 + (1+2+3) / 300 = 10

总结

  1. pipe函数的作用是固定第一个参数,然后将函数组合起来按顺序执行
  2. curry函数的作用是将函数变成一元函数,因为pipe函数的里面的函数调用一次只能接受一个入参

参考资料

不得不说,修言大佬的课程真是精品