乐趣区

JavaScript之函数柯理化

引语

最近在社区阅读技术博客的时候偶然间看到了 函数柯里化 几个字,还有要求手写js 函数柯里化,心想是柯里化是什么高级的东西?没听说过啊?

那就带着问题出发,专门去学习一下,也做了一些整理。

什么是函数柯里化?

什么是函数柯里化?先看看维基百科如何解释:

在计算机科学中,柯里化(英语:Currying),又译为卡瑞化或加里化,是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。

这个技术由克里斯托弗·斯特雷奇以逻辑学家哈斯凯尔·加里命名的,尽管它是 Moses Schönfinkel 和戈特洛布·弗雷格发明的。

在直觉上,柯里化声称“如果你固定某些参数,你将得到接受余下参数的一个函数”。所以对于有两个变量的函 y^x,如果固定了 y =2,则得到有一个变量的函数 2^x。

Currying 的概念其实并不复杂,用通俗易懂的话说:只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。

如果文字解释还是有一点抽象,我们就拿 add 函数,来做一个简单的函数柯里化的实现。

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

// add 函数柯里化后
var curryingAdd = function(x) {return function(y) {return x + y;};
};

// 函数复用
var increment = curryingAdd(1);
var addTen = curryingAdd(10);

increment(2);
// 3
addTen(2);
// 12

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

为什么要函数柯里化?

看完上面的关于 add 函数的柯里化,问题来了,费这么大劲封装一层,到底有什么用处呢?

一、参数复用

其实刚刚第一个 add 函数的柯里化例子中已经涉及到了函数柯里化所带来的函数复用的便捷,我们通过 add 函数柯里化,很快捷地实现了 increment 函数和 addTen 函数,再来看个例子:

// 正常正则验证字符串 reg.test(txt)

// 函数封装后
function check(reg, txt) {return reg.test(txt)
}

check(/\d+/g, 'test')       //false
check(/[a-z]+/g, 'test')    //true

// Currying 后
function curryingCheck(reg) {return function(txt) {return reg.test(txt)
    }
}

var hasNumber = curryingCheck(/\d+/g)
var hasLetter = curryingCheck(/[a-z]+/g)

hasNumber('test1')      // true
hasNumber('testtest')   // false
hasLetter('21212')      // false

上面的示例是一个正则的校验,正常来说直接调用 check 函数就可以了,但是如果我有很多地方都要校验是否有数字,其实就是需要将第一个参数 reg 进行复用,这样别的地方就能够直接调用 hasNumber,hasLetter 等函数,让参数能够复用,调用起来也更方便。

二、提前确认
var on = function(element, event, handler) {if (document.addEventListener) {if (element && event && handler) {element.addEventListener(event, handler, false);
        }
    } else {if (element && event && handler) {element.attachEvent('on' + event, handler);
        }
    }
}

var on = (function() {if (document.addEventListener) {return function(element, event, handler) {if (element && event && handler) {element.addEventListener(event, handler, false);
            }
        };
    } else {return function(element, event, handler) {if (element && event && handler) {element.attachEvent('on' + event, handler);
            }
        };
    }
})();

换一种写法可能比较好理解一点,上面就是把 isSupport 这个参数给先确定下来了

var on = function(isSupport, element, event, handler) {
    isSupport = isSupport || document.addEventListener;
    if (isSupport) {return element.addEventListener(event, handler, false);
    } else {return element.attachEvent('on' + event, handler);
    }
}

我们在做项目的过程中,封装一些 dom 操作可以说再常见不过,上面第一种写法也是比较常见,但是我们看看第二种写法,它相对一第一种写法就是自执行然后返回一个新的函数,这样其实就是提前确定了会走哪一个方法,避免每次都进行判断

三、延迟计算 / 运行
Function.prototype.bind = function (context) {
    var _this = this
    var args = Array.prototype.slice.call(arguments, 1)

    return function() {return _this.apply(context, args)
    }
}

像我们 js 中经常使用的 bind,实现的机制就是 Currying.

如何实现函数柯里化?

通用的封装方法:

// 初步封装
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,就会继续执行递归。

函数柯里化的性能怎么样?

关于 Currying 的性能,我们应该知道下面几点:

  • 存取 arguments 对象通常要比存取命名参数要慢一点
  • 一些老版本的浏览器在 arguments.length 的实现上是相当慢的
  • 使用 fn.apply(…) 和 fn.call(…)通常比直接调用 fn(…) 稍微慢点
  • 创建大量嵌套作用域和闭包函数会带来花销,无论是在内存还是速度上

其实在大部分应用中,主要的性能瓶颈是在操作 DOM 节点上,这 js 的性能损耗基本是可以忽略不计的,所以 curry 是可以直接放心的使用。

柯里化面试题

// 实现一个 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

总结

通过简单地传递几个参数,就能动态创建实用的新函数;而且还能带来一个额外好处,那就是保留了数学的函数定义,尽管参数不止一个。

Currying 函数用起来非常得心应手,每天使用它对我来说简直就是一种享受。它堪称手头必备工具,能够让函数式编程不那么繁琐和沉闷。


参考:

  • https://llh911001.gitbooks.io/mostly-adequate-guide-chinese/content/ch4.html# 不仅仅是双关语咖喱
  • https://www.jianshu.com/p/2975c25e4d71
  • https://zh.wikipedia.org/wiki/%E6%9F%AF%E9%87%8C%E5%8C%96

推荐阅读:
【专题:JavaScript 进阶之路】
ES6 Promise
JavaScript 之深入理解闭包
ES6 尾调用和尾递归
Git 常用命令小结
浅谈 MVC 和 MVVM 模型


我是 Cloudy,年轻的前端攻城狮一枚,爱专研,爱技术,爱分享。
个人笔记,整理不易,感谢关注、阅读、点赞和收藏。
文章有任何问题欢迎大家指出,也欢迎大家一起交流前端各种问题!

退出移动版