最近練習 Python 的時候,遇到函式參數使用可變物件預設引數(例如 list 或 dict)的地雷,請各位想想下方這個簡單的 函式範例:
def foo(a=[]):
a.append(5)
return a
以下回傳結果是什麼呢?
>>> foo()
>>> foo()
>>> foo()
我以為的輸出是這樣的:
>>> foo()
[5]
>>> foo()
[5]
>>> foo()
[5]
實際上輸出如下:
>>> foo()
[5]
>>> foo()
[5, 5]
>>> foo()
[5, 5, 5]
如果各位跟我一樣不太確定這是怎麼回事,好豪在這篇筆記簡短地用一級物件解釋 Python 為何如此處理、我們如何能避開這項地雷的方式、也介紹適合運用這種特性的範例。
函式是一級物件#
Python 函式是一級物件(first-class object),意思是它可以被指派給變數、存在資料結構中、做為參數傳給其他函式,甚至可以當作其他函式的傳回值。
即使這句話一時無法全部理解,請讀者只要記得:Python 函式也是一種物件、也擁有一般物件的特性。
## 我們建立的函式是 'function' 類別的一個實例
>>> type(foo)
<class 'function'>
## Python 所有類別都繼承自 object 類別
## 函式當然就也是 object 的子類別
>>> isinstance(foo, object)
True
## 你也可以用 dir() 看到函式物件有哪些成員(attributes)
>>> dir(foo)
['__annotations__', '__call__', '__class__', '__closure__', '__code__', '__defaults__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__get__', '__getattribute__', '__globals__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__kwdefaults__', '__le__', '__lt__', '__module__', '__name__', '__ne__', '__new__', '__qualname__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__']
## 函式是「存著程式碼的物件」
## 程式碼被變成位元組碼(bytecode)儲存在 __code__ 成員裡了
## 你可以用 dis library 來反組譯看到儲存的位元組碼
>>> foo.__code__
<code object foo at 0x103a86240, file "<stdin>", line 1>
>>> foo.__code__.co_code
b'|\x00\xa0\x00d\x01\xa1\x01\x01\x00|\x00S\x00'
>>> import dis
>>> dis.dis(foo)
2 0 LOAD_FAST 0 (a)
2 LOAD_METHOD 0 (append)
4 LOAD_CONST 1 (5)
6 CALL_METHOD 1
8 POP_TOP
3 10 LOAD_FAST 0 (a)
12 RETURN_VALUE
def 在做什麼#
根據 官方文件,def 是一種陳述(statement)、將創造一個函式物件。
函式參數如果有預設引數(default argument),只有在 def 的時候會被運算唯一一次、以後呼叫函式並不會重新運算引數預設值。函式預設引數在 def 的當下就被運算、並綁定在函式物件身上,這些預設值你可以在 __defaults__
成員裡找到。
這樣就很好理解本文範例遇到的問題了:
- 在創造函式物件(def)的當下,預設引數的 list 物件被創造、放進
__defaults__
- 只要我們沒有重新執行 def,那麼
__defaults__
內的 list 就永遠是同一個 list 物件 - 如果函式內的行為(function body)會改動
__defaults__
內的 list 物件,那未來重新呼叫同個函式、預設值的 list 內容當然也會改變
## 參數預設值就是函式物件的 __defaults__ 成員
>>> def foo(a=[]):
... a.append(5)
... return a
...
>>> foo.__defaults__
([],)
>>> foo()
[5]
>>> foo.__defaults__
([5],)
>>> foo()
[5, 5]
>>> foo.__defaults__
([5, 5],)
如何避開陷阱#
如果害怕踩到這個地雷,就提醒自己:「函式參數的預設引數不可以直接設置可變物件!」,也就是避免用 list、set、或者 dict 物件在參數列設置預設引數。
當函式參數需要這些可變物件預設值,可以把設置預設值的行為從參數列移動到函式主體(function body)再做,範例如下:
def foo(a=None):
if a is None:
a = []
a.append(5)
return a
這樣寫就可以讓函式的可變物件預設值更符合預期:
>>> foo()
[5]
>>> foo()
[5]
>>> foo()
[5]
這個函式,再加上 Python 的 Type Hints 的話,就可以有更好的可讀性、並且更容易 debug,有興趣的讀者請參考 Python Type Lints 教學的 Optional
章節。
(網友補充)如果你是 linter 的愛用者,部分 linter 在你使用可變物件當作預設引數時會提出警告。常見的 pylint 在規則 W0102 就有提供 dangerous-default-value 警告功能。
這種設計只是地雷?沒有好處嗎?#
好豪在搜尋 StackOverflow 時,看到大家爭吵著這究竟算不算是 Python 的設計問題,然而,我也找到了一個 Cache 機制的有趣範例,說明這種參數預設值設定行為也有好處:
def calculate(a, b, c, memo={}):
try:
# 如果算過了,就回傳以前算好的值、不需要重算
value = memo[(a, b, c)]
except KeyError:
# 如果沒算過,就計算、並且把計算結果儲存下來
print("用力計算中")
value = a + b*2 + c*3 # 一些可能超複雜的計算
memo[(a, b, c)] = value
return value
如果每次運算量都很龐大,這種寫法將特別有優勢,可以用記憶體空間去節省運算時間。
>>> calculate(1, 6, 8)
用力計算中
37
>>> calculate.__defaults__ ## 算過就記下來
({(1, 6, 8): 37},)
>>> calculate(8, 8, 1)
用力計算中
27
>>> calculate.__defaults__
({(1, 6, 8): 37, (8, 8, 1): 27},)
## 若再次以同樣的引數呼叫,不用重算,查看記憶體內記下的就好
>>> calculate(8, 8, 1)
27
>>> calculate.__defaults__
({(1, 6, 8): 37, (8, 8, 1): 27},)
結語#
整理一下:def 創造一個「存著程式碼的物件」。
- 預設引數在創造函式時就運算好、並存在物件的 __defaults__ 裡了
- 而程式碼則是變成 bytecode 儲存在 __code__ ,實際呼叫時才運算
總之,函式參數的預設引數會用到任何可變物件像是 list、set、或者 dict 的時候,多想幾秒鐘就對了!
參考資料:#
- 這篇文章是我的 Udemy - Python RESTful API and Flask 線上課程學習筆記,你也有興趣上課的話、歡迎參考我的 課程心得
- Python 面試練習題
- StackOverflow 討論
- effbot.org
還想知道更多 Python 相關技巧嗎?推薦你閱讀好豪蒐集的《Python 神乎其技》免費教學文章,學會更多 Pythonic Code!
感謝你閱讀這篇筆記,歡迎追蹤 好豪的粉絲專頁,我會持續分享 Python 技巧、資料科學等知識;也歡迎點選下方按鈕將本文加入書籤、或者分享給更多正在學 Python 的朋友。