浅谈JavaScript函数中的柯里化(Curring)

973 阅读4分钟

我们先看下一个经典的柯里化代码:

// 普通的add函数
function add(x, y) {
    return x + y
}

// Currying后
function curryingAdd(x) {
    return function (y) {
        return x + y
    }
}

add(1, 2)           // 3
curryingAdd(1)(2)   // 3

实际上就是把add函数的x,y两个参数变成了先用一个函数接收x然后返回一个函数去处理y参数。现在思路应该就比较清晰了,就是只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。


柯里化的应用

举个例子,你有一家商店,然后你想给你的优惠顾客10%的折扣

function discount (price, discount) {
  return price * discount
}
// 当一个顾客消费了500元
const price = discount(500, 0.1) // $50

// 从长远看,你的每一笔生意都要计算10%的折扣
const price = discount(1500, 0.1) // $150
const price = discount(2000, 0.1) // $200
const price = discount(50, 0.1) // $5
const price = discount(300, 0.1) // $30

// 将这个函数柯里化,然后我们就不用每次都写那0.1了
function discount (discount) {
  return (price) => {
    return price * discount
  }
}
const tenPercentDiscount = discount(0.1)

// 现在,我们只需用商品价格来计算就可以了:
tenPercentDiscount(500) // $50

// 接下来,有些优惠顾客越来越重要,让我们称为vip顾客,然后我们要给20%的折扣,我们这样来使用柯里化了的discount函数:
const twentyPercentDiscount = discount(0.2)

// 我们为vip顾客使用0.2调用柯里化discount函数来配置了一个新的函数。这个twentyPercentDiscount函数会被用来计算vip顾客的折扣:
twentyPercentDiscount(500) // $100
twentyPercentDiscount(3000) // $600
twentyPercentDiscount(80000) // $16000

这个例子说明,使用柯里化思想能让我们在遇到只能确定一个参数而无法确定另一个参数时,代码设计编的变得更方便与高效,达到提升性能的目的。


有没有什么通用的封装方法?

// 初步封装
var currying = function(fn) {
    // args 获取第一个方法内的全部参数
    var args = Array.prototype.slice.call(arguments, 1)
    return function() {
        // 将后面方法里的全部参数和args进行合并
        var newArgs = args.concat(Array.prototype.slice.call(arguments))
        // 把合并后的参数通过apply作为fn的参数并执行
        return fn.apply(this, newArgs)
    }
}

这边首先是初步封装,通过闭包把初步参数给保存下来,然后通过获取剩下的arguments进行拼接,最后执行需要currying的函数。

但是好像还有些什么缺陷,这样返回的话其实只能多扩展一个参数,currying(a)(b)(c)这样的话,貌似就不支持了(不支持多参数调用),一般这种情况都会想到使用递归再进行封装一层。

// 支持多参数传递
function progressCurrying(fn, args) {

    var _this = this
    var len = fn.length;
    var args = args || [];

    return function() {
        var _args = Array.prototype.slice.call(arguments);
        Array.prototype.push.apply(args, _args);

        // 如果参数个数小于最初的fn.length,则递归调用,继续收集参数
        if (_args.length < len) {
            return progressCurrying.call(_this, fn, _args);
        }

        // 参数收集完毕,则执行fn
        return fn.apply(this, _args);
    }
}

这边其实是在初步的基础上,加上了递归的调用,只要参数个数小于最初的fn.length,就会继续执行递归。


最后再扩展一道经典面试题

实现一个add方法,使计算结果能够满足如下预期:
add(1)(2)(3) = 6;
add(1, 2, 3)(4) = 10;
add(1)(2)(3)(4)(5) = 15;

function add() {
    // 第一次执行时,定义一个数组专门用来存储所有的参数
    var _args = Array.prototype.slice.call(arguments);

    // 在内部声明一个函数,利用闭包的特性保存_args并收集所有的参数值
    var _adder = function() {
        _args.push(...arguments);
        return _adder;
    };

    // 利用toString隐式转换的特性,当最后执行时隐式转换,并计算最终的值返回
    _adder.toString = function () {
        return _args.reduce(function (a, b) {
            return a + b;
        });
    }
    return _adder;
}

add(1)(2)(3)                // 6
add(1, 2, 3)(4)             // 10
add(1)(2)(3)(4)(5)          // 15
add(2, 6)(1)                // 9

原因:js是一门弱类型的语言,比如:

var a = 1 + '0'; //a == '10'
var b = 1 - '1'; // b == '0'
var c = (1 == '1'); // c == true

上面是有一些隐式的转换,比如 a 后面的整数1转换成了字段串的 '1'。

同理对象(函数也是对象)也会有隐藏转换的情况。

比如:

var obj = {}

var ret = obj + '1' // [object Object]1
这里对象是隐式转换的时候会调用 toString这个方法,如果重写这个方法就可以代理这个方法
回到面试题上,add方法返回_adder实际上是返回的_adder.toString()这个结果,所以把toString()重写之后,就可以利用这个方法隐式的调用_adder.toString。
介绍reduce

reduce() 方法接收一个函数作为累加器,reduce 为数组中的每一个元素依次执行回调函数,不包括数组中被删除或从未被赋值的元素,接受四个参数:初始值(上一次回调的返回值),当前元素值,当前索引,原数组。

语法:arr.reduce(callback,[initialValue])

callback:函数中包含四个参数
- previousValue (上一次调用回调返回的值,或者是提供的初始值(initialValue))
- currentValue (数组中当前被处理的元素)
- index (当前元素在数组中的索引)
- array (调用的数组)
 
initialValue (作为第一次调用 callback 的第一个参数。)
const arr = [1, 2, 3, 4, 5]
const sum = arr.reduce((pre, item) => {
    return pre + item
}, 0)
console.log(sum) // 15