Python 函式的可變物件預設引數陷阱

by 好豪
Published: Last Updated on

最近練習 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 的時候,多想幾秒鐘就對了!

參考資料:


還想知道更多 Python 相關技巧嗎?推薦你閱讀好豪蒐集的《Python 神乎其技》免費教學文章,學會更多 Pythonic Code!

感謝你閱讀這篇筆記,歡迎追蹤 好豪的粉絲專頁,我會持續分享 Python 技巧、資料科學等知識;也歡迎點選下方按鈕將本文加入書籤、或者分享給更多正在學 Python 的朋友。

推薦閱讀