Python - Mutable Default Argument 的陷阱與解法
在 Python 的世界裡,有一種 Bug 特別隱晦:它不會讓你程式崩潰,但會讓你的邏輯變得「有記憶」。這種現象通常發生在你給函式定義了一個可變物件(例如 list 或 dict)作為預設參數時。
如果你曾寫過類似 def add_item(item, items=[]) 的程式碼,並發現 items 竟然會「自動累積」上一次呼叫的內容,那麼你已經踩進了 Mutable Default Argument 的陷阱。
經典的陷阱範例
讓我們看一段看似無害的程式碼:1
2
3
4
5
6def 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)時評估。
- 定義時: 當 Python 直譯器讀到
def append_to(...)這行時,它會建立一個函式物件,並將[]這個列表物件建立起來,存放在函式的__defaults__屬性中。 - 呼叫時: 如果呼叫時沒有提供該參數,Python 就會直接「引用」存放在
__defaults__裡面的那個同一個物件。
因為列表是 可變(Mutable) 的,當你在函式內部執行 to.append() 時,你實際上是修改了那個存在於函式定義中的「全域」預設物件。
揭開黑盒子
你可以透過檢查函式的屬性來驗證這一點:1
print(append_to.__defaults__) # ( [12, 42], )
你會看到 __defaults__ 元組中存放的列表物件已經被改得面目全非了。
最佳實務:如何正確解決?
解決這個問題的標準做法(Idiomatic Python)是:使用 None 作為預設值,並在函式內部進行初始化。1
2
3
4
5
6
7
8def 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
4def 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
7from functools import lru_cache
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
總結
- 預設參數在定義時評估: 記住它們是函式物件的一部分,而不是每次呼叫時重新生成。
- 避免使用可變物件: 永遠不要使用
[]、{}或自定義類別實例作為預設參數。 - 擁抱
None: 使用arg=None搭配if arg is None是 Pythonic 的標準解答。
理解了這個機制,你就避開了 Python 初學者最容易遇到的「幽靈 Bug」之一。