最近練習 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 神乎其技》
即使這句話一時無法全部理解,請讀者只要記得: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 的朋友。