从一道面试题认识函数柯里化

23,207 阅读5分钟

最近在整理面试资源的时候,发现一道有意思的题目,所以就记录下来。

题目

如何实现 multi(2)(3)(4)=24?

首先来分析下这道题,实现一个 multi 函数并依次传入参数执行,得到最终的结果。通过题目很容易得到的结论是,把传入的参数相乘就能够得到需要的结果,也就是 2X3X4 = 24。

简单的实现

那么如何实现 multi 函数去计算出结果值呢?脑海中首先浮现的解决方案是,闭包。

function multi(a) {
    return function(b) {
        return function(c) {
            return a * b * c;
        }
    }
}

利用闭包的原则,multi 函数执行的时候,返回 multi 函数中的内部函数,再次执行的时候其实执行的是这个内部函数,这个内部函数中接着又嵌套了一个内部函数,用于计算最终结果并返回。

闭包实现

单纯从题面来说,似乎是已经实现了想要的结果,但仔细一想就会发现存在问题。

上面的实现方案存在的缺陷:

  • 代码不够优雅,实现步骤需要一层一层的嵌套函数。
  • 可扩展性差,假如是要实现 multi(2)(3)(4)...(n) 这样的功能,那就得嵌套 n 层函数。

那么有没有更好的解决方案,答案是,使用函数式编程中的函数柯里化实现。

函数柯里化

在函数式编程中,函数是一等公民。那么函数柯里化是怎样的呢?

函数柯里化指的是将能够接收多个参数的函数转化为接收单一参数的函数,并且返回接收余下参数且返回结果的新函数的技术。

函数柯里化的主要作用和特点就是参数复用、提前返回和延迟执行。

例如:封装兼容现代浏览器和 IE 浏览器的事件监听的方法,正常情况下封装是这样的。

var addEvent = function(el, type, fn, capture) {
    if(window.addEventListener) {
        el.addEventListener(type, function(e) {
            fn.call(el, e);
        }, capture);
    }else {
        el.attachEvent('on' + type, function(e) {
            fn.call(el, e);
        })
    }
}

该封装的方法存在的不足是,每次写监听事件的时候调用 addEvent 函数,都会进行 if else 的兼容性判断。事实上在代码中只需要执行一次兼容性判断就可以了,后续的事件监听就不需要再去判断兼容性了。那么怎么用函数柯里化优化这个封装函数。

var addEvent = (function() {
    if(window.addEventListener) {
        return function(el, type, fn, capture) {
            el.addEventListener(type, function(e) {
                fn.call(el, e);
            }, capture);
        }
    }else {
        return function(ele, type, fn) {
            el.attachEvent('on' + type, function(e) {
                fn.call(el, e);
            })
        }
    }
})()

js 引擎在执行该段代码的时候就会进行兼容性判断,并且返回需要使用的事件监听封装函数。这里使用了函数柯里化的两个特点:提前返回和延迟执行。

柯里化另一个典型的应用场景就是 bind 函数的实现。使用了函数柯里化的两个特点:参数复用和提前返回。

Function.prototype.bind = function(){
	var fn = this;
	var args = Array.prototye.slice.call(arguments);
	var context = args.shift();

	return function(){
		return fn.apply(context, args.concat(Array.prototype.slice.call(arguments)));
	};
};

柯里化的实现

那么如何通过函数柯里化实现面试题的功能呢?

通用版

function curry(fn) {
    var args = Array.prototype.slice.call(arguments, 1);
	return function() {
		var newArgs = args.concat(Array.prototype.slice.call(arguments));
        return fn.apply(this, newArgs);
    }
}

curry 函数的第一个参数是要动态创建柯里化的函数,余下的参数存储在 args 变量中。

执行 curry 函数返回的函数接收新的参数与 args 变量存储的参数合并,并把合并的参数传入给柯里化了的函数。

function multiFn(a, b, c) {
    return a * b * c;
}
var multi = curry(multiFn);
multi(2,3,4);

结果:

image

虽然得到的结果是一样的,但是很容易发现存在问题,就是代码相对于之前的闭包实现方式较复杂,而且执行方式也不是题目要求的那样 multi(2)(3)(4)。那么下面就来改进这版代码。

改进版

就题目而言,是需要执行三次函数调用,那么针对柯里化后的函数,如果传入的参数没有 3 个的话,就继续执行 curry 函数接收参数,如果参数达到 3 个,就执行柯里化了的函数。

function curry(fn, args) {
    var length = fn.length;
    var args = args || [];
    return function(){
        newArgs = args.concat(Array.prototype.slice.call(arguments));
        if(newArgs.length < length){
            return curry.call(this,fn,newArgs);
        }else{
            return fn.apply(this,newArgs);
        }
    }
}
function multiFn(a, b, c) {
    return a * b * c;
}
var multi = curry(multiFn);
multi(2)(3)(4);
multi(2,3,4);
multi(2)(3,4);
multi(2,3)(4);

image

可以看到,通过改进版的柯里化函数,已经将题目定的实现方式扩展到好几种了。这种实现方案的代码扩展性就比较强了,但是还是有点不足,就是必须事先知道求值的参数个数,那能不能让代码更灵活点,达到随意传参的效果,例如: multi(2)(3)(4),multi(5)(6)(7)(8)(9) 这样的。

优化版

function multi() {
    var args = Array.prototype.slice.call(arguments);
	var fn = function() {
		var newArgs = args.concat(Array.prototype.slice.call(arguments));
        return multi.apply(this, newArgs);
    }
    fn.toString = function() {
        return args.reduce(function(a, b) {
            return a * b;
        })
    }
    return fn;
}

image

这样的解决方案就可以灵活的使用了。不足的是返回值是 Function 类型。

image

总结

  • 就题目本身而言,是存在多种实现方式的,只要理解并充分利用闭包的强大。
  • 可能在实际应用场景中,很少使用函数柯里化的解决方案,但是了解认识函数柯里化对自身的提升还是有帮助的。
  • 理解闭包和函数柯里化之后,如果在面试中遇到类似的题型,应该就可以迎刃而解了。

后记

本着学习和总结的态度写的技术输出,文中有任何错误和问题,请大家指出。更多的技术输出可以查看我的 github博客

整理了一些前端的学习资源,希望能够帮助到有需要的人,地址: 学习资源汇总

参考