最近在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中的許多知識點,有些是平時不太會注意的。所以我在解題過程當中也是一邊思考一邊實驗一邊查資料,最終才搞定了這個問題,而且個人答案比不少人的答案更優秀,內心的成就感仍是挺高的。
最後,謝謝閱讀,若有錯誤請務必指出。【完】