ミュータブルなデフォルト引数の罠

Python で最も有名なアンチパターンの一つが、ミュータブルなデフォルト引数だ。特にクラスの __init__ で発生しやすく、見つけにくいバグの原因になる。

問題のあるコード

class ShoppingCart:
    def __init__(self, items=[]):
        self.items = items
    
    def add(self, item):
        self.items.append(item)

# 使ってみる
cart1 = ShoppingCart()
cart1.add("りんご")
print(cart1.items)  # ['りんご']

cart2 = ShoppingCart()
print(cart2.items)  # ['りんご'] ← なぜ?

cart2 は新しいインスタンスなのに、cart1 で追加した「りんご」が入っている。これは意図した動作ではないはずだ。

なぜこうなるのか

デフォルト引数は関数定義時に一度だけ評価される。つまり items=[] の空リストは、クラス定義時に作られた 1 つのリストオブジェクトを指し続ける。

期待する動作

インスタンスごとに新しい空リストが作られる

実際の動作

すべてのインスタンスが同じリストオブジェクトを共有する

class ShoppingCart:
    def __init__(self, items=[]):
        self.items = items

cart1 = ShoppingCart()
cart2 = ShoppingCart()

print(id(cart1.items))  # 140234567890
print(id(cart2.items))  # 140234567890 ← 同じ id
print(cart1.items is cart2.items)  # True

正しい書き方

ミュータブルなデフォルト引数には None を使い、関数内で初期化する。

class ShoppingCart:
    def __init__(self, items=None):
        if items is None:
            items = []
        self.items = items
    
    def add(self, item):
        self.items.append(item)

cart1 = ShoppingCart()
cart1.add("りんご")

cart2 = ShoppingCart()
print(cart2.items)  # [] ← 期待どおり空

あるいは、より簡潔に書くこともできる。

class ShoppingCart:
    def __init__(self, items=None):
        self.items = items if items is not None else []

リストだけではない

この問題はリストに限らない。辞書、セット、その他のミュータブルなオブジェクトすべてで起きる。

# すべて危険
def bad_func(data={}): ...
def bad_func(items=set()): ...
def bad_func(config=SomeMutableClass()): ...

# 安全
def good_func(data=None):
    if data is None:
        data = {}

なぜ Python はこういう仕様なのか

一見バグに見えるこの仕様だが、理由がある。デフォルト引数を呼び出しごとに評価すると、関数呼び出しのたびにオブジェクト生成コストがかかる。また、デフォルト引数をキャッシュとして利用するテクニックもある。

# メモ化のテクニック(意図的な利用例)
def fibonacci(n, cache={0: 0, 1: 1}):
    if n not in cache:
        cache[n] = fibonacci(n - 1) + fibonacci(n - 2)
    return cache[n]

ただし、このテクニックは可読性が低いため、functools.lru_cache を使うほうが望ましい。

覚えておくべきルール

デフォルト引数にはイミュータブルな値を使う

None、数値、文字列、タプルなどは安全。リスト、辞書、セットは危険。

ミュータブルな初期値が必要なら None を使う

関数内で if arg is None: のパターンで初期化する。これが Python の慣習。

この罠は Python を何年書いていても踏むことがある。IDE や Linter(pylint、flake8)はこのパターンを警告してくれる。