高阶函数之一个求和竟然可以玩出花
什么是高阶函数:就是一个函数的参数是函数,或者返回值是函数,满足其中一个就是高阶函数
大家好,我是梅利奥猪猪,一名持续进步的讲师!前一阵子和各位小伙伴们提到了高阶函数,大家兴趣都比较大,函数竟然能这么玩!那这次产出这篇博客的原因就是给各位小伙伴们带来一些看似简单又不简单的补充。
看完这篇文章,你会攻克以下面试题
- 纯函数的优化
- 柯里化定参的实现
- 柯里化不定参的实现
- 柯里化延迟计算
可能以上面试题有些概念大家不知道,但不要紧,听我之后娓娓道来
热身
先来两个简单的问题,刚学 js 的你们肯定也能做出来!请听题
-
声明个 add 函数,他的形参有 3 个,分别是 a,b,c,请实现函数,求出 a, b, c 的和
ps: 这个 add 函数之后我们会衍生出面试题-纯函数的优化以及柯里化定参的实现
-
声明个 sum 函数,他可以接受任意多个参数,请实现函数,最终求出所有参数的和
ps: 这个 sum 函数之后我们会衍生出面试题-柯里化不定参的实现以及柯里化延迟计算
是不是 so easy!直接开写
const add = (a, b, c) => a + b + c;
const sum = (...args) => args.reduce((sum, cur) => sum + cur);
对就是这么简单,但小伙伴们,不要急,接下去我们就要开始玩出花了!(狗头)
纯函数的优化
-
面试官: "小伙子,前面的加法 add 函数实现的不错,接下去能不能做下纯函数的优化呢?"
-
xxx: "嗯?啥?纯函数?什么鬼?"
-
面试官: "纯函数都不知道,可以回去等下通知"
-
xxx: "T-T"
为了避免有这种尴尬的情况,接下去我们先要知道纯函数的概念
什么是纯函数
纯函数的定义:1.确定的输入,会产生确定的输出 2.不会产生副作用
这 2 个条件都满足才是纯函数,那接下去我们来分析一波
const add = (a, b, c) => {
return a + b + c;
};
console.log(add(1, 2, 3));
console.log(add(1, 2, 3));
console.log(add(1, 2, 3));
console.log(add(1, 2, 3));
console.log(add(1, 2, 3));
console.log(add(1, 2, 3));
先提前剧透,这里的add
函数就是个纯函数,上述代码中,只要我们传入的参数是一样的,结果肯定也是一样的,明显是 6 个 6,直呼 666666!并且他没有副作用
那什么是副作用呢,简单来说就是只要函数不纯,他就有副作用,举个例子请看以下代码
let d = 66; // 加了个全局变量
const add = (a, b, c) => {
return a + b + c + d; // 注意,多加了个d
};
// 我们的结果会受这个d影响,如果d在某个时刻被更改了,我们传的参数即使和之前一样,结果也会不同,这个就是副作用
console.log(add(1, 2, 3));
console.log(add(1, 2, 3));
console.log(add(1, 2, 3));
d = 666; // 在这里全局变量被改了,所以后面的结果就和之前不一样了
console.log(add(1, 2, 3));
console.log(add(1, 2, 3));
console.log(add(1, 2, 3));
此次代码中,求和里面多加了d
,这个变量d
是个全局变量,不知道会在什么时候被谁改了,只要有人改了,你即使传一样的参数,结果肯定不一样,这就是副作用,所以此时的 add 函数就不是一个纯函数,请看结果
所以当我们提到纯函数的优化,必要条件就是你优化的函数必须是纯函数,就是我们一开始写的这个,没有副作用只要传入一样的参数必定是一样的结果
// 这个add就是纯函数
const add = (a, b, c) => {
return a + b + c;
};
那了解了纯函数的概念,之后就是谈谈如何优化了
如何优化纯函数
在优化前,我们在代码里再加上这样一句打印
const add = (a, b, c) => {
console.log("函数执行了参数是", a, b, c); // 这行代码是新加的
return a + b + c;
};
console.log(add(1, 2, 3));
console.log(add(1, 2, 3));
console.log(add(1, 2, 3));
console.log(add(1, 2, 3));
console.log(add(1, 2, 3));
console.log(add(1, 2, 3));
因为我们知道纯函数只要参数是一致的,那结果必然是一样的,上图中,明明参数都一样,但我们却函数执行了六次(看打印有几个 函数执行了 )!所以我们可以把执行过的结果缓存下来,记录对应的参数(xdm 就是这几个参数算出来这个结果哈),如果下次参数再是一样的,直接返回结果就可以(不用再重新计算),参数不一样,我们再重新计算,计算出结果了再将其缓存记录新的参数,这样就是个优化了
经过分析,我们的优化就是这样的步骤
- 看缓存的结果,如果之前有一样的传参计算出过结果 则直接返回结果(避免再次执行函数)
- 看缓存的结果,如果之前没有计算过,则执行函数计算,并将结果缓存
那接下去就开干!
实现优化纯函数
那接下来实现纯函数前,我们先来前瞻下我们实现出来的效果应该是什么样子的
// 实现个高阶函数,他接受的参数是个纯函数,返回一个函数,返回的这个函数用法,和之前纯函数一样,只不过有缓存结果的能力
const cacheAdd = cacheFn(add);
console.log(cacheAdd(1, 2, 3)); // 就第一次执行了add函数计算出结果
console.log(cacheAdd(1, 2, 3)); // 直接从缓存结果取
console.log(cacheAdd(1, 2, 3)); // 直接从缓存结果取
// 请实现cacheFn
首先cacheFn
是个通用函数,他的作用就是可以让我们纯函数变得更强,优化它,让他有缓存的能力,他不仅接受的是个函数,而且返回值也是个函数,是个名副其实的高阶函数!在这里,我们缓存结果可以利用闭包的特性,在函数 A 内部声明个对象,最后返回 B 函数!外面的 A 函数执行完,返回的函数 B(里面的函数)还能访问 A 函数里的变量!基本的架子可以是这样
const cacheFn = (fn) => {
// 记录缓存结果
const cacheObj = {};
// 返回的是个函数,所以cacheFn执行后拿到结果就是个函数
// const cacheAdd = cacheFn(add)
// cacheAdd是个函数,使用上和add函数一样,可以这么执行cacheAdd(1, 2, 3)
return (...args) => {
console.log(args);
};
};
在实现里面的逻辑前,我们在做个打印,大家可以一起思考下
之后开始核心步骤实现了
const add = (a, b, c) => {
console.log("函数执行了参数是", a, b, c);
return a + b + c;
};
const cacheFn = (fn) => {
const cacheObj = {};
return (...args) => {
// 将参数转换成key
const key = JSON.stringify(args);
// console.log(key);
if (cacheObj[key]) {
// 有一样的参数,从有缓存的结果直接返回结果
return cacheObj[key];
} else {
// 没有结果执行fn函数,并把对应的结果存入缓存
const result = fn(...args);
cacheObj[key] = result;
return result;
}
};
};
const cacheAdd = cacheFn(add);
// 此时验证是否有缓存的功能
console.log(cacheAdd(1, 2, 3));
console.log(cacheAdd(1, 2, 3));
console.log(cacheAdd(1, 2, 3));
的确有了缓存功能,只要是一样的参数就会直接返回结果!那接下去在把我们的代码精简下,就完成了纯函数的优化这道题了
参考代码如下,之后只要是纯函数就可以用cacheFn做优化啦!
const cacheFn = (fn) => {
const cacheObj = {};
return (...args) => {
const key = JSON.stringify(args);
return cacheObj[key] ? cacheObj[key] : (cacheObj[key] = fn(...args));
};
};
那恭喜大家,面试题纯函数的优化搞定了!
柯里化定参的实现
柯里化概念&应用场景&常见面试题
柯里化(Currying)是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术
上述的概念看上去是不是很高大上又很抽象,简单来说就是为了复用!我们先来说下我们第二个面试题他的应用场景
来自 xxx 最近一次和组长的聊天
- 组长: "小伙子,我看你这边执行了这么几个方法"
add(1, 2, 1);
add(1, 2, 2);
add(1, 2, 3);
add(1, 2, 4);
-
组长略有所思继续问道: "有没有发现你的这些调用方法里,前 2 个参数都是一样的,能不能优化下"
-
xxx: "这简单,我把这 2 个参数处理下,正好前几天刚学过高阶函数"
const add = (x, y) => (z) => x + y + z;
const add12 = add(1, 2); // 这里add函数执行后 返回的是个函数 z=>x+y+z
// 返回的函数接受一个参数z,然后在和之前x,y在进行累加
add12(1); // 等价于之前的add(1, 2, 1) 但抽离了add12进行了复用
add12(2); // 等价于之前的add(1, 2, 2) 但抽离了add12进行了复用
add12(3); // 等价于之前的add(1, 2, 3) 但抽离了add12进行了复用
add12(4); // 等价于之前的add(1, 2, 4) 但抽离了add12进行了复用
xxx 写完后内心狂喜,毕竟的确有了复用,肯定比之前的稍微看上去好这么点,以后只要有人要计算求和,并且前 2 个参数是 1 和 2,那直接用我这个add12函数就可以
-
组长: "的确做到了,但不够优雅啊,你这边相当于写死了传入 2 个参数返回了一个函数,能不能更灵活点呢"
-
xxx: "臣妾做不到啊 T-T"
-
组长: "那你可要好好练了,这个就是柯里化,这种面试题还很多的呢,最常见的这个你见过没"
curryAdd(1, 2, 3); // 6
curryAdd(1)(2)(3); // 6
curryAdd(1, 2)(3); // 6
curryAdd(1)(2, 3); // 6
xxx 有点失落但又燃起了斗志,的确见过这样的面试题。。。看来只能下定决心好好研究下这个该怎么做了。才能让组长刮目相看(xxx 心里强烈的希望变强)
分析柯里化定参的实现
说到底,函数柯里化的概念其实也离不开闭包,函数 A 接受一个参数(闭包中的自由变量)且返回一个新函数 B(闭包),而函数 A 明明已执行并释放,当函数 B 执行时依旧能访问 A 函数当时所接参数。
接下去我们还要来说明下定参的意思,不难发现,前面我们不管怎么调用,函数调用了几次,最后都是凑齐了 3 个参数,计算出了结果
curryAdd(1, 2, 3); // 6 - 当凑齐了1,2,3这3个参数后,就能计算出6的结果
curryAdd(1)(2)(3); // 6 - 当凑齐了1,2,3这3个参数后,就能计算出6的结果
curryAdd(1, 2)(3); // 6 - 当凑齐了1,2,3这3个参数后,就能计算出6的结果
curryAdd(1)(2, 3); // 6 - 当凑齐了1,2,3这3个参数后,就能计算出6的结果
他们的区别就是括号的数量不一样,那为什么能有()()()
括号括号括号的写法呢,因为这里用到的又是高阶函数的概念,一个函数执行后如果返回值是函数,是不是又能括号执行了呢!然后执行后返回的又是函数是不是又可以在加括号执行呢!就是这样的道理
那括号到何时才到头呢,那就是当你凑齐龙珠召唤神龙的时候,上述例子就是凑齐 3 个参数后就能计算出结果!因为我们是对 add 函数进行柯里化操作,add 函数就是接受 3 个参数就能得出结果!
那具体又该怎么做?对于curryAdd
函数自身而言,我们确定它最多同时接受 3 个参数,如果是三个参数就应该直接返回结果,但如果不足 3 个参数应该返回一个新函数,而返回新函数又有curryAdd(1)(2, 3)
与curryAdd(1)(2)(3)
等各种形式,对于这种不确定调用几次的,内部一定得存在一个递归。那么尴尬的又来了,curryAdd
要返回新函数调用,那计算的结果谁来返回?所以这里一定得存在一个限制,它是跳出递归以及返回最终结果的核心因素。那就是前面说的只要凑齐 3 个参数就计算结果
那接下去就是个小知识点,我们如何知道一个函数他接受几个参数呢!我们可以打印一个东西
const add = (a, b, c) => {
return a + b + c;
};
console.log(add.length); // 3 - (函数名.length)能获取函数有几个形参
现在我们越来越接近真相,可以实现柯里化定参
实现柯里化定参
老样子,我们再来要说下我们之后会怎么使用
const curryAdd = curryFn(add);
curryAdd(1, 2, 3); // 6
curryAdd(1)(2)(3); // 6
curryAdd(1, 2)(3);
const curryAdd12 = curryFn(add, 1, 2);
curryAdd12(1); // 4
curryAdd12(2); // 5
curryAdd12(3); // 6
const curryAdd1 = curryFn(add, 1);
curryAdd1(2, 3); // 6
curryAdd1(4, 5); // 10
// 请实现curryFn
不难发现,我们的curryFn接受的第一个参数,肯定是我们要柯里化的函数,剩下的参数是不限制数量的,可以是 1 个也可以是多个,只要不超过我们要柯里化的函数的参数个数就可以了,即使超过了我们直接让他返回结果(比如 add 只有 3 个参数,用户传了多个,我们就直接按前 3 个传入的参数执行函数), 那架子就可以这么搭
const curryFn = (fn, ...args) => {
if (args.length >= fn.length) {
// 传入的参数个数超过需要柯里化函数的参数个数,直接计算出结果
} else {
// 返回个函数,继续拼接参数,直到拼接到凑齐龙珠计算出结果
}
};
那我们实现简单的,也是一个比较边缘的 case,先实现 if 里的代码
const add = (a, b, c) => {
return a + b + c;
};
// console.log(add.length) // 3 - (函数名.length)能获取函数有几个形参
const curryFn = (fn, ...args) => {
if (args.length >= fn.length) {
// 直接计算出结果
return fn(...args);
} else {
// 返回个函数,继续拼接参数,直到拼接到凑齐龙珠计算出结果
}
};
const curryAdd123 = curryFn(add, 1, 2, 3);
console.log(curryAdd123); // 6 - 因为3个参数凑齐了,curryAdd直接就拿到了结果6
实际上我们curryFn从第二个参数开始不会一下子就把参数传齐了,一般是选择传入部分或者不传,那这样就会走else的逻辑,接下去我们就来实现核心的步骤
const add = (a, b, c) => {
return a + b + c;
};
// console.log(add.length) // 3 - (函数名.length)能获取函数有几个形参
const curryFn = (fn, ...args) => {
if (args.length >= fn.length) {
// 直接计算出结果
return fn(...args);
} else {
// 返回个函数,继续拼接参数,直到拼接到凑齐龙珠计算出结果
// 返回的新函数,接受的参数也可以是任意多个,所以用展开运算符...newArgs
return (...newArgs) => {
// 这里会把以前的args和现在的newArgs都传入curryFn函数
// 一开始的判断条件args.length只要超过了fn的长度就会执行出结果
return curryFn(fn, ...args, ...newArgs);
};
}
};
// const curryAdd123 = curryFn(add, 1, 2, 3)
// console.log(curryAdd123)
const curryAdd = curryFn(add);
console.log(curryAdd(1, 2, 3));
console.log(curryAdd(1)(2)(3));
console.log(curryAdd(1, 2)(3));
else
中的逻辑的确有点小复杂,这里我们用curryAdd(1)(2)(3)
来分析下代码是怎么跑的
-
初次调用
curryFn(add)
,由于除了函数之外没别的参数,因此args
长度是 0,if
判断后curryAdd
就是(...newArgs) => curryFn(fn, ...args, ...newArgs)
。 -
第一次调用
curryAdd(1)
,此时等同于(1) => curryFn(fn, [], [1])
,注意,接下来神奇的事情发生了,function (fn, ...args)
这一段中的...args
直接把[]
和[1]
进行了合并,于是执行完毕继续递归时,下一次执行函数时的..args
就是[1]
,即便函数执行完毕,变量依旧不会释放,这就是闭包的特性。 -
继续调用
(2)
,那么此时就等同于(2) => curryFn(fn, [1], [2])
,长度依旧不满足,继续返回递归,...args
再次合并。 -
调用
(3)
,此时等用于(3) => curryFn(fn, [1,2], [3])
,...args
再次合并,巧了,此时args.length >= fn.length
满足条件,于是执行fn(...args)
,也就是add(1, 2, 3)
。
同理,不管我们调用curryAdd(1, 2, 3)
还是curryAdd(1,2)(3)
,都是相同的过程,实参长度大于等于形参长度吗?满足就返回执行结果,不满足就继续柯里化(递归),同时巧妙的把新旧参数进行合并。
最后我们把curryFn
在改写的简单点
const curryFn = (fn, ...args) =>
args.length >= fn.length
? fn(...args)
: (...newArgs) => curryFn(fn, ...args, ...newArgs);
此刻应该欢呼,面试题柯里化定参的实现也搞定了
柯里化不定参的实现
分析需求 - 搭个架子
前面我们已经实现了柯里化定参(参数个数是固定的)的实现,那接下去我们就来试试看不定参的实现,那具体是什么意思呢,先来看下我们想做成什么样子的
// 注意每一行代码前面有加号
+sumAdd(1) // 1
+sumAdd(1)(2) // 3
+sumAdd(1, 2)(3); // 6
实现前,我们首先想到的是,由于没了形参数量的限制,此时就不可能存在在某种条件下跳出递归的条件了。但如果没条件,我们怎么知道什么时候返回函数,什么时候返回结果呢?
在说这个之前,我们先实现一个无限调用的函数,每次调用,它都会返回自己,且接受上次计算的结果,以及下次的参数,比如:
const sumAdd = (...args) => {
// 保留上一次的计算,同理也是最后一次的计算
let res = args.reduce((pre, cur) => pre + cur);
// 将上次的结果以及下次接受的参数都传下去
return (...newArgs) => sumAdd(res, ...newArgs);
};
此时控制台的输出为
现在尴尬的是,我们每次调用函数内部其实都做了求和,只是因为不断递归,我们拿不到结果,每次都是拿到一个新函数,怎么拿到结果?
聊聊 toString
其实有个很巧的方法,就是用toString
我们先来看下普通一个对象,调用toString
会发生什么
那接下去我们给obj
写个toString
方法,看下会怎么样
我们都知道,每个对象都有toString
方法,因为原型上就有这个方法,我们如果在自身添加了这个方法,那调用的时候就会调用自己的(自己有调用自己,没有就往原型上找),所以上图我们走了自己的toString
方法,执行了我们自己小业务逻辑,算出了this.a + this.b + this.c
的结果为6
那接下去,就要给大家科普个小技巧,如果此时我们不调用toString
,直接在 obj 前面加个+
号会怎么样
为什么会这样呢,其实有+
有这样的隐式转换
数字字符串转数字我们应该用的相对多一点,其他的可以简单做下了解,如果一个对象有toString
方法,隐式转换会调用对象的toString
方法,此时返回值就是结果
实现柯里化不定参
那知道了这个加号的特殊技巧后,那我们不定参求和的结果就可以通过这种方式去拿到
const sumAdd = (...args) => {
// 保留上一次的计算,同理也是最后一次的计算
let res = args.reduce((pre, cur) => pre + cur);
// 将上次的结果以及下次接受的参数都传下去
const sumAdd_ = (...newArgs) => sumAdd(res, ...newArgs);
sumAdd_.toString = () => {
// res就是求和的结果
return res;
};
return sumAdd_;
};
我们来看下结果,只要你加上+
,就能计算出结果,否则就是返回个函数
搞定!柯里化不定参求和也没难倒我们!真棒!
柯里化延迟计算
需求分析
前面我们不定参计算,直接就计算出了结果,那我们能不能做成延迟计算呢! 大致可以设计成这样
- 当我们看到函数名+括号就代表着执行了函数
- 括号里有参数,我们就把用户要使用的参数记住
- 括号里没参数,直接就执行我们的逻辑,把用户之前的参数累加
const sum = (...args) => args.reduce((a, b) => a + b);
const lazySum = lazyCurry(sum); // lazyCurry返回一个函数,可以让我们延迟计算
lazySum(1); // 记住1
lazySum(2)(3); // 记住2,3
lazySum(4); // 记住4
console.log(lazySum()); // 10 括号没参数直接计算结果 1 + 2 + 3 + 4 = 10
lazySum(5, 6); // 记住 5, 6
console.log(lazySum()); // 21 括号没参数直接计算结果 10 + 5 + 6 = 21
初步实现-搭架子
首先实现lazyCurry
肯定是个高阶函数,他接收我们需要延迟计算的函数,并且返回个函数(用户可以累计自己传的参数,当要计算结果直接用不带参数的括号执行就能结算出结果)
所以有这么几个要点
- 要收集用户的参数
- 最终需要返回一个函数
- 这个函数执行,如果有参数,那就累加参数并且还要继续返回函数
- 这个函数执行,如果没有参数,那就直接给用户计算结果
所以代码如下
const lazyCurry = (fn, ...args) => {
let collectionArgs = [...args]; // 收集参数用的
return function cb(...newArgs) {
if (newArgs.length === 0) {
// 直接计算出结果 因为调用时没有传参数
} else {
// 记住用户的参数
// 并返回函数
return cb;
}
};
};
实现柯里化延迟计算
前面的架子搭好了,相当于已经把球踢到球门口了,剩下临门一脚就可以了!
具体要做的就是
- 如何收集用户的参数
- 数组拼接参数就可以啦
- 最后如何计算结果
- 调用
fn
,fn
的参数就是收集参数的数组在数组前面加展开运算符就可以了
- 调用
代码如下
const sum = (...args) =>
args.reduce((prev, current) => {
return prev + current;
});
const lazyCurry = (fn, ...args) => {
let collectionArgs = [...args]; // 收集参数用的
return function cb(...newArgs) {
if (newArgs.length === 0) {
// 直接计算出结果
return fn(...collectionArgs);
} else {
// 记住用户的参数
collectionArgs = [...collectionArgs, ...newArgs];
// 并返回函数
return cb;
}
};
};
const lazySum = lazyCurry(sum); // lazyCurry返回一个函数,可以让我们延迟计算
lazySum(1); // 记住1
lazySum(2)(3); // 记住2,3
lazySum(4); // 记住4
console.log(lazySum()); // 10 括号没参数直接计算结果 1 + 2 + 3 + 4 = 10
lazySum(5, 6); // 记住 5, 6
console.log(lazySum()); // 21 括号没参数直接计算结果 10 + 5 + 6 = 21
结果和我们预期是一致的!
大功告成,那最后个知识点,柯里化延迟计算也做完了!就是这么酷炫!
总结
小伙伴们,通过这篇文章,不知道大家有没有体会到高阶函数的魅力!虽然只是一些求和的拓展,但还是应用到不少知识的!希望这次大家也能收获满满!
- 纯函数优化
- 可以缓存结果做优化
- 柯里化固定参数
- 可以复用优化逻辑
- 柯里化不固定参数
- 拓展前者固定参数
- 柯里化延迟计算
- 不用一开始就计算出结果,可以先收集参数,在用户想要结果的时候再计算
转载自:https://juejin.cn/post/7222141882514636855