本文將介紹使用mutable物件作為Python函式引數預設值潛在的危害,以及其實現原理和設計目的
陷阱重現
我們就用實際的舉例來演示我們今天所要討論的主要內容。
下面一段程式碼定義了一個名為 generate_new_list_with
的函式。該函式的本意是在每次呼叫時都新建一個包含有給定 element
值的list。而實際執行結果如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
Python 2.7.9 (default, Dec 19 2014, 06:05:48) [GCC 4.2.1 Compatible Apple LLVM 6.0 (clang-600.0.56)] on darwin Type "help", "copyright", "credits" or "license" for more information. >>> def generate_new_list_with(my_list=[], element=None): ... my_list.append(element) ... return my_list ... >>> list_1 = generate_new_list_with(element=1) >>> list_1 [1] >>> list_2 = generate_new_list_with(element=2) >>> list_2 [1, 2] >>> |
可見程式碼執行結果並不和我們預期的一樣。list_2
在函式的第二次呼叫時並沒有得到一個新的list並填入2,而是在第一次呼叫結果的基礎上append了一個2。為什麼會發生這樣在其他程式語言中簡直就是設計bug一樣的問題呢?
準備知識:Python變數的實質
要了解這個問題的原因我們先需要一個準備知識,那就是:Python變數到底是如何實現的?
Python變數區別於其他程式語言的申明&賦值方式,採用的是建立&指向的類似於指標的方式實現的。即Python中的變數實際上是對值或者物件的一個指標(簡單的說他們是值得一個名字)。我們來看一個例子。
1 2 |
p = 1 p = p+1 |
對於傳統語言,上面這段程式碼的執行方式將會是,先在記憶體中申明一個p
的變數,然後將1
存入變數p
所在記憶體。執行加法操作的時候得到2
的結果,將2
這個數值再次存入到p
所在記憶體地址中。可見整個執行過程中,變化的是變數p
所在記憶體地址上的值
面這段程式碼中,Python實際上是現在執行記憶體中建立了一個1
的物件,並將p
指向了它。在執行加法操作的時候,實際上通過加法操作得到了一個2
的新物件,並將p
指向這個新的物件。可見整個執行過程中,變化的是p
指向的記憶體地址
函式引數預設值陷阱的根本原因
一句話來解釋:Python函式的引數預設值,是在編譯階段就繫結的。
現在,我們先從一段摘錄來詳細分析這個陷阱的原因。下面是一段從Python Common Gotchas中摘錄的原因解釋:
Python’s default arguments are evaluated once when the function is defined, not each time the function is called (like it is in say, Ruby). This means that if you use a mutable default argument and mutate it, you will and have mutated that object for all future calls to the function as well.
可見如果引數預設值是在函式編譯compile
階段就已經被確定。之後所有的函式呼叫時,如果引數不顯示的給予賦值,那麼所謂的引數預設值不過是一個指向那個在compile
階段就已經存在的物件的指標。如果呼叫函式時,沒有顯示指定傳入引數值得話。那麼所有這種情況下的該引數都會作為編譯時建立的那個物件的一種別名存在。
如果引數的預設值是一個不可變(Imuttable
)數值,那麼在函式體內如果修改了該引數,那麼引數就會重新指向另一個新的不可變值。而如果引數預設值是和本文最開始的舉例一樣,是一個可變物件(Muttable
),那麼情況就比較糟糕了。所有函式體內對於該引數的修改,實際上都是對compile
階段就已經確定的那個物件的修改。
對於這麼一個陷阱在 Python官方文件中也有特別提示:
Important warning: The default value is evaluated only once. This makes a difference when the default is a mutable object such as a list, dictionary, or instances of most classes. For example, the following function accumulates the arguments passed to it on subsequent calls:
如何避免這個陷阱帶來不必要麻煩
當然最好的方式是不要使用可變物件作為函式預設值。如果非要這麼用的話,下面是一種解決方案。還是以文章開頭的需求為例:
1 2 3 4 5 |
def generate_new_list_with(my_list=None, element=None): if my_list is None: my_list = [] my_list.append(element) return my_list |
為什麼Python要這麼設計
這個問題的答案在 StackOverflow 上可以找到答案。這裡將得票數最多的答案最重要的部分摘錄如下:
Actually, this is not a design flaw, and it is not because of internals, or performance.
It comes simply from the fact that functions in Python are first-class objects, and not only a piece of code.
As soon as you get to think into this way, then it completely makes sense: a function is an object being evaluated on its definition; default parameters are kind of “member data” and therefore their state may change from one call to the other – exactly as in any other object.
In any case, Effbot has a very nice explanation of the reasons for this behavior in Default Parameter Values in Python.
I found it very clear, and I really suggest reading it for a better knowledge of how function objects work.
在這個回答中,答題者認為出於Python編譯器的實現方式考慮,函式是一個內部一級物件。而引數預設值是這個物件的屬性。在其他任何語言中,物件屬性都是在物件建立時做繫結的。因此,函式引數預設值在編譯時繫結也就不足為奇了。
然而,也有其他很多一些回答者不買賬,認為即使是first-class object
也可以使用closure
的方式在執行時繫結。
This is not a design flaw. It is a design decision; perhaps a bad one, but not an accident. The state thing is just like any other closure: a closure is not a function, and a function with mutable default argument is not a function.
甚至還有反駁者拋開實現邏輯,單純從設計角度認為:只要是違背程式猿基本思考邏輯的行為,都是設計缺陷!下面是他們的一些論調:
> Sorry, but anything considered “The biggest WTF in Python” is most definitely a design flaw. This is a source of bugs for everyone at some point, because no one expects that behavior at first – which means it should not have been designed that way to begin with.
The phrases “this is not generally what was intended” and “a way around this is” smell like they’re documenting a design flaw.
好吧,這麼看來,如果沒有來自於Python作者的親自陳清,這個問題的答案就一直會是一個謎了。