前言
最近代码写得比较少,不过看了几本书,其中一本有关函数式编程的比较有意思,就把读书笔记取其精华整理了一下分享出来,算是记录了,能让没了解过的同学简单易懂的了解一下。
- 第一篇:《函数式编程 —— 高阶函数与数组》(还没写,看反馈😂)
- 第二篇:《函数式编程 —— 柯里化与偏函数》
本文所有内容均使用 ES6 代码实现,目前大部分浏览器都基本完全支持 ES6 语法。
不针对任何人的发点感想
在写这篇文章的时候还有个挺有意思的地方,因为在看到偏函数这个概念的时候,看书的同时也查了点资料然后看了几篇文章,结果呢有点小失望,至于为什么失望?我们随便在掘金找一个有关偏函数的文章来看看就知道了:
强调一下,因为我在掘金写的,就随便找了篇文章(排序第一的),不过并不是针对任何大佬,我看了几篇文章,基本上都出现问题了,只是用的掘金这篇例子而已。这里并没有影射别人的意思。我觉得最后文章输出的代码看到并点赞的人是会用心去学去记得,但是如果输出的是一段有问题的代码,那就不是那么合适了。当然,我觉得以作者的水平,肯定是知道问题的,只是没有写出来,所以我这里就把出现的问题补充说明一下。
代码写的还是比较漂亮的,不过我们用代码实际在控制台敲一段应用一下:
可以看到,在执行的时候,只有第一次的结果是正确的,之后的结果都是错误的。当然了,这么简单的问题作者应该是知道的,不过呢,原文里其实只执行了一次函数封装以及调用,那么看起来当然没什么问题。还是那句话,并不是针对任何人,只是从个人感受出发,写出来的内容至少至少不能出问题,因为如果别人看到并学习应用了,而恰巧在业务里,真的没有用 lodash 等封装好的函数,而是看完之后自己仿照写的,那么可想而知,最后肯定会出问题的。
好了,插曲过去了,言归正传,开始用我的理解来和大家聊聊函数式编程里的柯里化和偏函数这两个非常重要的概念。
柯里化
JS里其实大部分概念都是晦涩难懂的,有时候你咬文嚼字不如来一个实际的例子认知的更快。
/**
* 一个最简单的柯里化
*/
const add = (a, b) => a + b;
const firstCurryAdd = a => b => a + b;
// 执行
add(2, 3); // 5
firstCurryAdd(2)(3); // 5
柯里化定义
从上面例子可以看到,柯里化是用来干啥的,就是原来我们有一个函数 add 接收两个参数,现在我们想把它变成依次接收一个参数,接收两次参数返回结果的函数。
【定义】:函数柯里化就是指把一个多元(多参数)函数转换为一个嵌套的一元函数的过程。
其实,仔细分析一下,可以很明显的发现,其实柯里化也就是利用了 JS 里闭包来实现的。
柯里化实现
接下来我们就来看看如何实现函数柯里化,实现当然就是从定义出发,其实就是利用闭包,每次传递一个参数然后返回一个接收一个参数的函数就行。
实现一:简易版
/**
* 简易版实现柯里化
*/
const currySimple = fn => x => y => fn(x, y);
// 应用
const curryAdd2 = currySimple(add);
curryAdd2(2)(3); // 5
从上面可以看到,非常简单的实现了二元函数柯里化,嗯没看错,只是二元函数柯里化,所以才称之为简易版。我们在日常开发中遇到的不仅仅只有两个参数,可能有三个四个甚至更多了,我们总不能像下面代码这样定义很多个柯里化函数:
// 二元函数柯里化
const curry2 = fn = x => y => fn(x, y);
// 三元函数柯里化
const curry3 = fn = x => y => z => fn(x, y, z);
// 四元函数柯里化...
...
上面代码并不符合设计规范,因此,上面方案并不是一个通用方案,我们接下来要探寻的就是完整版柯里化方案。
实现二:完整版
这里我就不一步一步实现了,大家又不是初学者,我把注释也写上了,大家随意看看就能理解。
/**
* 完整版柯里化
*/
const curry = fn => {
// 首先,柯里化接收的参数必须是一个函数,因为做的是函数柯里化操作
if (typeof fn !== 'function') {
throw Error('No function provided');
}
// 这里返回的函数不能是匿名函数,因为函数内部可能还会继续进行参数柯里化操作,所以要给个名字
return function curryInnerFn(...args) {
/**
* 这里有两个地方需要注意
* 第一个: 如果当前调用的函数接收到的参数小于原函数参数,那么进行参数柯里化操作,小于表示还不能执行函数返回结果
* 第二个:可以通过 fn.length 获取一个函数的参数列表长度,等价于函数内部的 arguments.length
*/
if (args.length < fn.length) {
// 因为还要继续进行柯里化操作,所以还要返回一个函数
return function() {
// 这里返回的是一个匿名函数,是 ES5 形式,因为下面合并参数要使用 arguments,箭头函数没有 arguments
return curryInnerFn.apply(null, args.concat(
/**
* 这里面是最核心的部分
* 第一:如果参数小于原函数 fn 的参数,返回柯里化函数之后说明后续还会继续接受参数
* 第二:返回的是递归调用的内部函数(闭包),在调用的时候要把第一次接收到的参数 (args) 与后面接收到的参数 (匿名函数的)arguments 依次拼接起来,这样才和原函数参数个数与顺序一致
* 第三:arguments 是类数组对象,要转化成数组
*/
[].slice.call(arguments)
));
}
}
// 如果柯里化函数接收的参数个数和原函数接收参数个数一致(或大于原函数),执行原函数返回结果
return fn.apply(null, args);
}
}
内部返回的时候使用的是 ES5 定义 function 的形式,感谢小伙伴在评论处提出了完全 ES6 的书写方式~
【柯里化重点】: 从柯里化实现的最后一段代码,我们得知,其实柯里化后的函数最后执行的本质就是等到参数接收完成之后,我们再把参数传递给原函数然后调用原函数获取结果。
分步解析
上面代码以防万一怕大家没看懂,咱们通过一个三元函数逐次解释一下就可以了。
// 三个数相加
const add3 = (x, y, z) => x + y + z;
// 柯里化
const curryAdd3 = curry(add3);
// 应用
curryAdd3(1)(2)(3); // 6
curryAdd3(1, 2)(3); // 6
- 第一步:
const curryAdd3 = curry(add3);
执行这行代码发生的事情是什么,从代码可知,这行代码返回的是一个函数 curryInnerFn(...args)
- 第二步:
curryAdd3(1)
这段代码执行的时候,进入循环,此时 args = [1],而 fn.length = 3(也就是add3的参数长度),因为 args.length < fn.length,因此进入判断语句内,此时又返回了一个函数,也就是执行后的结果如下:
curryAdd3(1) = function() {
return curryAdd3.apply(null, [1].concat([].slice.call(arguments)));
}
- 第三步:
curryAdd3(1)(2)
上一步的返回很清楚了,我们在传入一个参数2,此时代码变成了如下:
curryAdd3(1)(2) === function(2) {
return curryAdd3.apply(null, [1].concat([].slice.call([2])));
}
=> curryAdd3.apply(null, [1, 2]);
此时其实返回的仍是 innerFn,所以依然会在里面进行判断args = [1, 2] < fn.length = 3,所以重复第二步。
- 第四步:
curryAdd3(1)(2)(3)
与第二步第三步代码分析相同,只不过此时返回的就是 curryAdd3.apply(null, [1, 2, 3]),此时再次进行判断的时候,args.length = 3 === fn.length = 3,不满足条件进入循环,并且此时也就表示目前参数长度不小于原函数参数长度,可以直接调用原函数获取结果了,返回的是fn.apply(null, args)。
- 第五步:
fn.apply(null, args) ===> add3(1, 2, 3)
将接收到的参数组装成一个数组传回原函数,执行原函数,也就是说curryAdd3(1)(2)(3) === add3(1, 2, 3),进而得到原函数的执行结果,同时也就完成了函数的柯里化操作。
柯里化应用
定义与实现都讲完了,讲到了真的没啥是不是,都是很基础的应用。JS 概念这东西一看就懂,不过呢如果不应用其实忘记的也是很快,因为平时我们没有经常使用柯里化和偏函数,所以导致这两个概念我们不太了解,所以接下来我们就来说说应用场景。
其实从柯里化的实现来看,可以很清楚的看到,他就是将一个多参数函数转化成多个一元函数,这就表示可以用来封装固定参数。
【重点】:固定参数应该是在参数列表的左侧,也就是前面,这样才能实现用柯里化封装。
案例一:会员制度
比如各大网站都有会员制,不同会员优惠不一样,再比如我们经常去理发店,店家会各种让我们办会员充钱升级等等。假设一家店有白银/黄金/白金/钻石几种会员,具体优惠如下:
普通 => 不打折
白银 => 9折
黄金 => 8折
白金 => 7折
钻石 => 6折
接下来,因为折扣和会员的存在,我们计算消费的的函数就可以这么设计:
// 用户实际消费
const realConsume = (price, discount) => {
return price * discount;
}
最后,我们计算的时候就变成了下面这样:
// 假设 10 个人消费,5个白银,5个黄金,那么最后计算下来就是
// 白银会员实际消费
realConsume(100, 0.9);
realConsume(200, 0.9);
realConsume(300, 0.9);
realConsume(400, 0.9);
realConsume(500, 0.9);
// 黄金会员实际消费
realConsume(100, 0.8);
realConsume(200, 0.8);
realConsume(300, 0.8);
realConsume(400, 0.8);
realConsume(500, 0.8);
上面代码看起来是没什么问题的,并且我相信其实大部分开发者应该都是这么开发的,那么我们就来用柯里化改造一下。改造之前我们先来说说问题,可以很明显的看到,会员的折扣是固定的,也就是说 100 个白银会员花费不同的钱,进行计算的时候第二个参数传递的都是固定折扣 0.9,从设计角度来说,是可以进行封装复用的。也就是说我们可以将原函数进行柯里化然后进行折扣参数的封装,具体如下:
// 计算实际消费
const realConsume = (discount, price) => {
return discount * price;
}
// 封装白银 —— 实际消费
const silverRealConsume = curry(realConsume)(.9);
// 封装黄金 —— 实际消费
const goldRealConsume = curry(realConsume)(.8);
// 应用
silverRealConsume(100);
silverRealConsume(200);
silverRealConsume(300);
goldRealConsume(100);
goldRealConsume(200)
上面代码就是使用柯里化对函数进行封装,简化了付款场景。可能有的小伙伴说了,这其实也没简化甚至代码增多了。那么封装过后有哪些好处呢?
首先,因为场景过于简单,感觉是代码增多了,但是实际上随着使用增加,封装后的代码无论是阅读还是维护都是十分便捷的。
其次,再设想一下,如果是之前的用法,我们有一天白银会员从 9折改成了 88折,那么你要在代码里改变多少个参数,而柯里化过后,我们只需要改变一次即可。当然,如果代码写的够好,使用变量代替常量也是一样的效果,不过我们单从设计来说,确实封装更合理。
最后,条条大路通罗马,可能有无数种实现方式都能让代码跑起来,但是我们为啥不选一个比较优雅的方式呢?
【思考】:不知道小伙伴发没发现,我这里在进行柯里化的时候,将函数的参数调换了位置,因为柯里化的顺序是参数从左至右,那么要想实现封装折扣,就必须将折扣参数提前。这是一个简单的场景,封装二元函数,如果是多元复杂的,并且应用比较多,我们就不能保证要封装的一定在前面了,那该怎么办呢?这里留作思考,下面会回来解决这个问题。
案例二:封装日志函数
上面封装的是一个很简答场景,可能大家实际上也用不到,但是起到抛砖引玉的效果其实刚刚好,下面这个稍微复杂些的例子,我觉得更合适帮助我们理解柯里化的好处。
比如我们有一个日志函数,将错误消息相关内容打印到控制台,具体函数如下:
const loggerHelper = (type, errorMsg, lineNo) => {
if (type === 'DEBUG') {
console.debug(errorMsg + ' at line: ' + lineNo);
} else if (type === 'ERROR') {
console.error(errorMsg + ' at line: ' + lineNo);
} else if (type === 'WARN') {
console.warn(errorMsg + ' at line: ' + lineNo);
} else {
throw "wrong type"
}
}
也就是说我们使用一个函数打印不同的日志类型,那么为了代码阅读更直观,其实我们可以使用柯里化函数进行如下封装:
// 封装
const debugLogger = curry(loggerHelper)('DEBUG');
const errorLogger = curry(loggerHelper)('ERROR');
const warnLogger = curry(loggerHelper)('WARN');
// 使用
debugLogger('debug msg', 21);
errorLogger('error msg', 33);
warnLogger('warn msg', 89);
上面就是柯里化应用的简单的两个小案例,其实在日常开发过程中,柯里化可能并不会经常用到,但是其实如果使用起来,柯里化确实是能给开发以及后续的维护带来便捷。希望大家看完之后在遇到合适场景的时候能想起来用~
偏函数
好,终于提到了偏函数了也就是开头我提到过的阅读小结的问题,在这里就不多继续说那个问题了。咱们先来看看上面的思考:在进行会员制度函数柯里化的时候,我们特地的改变了函数参数的传递顺序,因为要封装固定折扣,所以把原本在第二个参数的固定折扣提到了第一个参数,然后进行了柯里化封装。那么能不能不动原函数的参数顺序,仍然可以继续封装呢?答案就是 —— 偏函数。
【重点】:所以也就是最开始提到的,柯里化和偏函数在函数式编程的概念里都是十分重要的,但是其实我们只需要在特定场景下使用其中一种即可。
偏函数定义
前面虽说我们将偏函数和柯里化进行了对比,但是其实二者并不是完全对立面。柯里化是参数从左到右进行柯里化操作,而偏函数并不是从右到左进行相关封装操作。 偏函数是指开发者可以部分地对函数固定参数进行相关操作。
【强调】:偏函数重点是部分参数,并不强调函数的顺序。
偏函数实现
我们先不管上面提到过的偏函数,还是从头开始一步步实现一下,慢慢发现问题解决问题,更有助于理解偏函数。
实现案例:封装一个普适版本的 1s 延时函数
都知道,setTimeout(fn, delay) 使 JS 内置的函数,这也就表示它不能像上面会员制度一样,随便改变顺序,所以我们不能使用柯里化进行封装(当然不是绝对不能,只是会麻烦一步)。假设,我们系统里要大量调用 1s 延时的这种函数。所以提前封装一下更为方便。
- 正常应用
// 应用
const timer1 = setTimeout(() => console.log('task 1'), 1000);
const timer2 = setTimeout(() => console.log('task 2'), 1000);
- 普通封装
// 普通封装
const setTimeout1s = fn => setTimeout(fn, 1000);
// 应用
const time1 = setTimout1s(() => console.log('task1'))
const time2 = setTimout1s(() => console.log('task2'))
- 柯里化封装
前面说了,其实并不是不能用柯里化来封装,只是没必要,我们来看看为啥没必要:
// curry 化封装
const newSetTimeout = (delay, fn) => setTimeout(fn, delay);
const setTimeout1s = curry(newSetTimeout)(1000);
// 应用
const time1 = setTimout1s(() => console.log('task1'))
const time2 = setTimout1s(() => console.log('task2'))
- 偏函数封装
// 偏函数封装
const setTimeout1s = patial(setTimeout, undefined, 1000);
// 应用
const time1 = setTimout1s(() => console.log('task1'))
const time2 = setTimout1s(() => console.log('task2'))
一共三种封装方式,可以看到,第一种是强行封装场景并不通用,不考虑;第二种,柯里化封装之前需要额外一个函数进行封装转换参数,更麻烦了,不考虑;第三种,偏函数封装,虽然我们还没实现,但是很明显,实现了以后它更适合做这种操作。
实现一:简易 Bug 版
【原理】:上面我们使用了偏函数进行封装,先不实现但是来看看如何执行的:
const setTimeout1s = patial(setTimeout, undefined, 1000);,patial 接收了三个参数,第一个参数是封装的函数本身,之后的参数是原函数接收参数的个数以及顺序,但是因为我们做的是封装部分指定参数,所以不固定的部分,也就是要传递进来的部分使用undefined。
// 第一次实现偏函数 - partial
const partial = (fn, ...partialArgs) => {
// 将偏函数除了第一个参数 fn 赋值给 args
let args = partialArgs;
// 返回函数,该函数也接受一串参数
return (...fullArgs) => {
let arg = 0;
// 遍历两个参数列表,分别是外部封装函数的参数列表以及内部调用函数的参数列表,外部封装函数的参数列表存在 undefined,undefined 的意义表示,遇到 undefined 了就要进行参数的替换,undefined 就是封装固定参数的占位符,替换的内容就是内部调用函数的参数。
for (let i = 0; i < args.length && arg < fullArgs.length; i++) {
if (args[i] === undefined) {
args[i] = fullArgs[arg++];
}
}
return fn.apply(null, args);
}
}
代码就不过多解释了,注释内容我已经写的很清晰了。还是直接来应用一下就知道了:
- 案例一 — 封装
parseInt
众所周知,parseInt接收两个参数,第一个是要转换的字符串,第二个是转换系数,一般来说我们都会选择默认的十进制。(第二个参数必须传,因为如果不传在某些场景下会出现bug),所以就可以封装一个默认十进制安全的parseInt。
window.tenParseInt = partial(parseInt, undefined, 10);
// 或者
window.tenParseInt = function (str) {
return partial(parseInt, undefined, 10)(str);
}
// 应用
tenParseInt('123'); // 123
【解释】:简单的释义一下,第一个外部参数列表
args = [undefined, 10],之后我们进行调用,传进来参数fullArgs = ['123'],然后进行 for 循环判断,发现第一个参数是undefined,表示此参数需要替换,也就是args[0] = fullArgs[0] = '123',最后再也没遇到undefined,表示只需替换第一个并且我们已经替换完成,完成之后调用fn.apply(null, args) = fn('123', 10) = parseInt('123', 10),偏函数封装完毕。
- 案例二 - 封装
JSON.stringfy
JSON.stringify(str, replacer, tab)`其实可以接三个参数,第一个是要转换的字符串,第二个可以是一个函数或者数组,第三个是转换后添加格式(空格或者换行符)。
const tab4Stringify = partial(JSON.stringify, undefined, null, 4);
const obj = { name: 'luffyzh', email: 'luffyzh@163.com' };
tab4Stringify(obj);
{
"name": "luffyzh",
"email": "luffhzh@163.com"
}
上面函数我们封装完成,看起来也没什么问题,此时也就出现了开头我们提到的,其实实际调用的时候,此函数是有问题的。
const obj2 = { name: 'naruto', email: 'naruto@163.com' };
tab4Stringify(obj2);
{
"name": "luffyzh",
"email": "luffhzh@163.com"
}
我们发现,第一次调用是成功的,第二次调用居然失败了?这是为什么呢?我们来继续回顾一下:**我们在函数内部使用的是数组存储参数列表内容,那么在JS里,数组是地址引用,这我们应该很清楚。**在第一次执行完成之后实际上我们的参数列表 args 变成了 ['[object Object]', null, 4],什么意思,也就是我们第一次把传递的参数赋值给了 undefined,但是呢第二次我们执行的时候,原本的 undefined 已经被第一次的执行结果覆盖了,args已经没有undefined也就是要替换的内容了。这就是问题出现的本质。
实现二:改进 Bug 版
发现问题,我们就要解决问题,本质就是我们每次执行完毕应该重置一下参数,把应该替换的位置变成 undefined。
// 第一次改进
const partial = (fn, ...partialArgs) => {
// 将偏函数除了第一个参数 fn 赋值给 args
let args = partialArgs;
// 返回函数,该函数也接受一串参数
return (...fullArgs) => {
// 重制占位符,因为占位符位置是第一个,所以重置 args[0] 即可
args[0] = undefined;
let arg = 0;
// 遍历两个参数列表,如果外部函数参数列表遇到了 undefined,此时将内部函数参数列表逐次赋值给外部函数参数列表。undefined 的意义表示,从 undefined 开始要把内部函数的参数也赋值给 args 参数列表
for (let i = 0; i < args.length && arg < fullArgs.length; i++) {
if (args[i] === undefined) {
args[i] = fullArgs[arg++];
}
}
return fn.apply(null, args);
}
}
const tab4Stringify = partial(JSON.stringify, undefined, null, 4);
const obj = { name: 'luffyzh', email: 'luffyzh@163.com' };
// 第一次调用
tab4Stringify(obj);
{
"name": "luffyzh",
"email": "luffyzh@163.com"
}
const obj2 = { name: 'naruto', email: 'naruto@163.com' };
// 第二次调用
tab4Stringify(obj2);
{
"name": "naruto",
"email": "naruto@163.com"
}
改进版完成了,看起来没什么问题,但是其实改进版问题也是大的很,为什么呢?仔细看看,我们重置的参数只是第一个参数,前面提到了偏函数 partial 是能帮助我们处理部分参数的封装操作的,那只处理第一个参数位置很明显不对。比如下面这个场景:
- 案例四:封装两个部分参数
有一个四个函数相加的函数 — add4(a, b, c, d),现在我们希望暂时固定第1个和第3个参数分别是 10 和 30。第2个和第4个传入之后计算结果,很明显,这不能使用柯里化以及上面封装的,不信的话你可以用一下看看。但是思路其实是差不多的,我们只需要把外部参数列表的占位符位置记下来,然后在执行完毕之后重置即可。
实现三:最终完整版
const partial = (fn, ...particalArgs) => {
// 转换成另一个数组
let args = particalArgs;
// 记录占位符的位置
const keyPositions = args.reduce((acc, cur, index) => {
cur === undefined ? acc.push(index) : null;
return acc;
}, []);
// 返回函数,该函数也接受一串参数
return (...fullArgs) => {
let arg = 0;
// 每次调用之前重置占位符为 undefined
for (let i = 0; i < keyPositions.length; i++) {
args[keyPositions[i]] = undefined;
}
// 遍历两个参数列表,如果外部函数参数列表遇到了 undefined,此时将内部函数参数列表逐次赋值给外部函数参数列表。undefined 的意义表示,从 undefined 开始要把内部函数的参数也赋值给 args 参数列表
for (let i = 0; i < args.length && arg < fullArgs.length; i++) {
if (args[i] === undefined) {
args[i] = fullArgs[arg++];
}
}
return fn.apply(null, args);
}
}
我们执行一下看看结果,发现完成了需求,没问题,确实是固定了第1个和第3个参数,我们传的两个参数也被放到了第二个和第四个上:
const add4 = (a, b, c, d) => {
return a + b + c + d;
}
const _add4 = partial(add4, 10, undefined, 30, undefined);
_add4(20, 40);
实现小结
虽然是实现了,但是并不是最优解,只是给大家说清楚问题以及原理,至于有想用到的地方,我还是建议大家使用 lodash 封装好的。当然,我们其实也可以使用特定的占位符代替 undefined,那样可能用起来更优雅一些。
总结
柯里化与偏函数都是函数式编程中重要的一环,并且它们应用的也都是 JS 里常用的一个概念 —— 闭包来实现的。这两个在我们日常开发过程中其实有着很大的用处,可以让代码变得直观优雅以及便于维护,同时对于理解函数式编程也起着至关重要的作用,希望通过本篇文章傻瓜式地讲解能让大家简单的掌握它们。不足之处多多体谅~