最近在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应该返回一个函数,这个函数用来对原函数的调用进行包装,从而在调用原函数以前对传入的参数进行处理,用默认值替换那些未传入值的参数。因为默认值是用形参名称来指定的,而不是参数在列表中的顺序,因此须要得到形参的列表,才能判断为哪些参数指定了默认值。app
刚开始准备写代码就遇到了第一个难题:怎么才能得到一个函数的形参列表呢?框架
这个问题确实让我抓耳挠腮了一阵,最后想出了一个方法。咱们知道JS中的全部对象都有toString()方法,函数是一个function对象,那么function对象的toString()返回什么呢?对了,就是函数的定义代码。例如add.toString()将返回“function add(a, b) {return a+b;}”。函数
拿到了定义函数的字符串,获取形参列表的方法也就有了,只需把括号里的内容取出来,而后以逗号进行拆分就能够了(注意要去除参数名先后的空格)。测试
(后来再次阅读问题描述的时候发现问题中是有提示能够用这种方法来得到形参列表的,没认真读题的悲哀啊。)spa
要取出形参列表,一种方法是先找到左右括号的索引,而后用substring()来取;另外一种是用正则表达式来取。我使用的是正则表达式的方式:prototype
var match = func.toString().match(/function([^\(]*)\(([^\)]*)\)/); var argNames = match[2].split(',');
这个正则表达式的匹配过程以下图:
第一个分组(group 1)用来匹配左括号前面的函数名部分,第二个分组(group 2)用来匹配括号中的形参列表。注意函数名和形参列表都不是必须的,所以匹配时使用的是*号。match()方法返回的是一个数组,第一个元素是匹配到的完整结果,后面的元素依次为各个捕获分组所匹配到的内容,因此形参列表所在的group 2分组对应返回结果的第三个元素。
形参列表有了,接下来就是得到实参了。由于func函数不是咱们本身定义的,咱们没法用形参名称来引用实参,可是JS为每一个函数的调用隐式提供了一个变量arguments,用来获取传入的实参。关于arguments这里就很少说了,相信会JS的都比较了解。
一开始个人作法是,遍历形参数组,若是发现对应的参数值为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中的许多知识点,有些是平时不太会注意的。所以我在解题过程当中也是一边思考一边实验一边查资料,最终才搞定了这个问题,而且个人答案比不少人的答案更优秀,内心的成就感仍是挺高的。
最后,谢谢阅读,若有错误请务必指出。【完】