作为函数式编程语言,JS带来了很多语言上的有趣特性,比如柯里化。
1. 简介
柯里化(Currying),又称部分求值(Partial Evaluation),是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。
核心思想是把多参数传入的函数拆成单参数(或部分)函数,内部再返回调用下一个单参数(或部分)函数,依次处理剩余的参数。
按照Stoyan Stefanov --《JavaScript Pattern》作者 的说法,所谓“柯里化”就是使函数理解并处理部分应用
柯里化有3个常见作用:
- 参数复用
- 提前返回
- 延迟计算/运行
talk is cheap,看看怎么实现吧~
2. 实现
2.1 通用实现
一个通用实现:
1function currying(fn, ...rest1) { 2 return function(...rest2) { 3 return fn.apply(null, rest1.concat(rest2)) 4 } 5}
注意这里concat接受非数组元素参数将被当做调用者的一个元素传入
用它将一个sayHello函数柯里化试试:
1function sayHello(name, age, fruit) { 2 console.log(console.log(`我叫 ${name},我 ${age} 岁了, 我喜欢吃 ${fruit}`)) 3} 4 5const curryingShowMsg1 = currying(sayHello, '小明') 6curryingShowMsg1(22, '苹果') // 我叫 小明,我 22 岁了, 我喜欢吃 苹果 7 8const curryingShowMsg2 = currying(sayHello, '小衰', 20) 9curryingShowMsg2('西瓜') // 我叫 小衰,我 20 岁了, 我喜欢吃 西瓜
嘻嘻,感觉还行~
2.2 高阶柯里化函数
以上柯里化函数已经能解决一般需求了,但是如果要多层的柯里化总不能不断地进行currying函数的嵌套吧,我们希望经过柯里化之后的函数每次只传递一个或者多个参数,那该怎么做呢:
1function curryingHelper(fn, len) { 2 const length = len || fn.length // 第一遍运行length是函数fn一共需要的参数个数,以后是剩余所需要的参数个数 3 return function(...rest) { 4 return rest.length >= length // 检查是否传入了fn所需足够的参数 5 ? fn.apply(this, rest) 6 : curryingHelper(currying.apply(this, [fn].concat(rest)), length - rest.length) // 在通用currying函数基础上 7 } 8} 9 10function sayHello(name, age, fruit) { console.log(`我叫 ${name},我 ${age} 岁了, 我喜欢吃 ${fruit}`) } 11 12const betterShowMsg = curryingHelper(sayHello) 13betterShowMsg('小衰', 20, '西瓜') // 我叫 小衰,我 20 岁了, 我喜欢吃 西瓜 14betterShowMsg('小猪')(25, '南瓜') // 我叫 小猪,我 25 岁了, 我喜欢吃 南瓜 15betterShowMsg('小明', 22)('倭瓜') // 我叫 小明,我 22 岁了, 我喜欢吃 倭瓜 16betterShowMsg('小拽')(28)('冬瓜') // 我叫 小拽,我 28 岁了, 我喜欢吃 冬瓜
如此实现一个高阶的柯里化函数,使得柯里化一个函数的时候可以不用嵌套的currying,当然是因为把嵌套的地方放到了curryingHelper里面进行了...-。-
2.3 疯狂柯里化函数
尽管柯里化函数已经很牛了,但是它也让你必须花费点小心思在你所定义函数的参数顺序上。在一些函数式编程语言中,会定义一个特殊的“占位变量”。通常会指定下划线来干这事,如果作为一个函数的参数被传入,就表明这个是可以“跳过的”,是尚待指定的参数。比如:
1var sendAjax = function (url, data, options) { /* ... */ } 2var sendPost = function (url, data) { // 当然可以这样 3 return sendAjax(url, data, { type: "POST", contentType: "application/json" }) 4} 5// 也可以使用下划线来指定未确定的参数 6var sendPost = sendAjax( _ , _ , { type: "POST", contentType: "application/json" })
JS不具备这样的原生支持,可以使用一个全局占位符变量const _ = { }并且通过===来判断是否是占位符,当然你如果使用了lodash的话可以使用别的符号代替。那么可以这样改造柯里化函数:
1const _ = {} 2function crazyCurryingHelper(fn, length, args, holes) { 3 length = length || fn.length // 第一遍是fn所需的参数个数,以后是 4 args = args || [] 5 holes = holes || [] 6 7 return function(...rest) { 8 let _args = args.slice(), 9 _holes = holes.slice(), 10 argLength = _args.length, // 存储接收到的args和holes的长度 11 holeLength = _holes.length, 12 arg, i = 0 13 for (; i < rest.length; i++) { 14 arg = rest[i] 15 if (arg === _ && holeLength) { 16 holeLength-- // 循环_holes的位置 17 _holes.push(_holes.shift()) // _holes最后一个移到第一个 18 } else if (arg === _) { 19 _holes.push(argLength + i) // 存储_hole就是_的位置 20 } else if (holeLength) { // 是否还有没有填补的hole 21 holeLength-- 22 _args.splice(_holes.shift(), 0, arg) // 在参数列表指定hole的地方插入当前参数 23 } else { 24 _args.push(arg) // 不需要填补hole,直接添加到参数列表里面 25 } 26 } 27 28 return _args.length >= length // 递归的进行柯里化 29 ? fn.apply(this, _args) 30 : crazyCurryingHelper.call(this, fn, length, _args, _holes) 31 } 32} 33 34function sayHello(name, age, fruit) { console.log(`我叫 ${name},我 ${age} 岁了, 我喜欢吃 ${fruit}`) } 35 36const betterShowMsg = crazyCurryingHelper(sayHello) 37betterShowMsg(_, 20)('小衰', _, '西瓜') // 我叫 小衰,我 20 岁了, 我喜欢吃 西瓜 38betterShowMsg(_, _, '南瓜')('小猪')(25) // 我叫 小猪,我 25 岁了, 我喜欢吃 南瓜 39betterShowMsg('小明')(_, 22)(_, _, '倭瓜') // 我叫 小明,我 22 岁了, 我喜欢吃 倭瓜 40betterShowMsg('小拽')(28)('冬瓜') // 我叫 小拽,我 28 岁了, 我喜欢吃 冬瓜
牛B闪闪
3. 柯里化的常见用法
3.1 参数复用
通过柯里化方法,缓存参数到闭包内部参数,然后在函数内部将缓存的参数与传入的参数组合后apply/bind/call给函数执行,来实现参数的复用,降低适用范围,提高适用性。
参看以下栗子,官员无论添加后续老婆,都能和合法老婆组合,通过柯里化方法,getWife方法就无需添加多余的合法老婆...
1var currying = function(fn) { 2 var args = [].slice.call(arguments, 1) // fn 指官员消化老婆的手段,args 指的是那个合法老婆 3 return function(...rest) { 4 var newArgs = args.concat(...rest) // 已经有的老婆和新搞定的老婆们合成一体,方便控制 5 return fn.apply(null, newArgs) // 这些老婆们用 fn 这个手段消化利用,完成韦小宝前辈的壮举并返回 6 } 7} 8 9var getWife = currying(function() { 10 console.log([...arguments].join(';')) // allwife 就是所有的老婆的,包括暗渡陈仓进来的老婆 11}, '合法老婆') 12 13getWife('老婆1', '老婆2', '老婆3') // 合法老婆;老婆1;老婆2;老婆3 14getWife('超越韦小宝的老婆') // 合法老婆;超越韦小宝的老婆 15getWife('超级老婆') // 合法老婆;超级老婆
3.2 提高适用性
通用函数解决了兼容性问题,但同时也会再来,使用的不便利性,不同的应用场景往,要传递很多参数,以达到解决特定问题的目的。有时候应用中,同一种规则可能会反复使用,这就可能会造成代码的重复性。
1// 未柯里化前 2function square(i) { return i * i; } 3function dubble(i) { return i * 2; } 4function map(handler, list) { return list.map(handler); } 5 6map(square, [1, 2, 3, 4, 5]); // 数组的每一项平方 7map(square, [6, 7, 8, 9, 10]); 8map(dubble, [1, 2, 3, 4, 5]); // 数组的每一项加倍 9map(dubble, [6, 7, 8, 9, 10]);
同一规则重复使用,带来代码的重复性,因此可以使用上面的通用柯里化实现改造一下:
1// 柯里化后 2function square(i) { return i * i; } 3function dubble(i) { return i * 2; } 4function map(handler, ...list) { return list.map(handler); } 5 6var mapSQ = currying(map, square); 7mapSQ([1, 2, 3, 4, 5]); 8mapSQ([6, 7, 8, 9, 10]); 9 10var mapDB = currying(map, dubble); 11mapDB([1, 2, 3, 4, 5]); 12mapDB([6, 7, 8, 9, 10]);
可以看到这里柯里化方法的使用和偏函数比较类似,顺便回顾一下偏函数~
偏函数是创建一个调用另外一个部分(参数或变量已预制的函数)的函数,函数可以根据传入的参数来生成一个真正执行的函数。比如:
1const isType = function(type) { 2 return function(obj) { 3 return Object.prototype.toString.call(obj) === `[object ${type}]` 4 } 5} 6const isString = isType('String') 7const isFunction = isType('Function')
这样就用偏函数快速创建了一组判断对象类型的方法~
偏函数固定了函数的某个部分,通过传入的参数或者方法返回一个新的函数来接受剩余的参数,数量可能是一个也可能是多个
柯里化是把一个有n个参数的函数变成n个只有1个参数的函数,例如:add = (x, y, z) => x + y + z→curryAdd = x => y => z => x + y + z
当偏函数接受一个参数并且返回了一个只接受一个参数的函数,与两个接受一个参数的函数curry()()的柯里化函数,这时候两个概念类似。(个人理解不知道对不对)
3.3 延迟执行
柯里化的另一个应用场景是延迟执行。不断的柯里化,累积传入的参数,最后执行。例如累加:
1const curryAdd = function(...rest) { 2 const _args = rest 3 return function cb(...rest) { 4 if (rest.length === 0) { 5 return _args.reduce((sum, single) => sum += single) 6 } else { 7 _args.push(...rest) 8 return cb 9 } 10 } 11}() // 为了保存添加的数,这里要返回一个闭包 12curryAdd(1) 13curryAdd(2) 14curryAdd(3) 15curryAdd(4) 16curryAdd() // 最后计算输出:10
更通用的写法,将处理函数提取出来:
1const curry = function(fn) { 2 const _args = [] 3 return function cb(...rest) { 4 if (rest.length === 0) { 5 return fn.apply(this, _args) 6 } 7 _args.push(...rest) 8 return cb 9 } 10} 11 12const curryAdd = curry((...T) => 13 T.reduce((sum, single) => sum += single) 14) 15curryAdd(1) 16curryAdd(2) 17curryAdd(3) 18curryAdd(4) 19curryAdd() // 最后计算输出:10
4. Function.prototype.bind 方法也是柯里化应用
与 call/apply 方法直接执行不同,bind 方法将第一个参数设置为函数执行的上下文,其他参数依次传递给调用方法(函数的主体本身不执行,可以看成是延迟执行),并动态创建返回一个新的函数, 这符合柯里化特点。
1var foo = {x: 888}; 2var bar = function () { 3 console.log(this.x); 4}.bind(foo); // 绑定 5bar(); // 888
下面是一个 bind 函数的模拟,testBind 创建并返回新的函数,在新的函数中将真正要执行业务的函数绑定到实参传入的上下文,延迟执行了。
1Function.prototype.testBind = function(scope) { 2 return () => this.apply(scope) 3} 4var foo = { x: 888 } 5var bar = function() { 6 console.log(this.x) 7}.testBind(foo) // 绑定 8bar() // 888
网上的帖子大多深浅不一,甚至有些前后矛盾,在下的文章都是学习过程中的总结,如果发现错误,欢迎留言指出~
