这是我参与「第五届青训营 」伴学笔记创作活动的第 19 天
有这么一个数组:
const arr = [1, 2, 3, 4, 5, 6, 7, 8]
接下来我想做一个求和操作:
- 先筛选出arr大于2的数字
- 然后将这些数字逐个乘以2
- 最后对这些数组做求和
// 用于筛选大于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() 这些方法之间,之所以能够进行链式调用,是因为:
- 它们都挂载在 Array 原型的 Array.prototype 上
- 它们在计算结束后都会 return 一个新的 Array
- 既然 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)
}
}
借用大佬的图片
求解多元参数
我们可以从上面的例子中看出来,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 函数应该具备哪些能力?答案:根据根据参数自动的做嵌套
- 获取函数参数的数量
- 自动分层嵌套函数:有多少参数,就有多少层嵌套
- 在嵌套的最后一层,调用回调函数,传入所有入参。
思路
- 获取函数的长度length,因为函数的长度就是他的参数个数
- 递🐢
- 存储递🐢的参数并做边界判定
// 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
总结
- pipe函数的作用是固定第一个参数,然后将函数组合起来按顺序执行
- curry函数的作用是将函数变成一元函数,因为pipe函数的里面的函数调用一次只能接受一个入参
参考资料
- JavaScript函数式编程实践指南
- juejin.cn/book/717359…
不得不说,修言大佬的课程真是精品