Python - Mutable Default Argument 的陷阱與解法

在 Python 的世界裡,有一種 Bug 特別隱晦:它不會讓你程式崩潰,但會讓你的邏輯變得「有記憶」。這種現象通常發生在你給函式定義了一個可變物件(例如 listdict)作為預設參數時。

如果你曾寫過類似 def add_item(item, items=[]) 的程式碼,並發現 items 竟然會「自動累積」上一次呼叫的內容,那麼你已經踩進了 Mutable Default Argument 的陷阱。

經典的陷阱範例

讓我們看一段看似無害的程式碼:

1
2
3
4
5
6
def append_to(element, to=[]):
to.append(element)
return to

print(append_to(12)) # 預期:[12]
print(append_to(42)) # 預期:[42],但結果卻是?

輸出結果:

1
2
[12]
[12, 42]

第二次呼叫時,我們並沒有傳入 to 參數,理論上它應該使用預設的空列表 []。但結果顯示,它竟然「記得」了第一次呼叫時加入的 12。這就是所謂的「幽靈參數」。

為什麼會這樣?

這並非 Python 的 Bug,而是其物件模型的設計選擇。

在 Python 中,函式的預設參數只會在函式被定義(Definition time)時評估一次,而不是在函式被呼叫(Call time)時評估。

  1. 定義時: 當 Python 直譯器讀到 def append_to(...) 這行時,它會建立一個函式物件,並將 [] 這個列表物件建立起來,存放在函式的 __defaults__ 屬性中。
  2. 呼叫時: 如果呼叫時沒有提供該參數,Python 就會直接「引用」存放在 __defaults__ 裡面的那個同一個物件。

因為列表是 可變(Mutable) 的,當你在函式內部執行 to.append() 時,你實際上是修改了那個存在於函式定義中的「全域」預設物件。

揭開黑盒子

你可以透過檢查函式的屬性來驗證這一點:

1
print(append_to.__defaults__)  # ( [12, 42], )

你會看到 __defaults__ 元組中存放的列表物件已經被改得面目全非了。

最佳實務:如何正確解決?

解決這個問題的標準做法(Idiomatic Python)是:使用 None 作為預設值,並在函式內部進行初始化。

1
2
3
4
5
6
7
8
def append_to(element, to=None):
if to is None:
to = []
to.append(element)
return to

print(append_to(12)) # [12]
print(append_to(42)) # [42] - 這次正確了!

為什麼這樣有效?

  • None 是不可變(Immutable)的,且作為單例物件,它的評估代價極低。
  • to = [] 這行程式碼是在呼叫時執行的。這意味著每次進入函式時,如果沒有傳入參數,都會建立一個全新的、空的列表物件。

有沒有例外?

雖然 99% 的情況下這都是個坑,但偶爾這也被用來當作一種快取機制(Caching/Memoization)

例如,在計算費氏數列時利用預設參數來儲存已計算過的結果:

1
2
3
4
def fib(n, cache={0: 0, 1: 1}):
if n not in cache:
cache[n] = fib(n - 1) + fib(n - 2)
return cache[n]

不過,這種寫法可讀性較差,現代 Python 更推薦使用 functools.lru_cache 裝飾器來達成同樣的目的,既優雅又安全:

1
2
3
4
5
6
7
from functools import lru_cache

@lru_cache(maxsize=None)
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)

總結

  1. 預設參數在定義時評估: 記住它們是函式物件的一部分,而不是每次呼叫時重新生成。
  2. 避免使用可變物件: 永遠不要使用 []{} 或自定義類別實例作為預設參數。
  3. 擁抱 None 使用 arg=None 搭配 if arg is None 是 Pythonic 的標準解答。

理解了這個機制,你就避開了 Python 初學者最容易遇到的「幽靈 Bug」之一。

也許你也會想看看