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中的許多知識點,有些是平時不太會注意的。因此我在解題過程中也是一邊思考一邊實驗一邊查資料,最終才搞定了這個問題,並且我的答案比很多人的答案更優秀,心里的成就感還是挺高的。

 

最后,謝謝閱讀,如有錯誤請務必指出。【完】

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM