Pythonでは関数のデフォルト引数が定義時に評価されるため、リストや辞書のように変更可能なオブジェクトを置くと、呼び出しのたびに前回の状態が残ってしまいます。
本記事では、なぜそれが危険なのか、内部の仕組みから安全な書き方、実践的なチェック方法までを丁寧に解説します。
デフォルト引数にリストや辞書は危険
定義時に一度だけ評価される
Pythonの関数定義では、デフォルト引数は関数定義が実行されたタイミングで一度だけ評価されます。
つまり、def func(x, acc=[])
の []
は関数が読み込まれた瞬間に1つ作られ、その後ずっと使い回されます。
# デフォルト引数が定義時に1度だけ評価されることを確認する例
def f(x, acc=[]): # 空リストはこの瞬間に作られて保持される
acc.append(x)
return acc
print(f.__defaults__) # 関数が持つデフォルト引数の実体を確認
print(id(f.__defaults__[0])) # リストのIDを表示
print(f(1))
print(id(f.__defaults__[0])) # 呼び出し後も同じIDかを確認
print(f(2))
([ ],)
140634844026752
[1]
140634844026752
[1, 2]
出力から、デフォルトのリストが1度だけ作られ、呼び出しごとに同じオブジェクトが使われていることが分かります。
ミュータブル共有で状態が残る
リストや辞書はミュータブル(可変)なオブジェクトです。
同じオブジェクトを共有してしまうと、前回の呼び出しで加えた変更が次回の呼び出しにも残ります。
これは多くの場合、意図しないバグの原因になります。
# 呼び出すたびに値が累積してしまう危険な例
def append_item(x, bucket=[]):
# 呼び出すたびに同じbucketに追加される
bucket.append(x)
return bucket
print(append_item("a")) # 1回目は["a"]
print(append_item("b")) # 2回目は["a", "b"] になってしまう
print(append_item("c")) # 3回目は["a", "b", "c"]
['a']
['a', 'b']
['a', 'b', 'c']
予期せぬ重複や前回の値が混入
「毎回空のリストから始めたい」「毎回空の辞書で集計したい」といった前提で関数を書くと、意図に反して以前の値が混ざることがあります。
# 前回のデータが混入して重複カウントされる例
def count_tags(tags, counter={}):
for t in tags:
counter[t] = counter.get(t, 0) + 1
return counter
print(count_tags(["py", "tips"]))
print(count_tags(["py", "mistake"])) # 前回のカウントが混入
{'py': 1, 'tips': 1}
{'py': 2, 'tips': 1, 'mistake': 1}
このように、知らないうちに状態を共有してしまうと、重複や誤集計が発生します。
デフォルト引数の仕組みとミュータブル
同じオブジェクトが全呼び出しで再利用
Pythonはデフォルト引数をタプルとして 関数.__defaults__
に保持します。
そこに格納されたオブジェクトは、関数を呼び出すたびにそのまま再利用されます。
リストや辞書のようなミュータブルを置くと、変更が次回以降にも反映されてしまいます。
# __defaults__ を覗いて同じオブジェクトが使い回されることを確認
def g(x, memo={"count": 0}):
memo["count"] += 1
return memo
print(g.__defaults__)
before_id = id(g.__defaults__[0])
print(g(10))
print(g(20))
after_id = id(g.__defaults__[0])
print(before_id == after_id) # True なら同一オブジェクト
({'count': 0},)
{'count': 1}
{'count': 2}
True
グローバルでないのに副作用が広がる
デフォルト引数は関数のスコープ外に保持されます。
あたかも関数間で共有されるグローバル変数のように振る舞い、呼び出し元が異なっても副作用が広がります。
これはローカル関数のつもりで書いたコードを不必要に密結合にし、テストもしづらくします。
イミュータブルなら安全
イミュータブル(不変)なオブジェクトをデフォルトにする場合、そもそもオブジェクトを変更できないため、同じものが再利用されても安全です。
例えば None
、0
、""
、()
、frozenset()
は安全です。
ただし、タプルでも中身にミュータブルが入っていると安全ではありません。
# タプルは不変なのでデフォルトにしても安全
def add_suffix(text, suffixes=()):
# 新しいタプルを返す操作はOK。suffixes自体は変更しない。
for s in suffixes:
text += s
return text
print(add_suffix("X")) # デフォルトの空タプル
print(add_suffix("X", ("-A", "-B"))) # 明示的に渡すのは安全
X
X-A-B
安全な書き方 Noneで遅延初期化
デフォルトはNoneにする
安全な定石は、デフォルトに None
を置き、関数の中で必要に応じて新しい list
や dict
を作る「遅延初期化」を行うことです。
# 安全なパターン: None を使って遅延初期化
def append_item_safe(x, bucket=None):
if bucket is None: # 初回や未指定の場合だけ新しいリストを作る
bucket = []
bucket.append(x)
return bucket
print(append_item_safe("a")) # ["a"]
print(append_item_safe("b")) # ["b"] 先ほどの結果は混入しない
print(append_item_safe("c", [])) # 明示的に新しいリストを渡してもOK
['a']
['b']
['c']
関数内で新しいlistやdictを作る
None
チェックの分岐内でだけ新しいコンテナを生成します。
このとき、毎回 []
や {}
を返すのではなく「一度作ったオブジェクトに対して処理する」という目的を明確に保ちます。
# 辞書でも同様に安全な書き方
def count_tags_safe(tags, counter=None):
if counter is None:
counter = {}
for t in tags:
counter[t] = counter.get(t, 0) + 1
return counter
print(count_tags_safe(["py", "tips"]))
print(count_tags_safe(["py", "mistake"]))
{'py': 1, 'tips': 1}
{'py': 1, 'mistake': 1}
型ヒントでOptionalとlistやdictを明示
型ヒントを使うと、None
を許容する意図と返り値の型が明確になります。
Python 3.9以降は組み込みジェネリック型表記(list[int]
, dict[str, int]
)が使えます。
3.8以前では typing
の List
, Dict
, Optional
を使います。
# Python 3.9+ の例
def append_item_typed(x: str, bucket: list[str] | None = None) -> list[str]:
if bucket is None:
bucket = []
bucket.append(x)
return bucket
# Python 3.8 互換の例
from typing import List, Dict, Optional
def count_tags_typed(tags: List[str], counter: Optional[Dict[str, int]] = None) -> Dict[str, int]:
if counter is None:
counter = {}
for t in tags:
counter[t] = counter.get(t, 0) + 1
return counter
print(append_item_typed("x"))
print(count_tags_typed(["a", "a", "b"]))
['x']
{'a': 2, 'b': 1}
ベストプラクティスとチェック方法
空のlistやdictをデフォルトにしない
関数定義の括弧内に []
や {}
を置かないことが最重要です。
これはPEP 8の精神にも合致する「驚き最小の原則」に沿った書き方であり、チーム全体で統一するとバグの予防効果が高いです。
必要であればコメントで意図を補足し、None
パターンを徹底します。
タプルやfrozensetは安全な選択
不変なコレクションである tuple
や frozenset
はデフォルトにしても安全です。
ただし、操作時に新しいオブジェクトを返す形にし、元のデフォルトを変更しようとしない実装にします。
タプルの中にミュータブルを入れると不安全になるので避けます。
以下に、代表的な型と安全性をまとめます。
型 | ミュータブル | デフォルトに安全か | 備考 |
---|---|---|---|
list | はい | いいえ | 共有され状態が残る |
dict | はい | いいえ | 同上 |
set | はい | いいえ | 同上 |
tuple | いいえ | はい | 中身がミュータブルなら不可 |
frozenset | いいえ | はい | 安全 |
str | いいえ | はい | 不変 |
int/float/bool | いいえ | はい | 不変 |
None | 該当なし | はい | 遅延初期化のトリガに最適 |
linterで違反を検出する
リンターを活用すると、危険なデフォルト引数を自動検出できます。
代表的なルールは以下です。
- Ruff: B006 (mutable default arguments)
- flake8-bugbear: B006
- Pylint: W0102 (dangerous-default-value)
Ruffを使う例を示します。
pyproject.toml
に設定を追加すると、プロジェクト全体をチェックできます。
# pyproject.toml の例
[tool.ruff]
select = ["E", "F", "B"] # E/F は一般的なエラー、B はbugbear系
ignore = []
検出例は次のとおりです。
# lint対象の危険な関数
def bad(x, items=[]): # B006: mutable default
items.append(x)
return items
example.py:2:15: B006 Do not use mutable data structures for argument defaults
Pylintでも同様に警告が出ます。
# Pylint 警告の例
# W0102: dangerous-default-value
def bad2(mapping={}): # デフォルト辞書は危険
mapping["a"] = 1
return mapping
example.py:3:0: W0102: Dangerous default value {} as argument (dangerous-default-value)
開発フローにリンターを組み込めば、レビューの手間を削減しながら、うっかりを防止できます。
まとめ
デフォルト引数は関数定義時に一度だけ評価され、以後は同じオブジェクトが再利用されます。
リストや辞書のようなミュータブルをデフォルトに置くと、呼び出し間で状態が共有され、意図しない重複や前回の値の混入を招きます。
これを避ける最もシンプルで強力な手法は、デフォルトを None
にし、関数内で新しい list
や dict
を作る遅延初期化パターンです。
型ヒントで Optional
を用いて意図を明確化し、必要に応じて tuple
や frozenset
といった不変コレクションを選ぶと安全です。
さらにRuffやPylintなどのリンターを活用すれば、プロジェクト全体で一貫した品質を保ちながら、ミュータブルなデフォルト引数によるバグを未然に防げます。