陷阱!python引數預設值

發表於2016-10-01

在stackoverflow上看到這樣一個程式:

輸出結果是

[0]
[0, 1]
[0, 1, 2]
[0, 1, 2, 3]
[0, 1, 2, 3, 4]

有點奇怪,難道輸出不應該是像下面這樣嗎?

<!– more –>

[0]
[1]
[2]
[3]
[4]

其實想要得到上面的輸出,只需要將obj = intlist()替換為obj = intlist(l=[])

預設引數工作機制

上面怪異的輸出簡單來說是因為:

Default values are computed once, then re-used.

因此每次呼叫__init__(),返回的是同一個list。為了驗證這一點,下面在__init__函式中新增一條語句,如下:

輸出結果為:

4346933688 [0]
4346933688 [0, 1]
4346933688 [0, 1, 2]
4346933688 [0, 1, 2, 3]
4346933688 [0, 1, 2, 3, 4]

可以清晰看出每次呼叫__init__函式時,預設引數l都是同一個物件,其id為4346933688。

關於預設引數,文件中是這樣說的:

Default parameter values are evaluated when the function definition is executed. This means that the expression is evaluated once, when the function is defined, and that the same “pre-computed” value is used for each call.

為了能夠更好地理解文件內容,再來看一個例子:

注意,當python執行def語句時,它會根據編譯好的函式體位元組碼和名稱空間等資訊新建一個函式物件,並且會計算預設引數的值。函式的所有構成要素均可通過它的屬性來訪問,比如可以用func_name屬性來檢視函式的名稱。所有預設引數值則儲存在函式物件的__defaults__屬性中,它的值為一個列表,列表中每一個元素均為一個預設引數的值。

好了,你應該已經知道上面程式的輸出內容了吧,一個可能的輸出如下(id值可能為不同):

a executed
————— Call b() —————
id(x): 4316528512
x: [5]
([5],)
id(b.__defaults__[0]): 4316528512
————— Call b() —————
id(x): 4316528512
x: [5, 5]
([5, 5],)
id(b.__defaults__[0]): 4316528512
————— Call b(list()) —————
id(x): 4316684872
x: [5]
([5, 5],)
id(b.__defaults__[0]): 4316528512
————— Call b(list()) —————
id(x): 4316684944
x: [5]
([5, 5],)
id(b.__defaults__[0]): 4316528512

我們看到,在定義函式b(也就是執行def語句)時,已經計算出預設引數x的值,也就是執行了a函式,因此才會列印出a executed。之後,對b進行了4次呼叫,下面簡單分析一下:

  1. 第一次不提供預設引數x的值進行呼叫,此時使用函式b定義時計算出來的值作為x的值。所以id(x)和id(b.__defaults__[0])相等,x追加數字後,函式屬性中的預設引數值也變為[5];
  2. 第二次仍然沒有提供引數值,x的值為經過第一次呼叫後的預設引數值[5],然後對x進行追加,同時也對函式屬性中的預設引數值追加;
  3. 傳遞引數list()來呼叫b,此時新建一個列表作為x的值,所以id(x)不同於函式屬性中預設引數的id值,追加5後x的值為[5];
  4. 再一次傳遞引數list()來呼叫b,仍然是新建列表作為x的值。

如果上面的內容你已經搞明白了,那麼你可能會覺得預設引數值的這種設計是python的設計缺陷,畢竟這也太不符合我們對預設引數的認知了。然而事實可能並非如此,更可能是因為:

Functions in Python are first-class objects, and not only a piece of code.

我們可以這樣解讀:函式也是物件,因此定義的時候就被執行,預設引數是函式的屬性,它的值可能會隨著函式被呼叫而改變。其他物件不都是如此嗎?

可變物件作為引數預設值?

引數的預設值為可變物件時,多次呼叫將返回同一個可變物件,更改物件值可能會造成意外結果。引數的預設值為不可變物件時,雖然多次呼叫返回同一個物件,但更改物件值並不會造成意外結果。

因此,在程式碼中我們應該避免將引數的預設值設為可變物件,上面例子中的初始化函式可以更改如下:

在這裡將None用作佔位符來控制引數l的預設值。不過,有時候引數值可能是任意物件(包括None),這時候就不能將None作為佔位符。你可以定義一個object物件作為佔位符,如下面例子:

雖然應該避免預設引數值為可變物件,不過有時候使用可變物件作為預設值會收到不錯的效果。比如我們可以用可變物件作為引數預設值來統計函式呼叫次數,下面例子中使用collections.Counter()作為引數的預設值來統計斐波那契數列中每一個值計算的次數。

執行結果如下:

89 Counter({2: 34, 1: 21, 3: 21, 4: 13, 5: 8, 6: 5, 7: 3, 8: 2, 9: 1, 10: 1})

我們還可以用預設引數來做簡單的快取,仍然以斐波那契數列作為例子,如下:

結果為:

89 Counter({2: 2, 3: 2, 4: 2, 5: 2, 6: 2, 7: 2, 8: 2, 1: 1, 9: 1, 10: 1})

這樣就快了太多了,fib_direct(n)呼叫次數為o(n),這裡也可以用裝飾器來實現計數和快取功能。

參考
Python instances and attributes: is this a bug or i got it totally wrong?
Default Parameter Values in Python
“Least Astonishment” in Python: The Mutable Default Argument
A few things to remember while coding in Python
Using Python’s mutable default arguments for fun and profit

相關文章