JS中给函数参数添加默认值

 

最近在Codewars上面看到一道很好的题目,要求用JS写一个函数defaultArguments,用来给指定的函数的某些参数添加默认值。举例来说就是:

// foo函数有一个参数,名为x
var foo_ = defaultArguments(foo, {x:3});
// foo_是一个函数,调用它会返回foo函数的结果,同时x具有默认值3

下面是一个具体的例子:

function add(a, b) {return a+b;}
// 给add函数的参数b添加默认值3
var add_ = defaultArguments(add, {b : 3});
// 这句代码相当于调用add(2, 3),因此返回5
add_(2); // 5
// 而这句代码由于完整地给出了a、b的值,所以不会用到b的默认值,因此返回12
add_(2, 10); // 12

 

之所以说这是一个好题目,是因为它和那些单纯考算法的题不同,完成它需要你对JS的很多知识点有相当深入的了解。包括获取函数的形参列表、运行时实参、正则表达式、高阶函数、管道调用等,以及其他一些细小的知识点。

我在刚拿到这个题目时的感觉是无从下手,因为之前没有碰到过类似的题目,完全没有过往的经验可以借鉴。但是经过简单的思考,虽然还有很多问题需要解决,但已经有了一个初步的思路,这个函数的框架大体上应该是这样的:

function defaultArguments(func, defaultValues) {
  // step 1: 获得形参列表
  var argNames = ...;

  // 返回一个wrapper函数,其中封装了对原函数的调用
  return function() {
    // step 2: 获得运行时实参
    var args = ...;

    // step 3: 用默认值补齐实参,没有定义默认值的为undefined

    // step 4: 调用原函数并返回结果
    return func.apply(null, args);
  };
}

思路还是比较清楚的,函数defaultArguments应该返回一个函数,这个函数用来对原函数的调用进行包装,从而在调用原函数之前对传入的参数进行处理,用默认值替换那些未传入值的参数。由于默认值是用形参名称来指定的,而不是参数在列表中的顺序,所以需要获得形参的列表,才能判断为哪些参数指定了默认值。

 

Step 1:获得形参列表

刚开始准备写代码就遇到了第一个难题:怎么才能获得一个函数的形参列表呢?

这个问题确实让我抓耳挠腮了一阵,最后想出了一个方法。我们知道JS中的所有对象都有toString()方法,函数是一个function对象,那么function对象的toString()返回什么呢?对了,就是函数的定义代码。例如add.toString()将返回“function add(a, b) {return a+b;}”。

拿到了定义函数的字符串,获取形参列表的方法也就有了,只需把括号里的内容取出来,然后以逗号进行拆分就可以了(注意要去除参数名前后的空格)。

(后来再次阅读问题描述的时候发现问题中是有提示可以用这种方法来获得形参列表的,没认真读题的悲哀啊。)

要取出形参列表,一种方法是先找到左右括号的索引,然后用substring()来取;另一种是用正则表达式来取。我使用的是正则表达式的方式:

var match = func.toString().match(/function([^\(]*)\(([^\)]*)\)/);
var argNames = match[2].split(',');

 

这个正则表达式的匹配过程如下图:

第一个分组(group 1)用来匹配左括号前面的函数名部分,第二个分组(group 2)用来匹配括号中的形参列表。注意函数名和形参列表都不是必须的,因此匹配时使用的是*号。match()方法返回的是一个数组,第一个元素是匹配到的完整结果,后面的元素依次为各个捕获分组所匹配到的内容,所以形参列表所在的group 2分组对应返回结果的第三个元素。

 

Step 2:获得运行时实参

形参列表有了,接下来就是获得实参了。因为func函数不是我们自己定义的,我们无法用形参名称来引用实参,但是JS为每个函数的调用隐式提供了一个变量arguments,用来获取传入的实参。关于arguments这里就不多说了,相信会JS的都比较了解。

 

Step 3:用默认值补齐实参

一开始我的做法是,遍历形参数组,如果发现对应的参数值为undefined,就检查是否为该参数提供了默认值,如果是就将其替换为默认值。代码类似于下面这样:

var args = [];
for (var i = 0; i < argNames.length; i++) {
    if (arguments[i] !== undefined) {
        args[i] = arguments[i];
    } else {
        args[i] = defaultValues[argNames[i]];
    }
}

但这段代码在其中一个测试用例上失败了 。那个用例显式地传入了一个undefined值。就像这样:“add_(2, undefined);”,此时应该返回NaN,但我的代码却会把b参数用默认值替换为3,所以返回的是5。

我意识到不能用这种方法来替换参数的默认值。思考后发现,能够提供默认值的只能是最后若干个参数,你无法为前面的某个参数提供默认值而不为它之后的参数提供默认值。例如对于函数add(a,b)来说,是无法做到只为参数a提供默认值的。如果你调用“defaultArguments(add, {a:1});”的话,此时好像是a有了默认值1而b没有默认值,但实际上此时的b也有隐含地有了默认值undefined,因为你永远无法做到只使用a的默认值而给b传入一个具体的值。

比如你想使用a的默认值,同时想给b传入2,这是无法做到的。如果你这样:“add_(2)”,实际上是给a指定了参数值2。而如果你想这样:“add_(undefined, 2)”,虽然确实把2传给了b,但此时却同时为a指定了undefined。

所以,默认参数只能出现在形参列表的最后若干个参数中。如果我们为某个参数指定了默认值但却没有为它后面的参数指定,此时实际上相当于它后面的那些参数的默认值为undefined。就像上面例子中的a、b那样。

实际上这个规则很早就了解了,也在其他语言中使用过,但却没有认真思考过其中包含的逻辑。直到解答这个问题的时候才算彻底了解了。

根据上面的结论,就可以很容易地修改上面的代码了,只需为形参列表中没有传入值的最后若干个参数使用默认值即可,原arguments中的参数值不需要去管它,即使其中可能有些参数的值是undefined,那也是用户自己传入的。因此可以将代码修改为:

var args = Array.prototype.slice.call(arguments);
for (var i = arguments.length; i < argNames.length; i++) {
    args[i] = defaultValues[argNames[i]];
}

 

完整代码如下:

var defaultArguments = function(func, defaultValues) {
    if (!defaultValues) return func;
var match = func.toString().match(/function[^\(]*\(([^\)]*)\)/); if (!match || match.length < 2) return func; var argNameStr = match[1].replace(/\s+/g, ''); // remove spaces if (!argNameStr) return func; var argNames = argNameStr.split(','); var wrapper = function() { var args = Array.prototype.slice.call(arguments); for (var i = arguments.length; i < argNames.length; i++) { args[i] = defaultValues[argNames[i]]; } return func.apply(null, args); }; return wrapper; }

这就是我当时根据题目要求所写的第一个程序,自认为已经不错了,自己编写的几个测试用例也能顺利通过,于是信心满满地点击了提交,但是……失败了。未通过的用例大概是这样的:

var add_ = defaultArguments(add, {b:9});
add_ = defaultArguments(add_, {a:2, b:3});
add_(10);

结果应该是13,因为最后b的默认值已经重置为3了。但上面的程序返回的却是19,原因是第一次调用defaultArguments()返回的那个函数已经丢失了add函数的形参列表。add()函数的形参列表应该是“a,b”,但如果我们在该测试用例的第一条语句后执行add_.toString(),返回的将是defaultArguments中包装函数的定义,而不是add的,但此包装函数并没有定义形参,因此第二次调用defaultArguments无任何作用。

为了解决这个问题,我尝试了好几种方案。首先考虑使用Function类来构造一个function对象,因为Function的构造函数接受的是字符串类型的参数,这样就可以为包装函数定义形参了,因为func函数的形参列表我们已经拿到了。Function构造函数的前面若干个参数用来定义形参,最后一个参数则是函数体。例如:

var add = new Function('a', 'b', 'return a+b'); // function(a,b){return a+b}

但是问题来了,我如何才能将形参列表传给Function的构造函数呢?毕竟func函数是用户传入的,其形参个数是不确定的,但Function的构造函数又不接受数组作为参数。问题到此似乎陷入了僵局,忽然,我想到JS中几大内置类型是比较特殊的,其中有几个(Function、Date等)无论用不用new都会返回正确的结果。因此我们可以不用new,而把Function的构造函数当成普通函数调用,这样就可以使用apply()方法将一个数组作为参数传给它了。经过试验发现这样确实可以,下面的代码确实返回了和使用new时一样的function对象:

Function.apply(null, ['a', 'b', 'return a+b']);

于是我用这种方法修改前面的代码,但是却发现行不通,因为包装函数的内部需要通过闭包来使用外层函数defaultArguments()的func和defaultValues的值,但是经过Function构造的函数所在的却是全局作用域,无法在当前上下文中形成闭包。因此此路不通。

虽然这个方案失败了,但我对Function构造函数的理解却更进了一步,也算是小有收获。

第二种方案是使用eval()来构造一个function对象。该方案并没有实施,因为我知道eval()也会使构造的代码脱离当前作用域,因此也无法形成闭包,pass掉。(幸好这种方法不行,否则用eval()对于强迫症的我来说必然很难受)

至此问题再度陷入了僵局。期间又尝试了数种方案但都行不通。忽然我灵机一动,实际上我们并不需要让包装函数的形参列表与原函数一致,只需让它的toString()返回的结果与原函数的形参列表一致即可,因为我们并不需要真正的反射包装函数本身,只是通过它的toString()来解析而已。因此,我们只需重写包装函数的toString()方法,让其返回原函数的toString()的值即可:

wrapper.toString = function() {
    return func.toString();
};

一句代码就完美地解决了这个问题,心里着实有点小激动。于是再次信心满满地点击提交,本以为这次一定能顺利地通过,但是很不幸地再次遭遇了失败。这次的原因是:当传入的函数的形参列表中包含注释时会导致形参的解析不正确。例如:

function add(a, // 注释
        b /* 注释 */) {
    return a + b;
}

此时add.toString()返回的字符串中是包含这些注释的,如果不加处理,就会把注释的内容错误地当成形参的一部分,自然是不行的。不过这个问题比较简单,只需在匹配到括号中的内容后将注释去掉就可以了,使用合适的正则表达式调用replace()即可:

var argNameStr = match[1].replace(/\/\/.*/gm, '') // remove single-line comments
    .replace(/\/\*.*?\*\//g, '') // remove multi-line comments
    .replace(/\s+/g, ''); // remove spaces

这两个正则表达式就不再赘述了。修改后再次提交,这次终于通过了全部测试用例!撒花~~~撒花~~~

完整程序如下:

var defaultArguments = function(func, defaultValues) {
    if (!func) return null;
    if (!defaultValues) return func;

    var match = func.toString().match(/function[^\(]*\(([^\)]*)\)/);
    if (!match || match.length < 2) return func;

    var argNameStr = match[1].replace(/\/\/.*/gm, '') // remove single-line comments
            .replace(/\/\*.*?\*\//g, '') // remove multi-line comments
            .replace(/\s+/g, ''); // remove spaces
    if (!argNameStr) return func;
    var argNames = argNameStr.split(',');

    var wrapper = function() {
        var args = Array.prototype.slice.call(arguments);
        for (var i = arguments.length; i < argNames.length; i++) {
            args[i] = defaultValues[argNames[i]];
        }
        return func.apply(null, args);
    };
    wrapper.toString = function() {
        return func.toString();
    };

    return wrapper;
};

 

到此还没有结束

虽然最终提交成功了,但回过头再仔细检查一下代码,发现还是有问题。例如对于下面的代码:

function add(a,b,c) { return a+b+c; }
var add_ = defaultArguments(add,{b:2,c:3});
// 修改c的默认值,注意此时b的默认值应该仍然为2
add_ = defaultArguments(add_,{c:10});
add_(1);

 

因为最终b和c的默认值分别为2和10,所以这段代码的结果应该是13,但实际得到的却是NaN。

这个问题在提交时没有被测试出来,看来原题的测试用例并不完善。要修复这个问题,就要先搞清楚原因。我们来看看当执行上面的代码时的过程是怎样的。

这段代码一共调用了2次defaultArguments,因此会生成2个包装函数,2个函数是嵌套的。我们不妨把第一次生成的称为wrapper1,第二次的称为wrapper2。wrapper1包着原函数,wrapper2又包着wrapper1。它们之间的关系如下图所示:

当最后调用“add_(1)”时,实际上在调用wrapper2,“1”作为参数传给wrapper2,此时a的值为1,b和c都没有值。然后经过wrapper2的处理,会形成新的实参:“1, undefined, 10”并传给wrapper1。注意此时3个参数都是有值的,所以并不会用默认值替换,因此wrapper1会直接将它们传给原函数add,所以最终返回的是“1+undefined+10”,这个结果为NaN。整个过程如下图所示:

明白了原因,但是该怎么解决呢?我的方法是在生成的包装函数上将defaultValues的值保存下来,下次调用时,先判断是否已经存在之前指定的默认值,如果存在,就将其合并到新的默认值里去。

// 如果之前保存过默认值,将其取出合并到新的defaultValues中
var _defaultValues = func._defaultValues;
if (_defaultValues) {
    for (var k in _defaultValues) {
        if (!defaultValues.hasOwnProperty(k)) {
            defaultValues[k] = _defaultValues[k];
        }
    }
}

......

// 生成wrapper后,把defaultValues保存到wrapper中
wrapper._defaultValues = defaultValues;

如果此时再次运行,2次生成的包装函数将如下图所示:

wrapper2中的默认参数不再只有c=10,而是会将wrapper1中定义的b的默认值合并过来,从而不会再有之前的问题了。实际上通过此图还可以看出,此时的wrapper1对wrapper2来说已经用处不大了,因为有了新的默认参数,已经不再需要wrapper1中的默认参数了。wrapper1剩下的唯一用处只是用来最终调用原函数而已。那么如果我们把初始传入的函数也保存下来,在wrapper2中直接调用,就可以完全去掉wrapper1了。这只需添加两句代码:

// 如果有保存的func函数就取出来,从而省掉一层对wrapper的调用
func = func._original ? func._original : func;

......

// 生成wrapper后,保存func函数
wrapper._original = func;

这时运行上面的测试代码,wrapper2中就不再有wrapper1了。如下图所示:

至此,我觉得代码已经趋于完美了。最终的代码如下:

var defaultArguments = function(func, defaultValues) {
    if (!func) return null;
    if (!defaultValues) return func;

    // 如果之前保存过默认值,将其取出合并到新的defaultValues中
    var _defaultValues = func._defaultValues;
    if (_defaultValues) {
        for (var k in _defaultValues) {
            if (!defaultValues.hasOwnProperty(k)) {
                defaultValues[k] = _defaultValues[k];
            }
        }
    }

    // 如果有保存的func函数就取出来,从而省掉一层对wrapper的调用
    func = func._original ? func._original : func;
    var match = func.toString().match(/function[^\(]*\(([^\)]*)\)/);
    if (!match || match.length < 2) return func;

    var argNameStr = match[1].replace(/\/\/.*/gm, '') // remove single-line comments
            .replace(/\/\*.*?\*\//g, '') // remove multi-line comments
            .replace(/\s+/g, ''); // remove spaces
    if (!argNameStr) return func;
    var argNames = argNameStr.split(',');

    var wrapper = function() {
        var args = Array.prototype.slice.call(arguments);
        for (var i = arguments.length; i < argNames.length; i++) {
            args[i] = defaultValues[argNames[i]];
        }
        return func.apply(null, args);
    };
    // 重写wrapper的toString方法,返回原始func函数的toString()结果
    wrapper.toString = function() {
        return func.toString();
    };
    // 把原始的func函数和当前的默认值对象保存到wrapper中
    wrapper._original = func;
    wrapper._defaultValues = defaultValues;

    return wrapper;
};

 

总结

这个问题看似简单,但实现起来却不简单。其中涉及到JS中的许多知识点,有些是平时不太会注意的。因此我在解题过程中也是一边思考一边实验一边查资料,最终才搞定了这个问题,并且我的答案比很多人的答案更优秀,心里的成就感还是挺高的。

 

最后,谢谢阅读,如有错误请务必指出。【完】

 

posted @ 2015-10-11 20:58  Antineutrino  阅读(47527)  评论(9编辑  收藏  举报