閉じる

なぜ危険?Pythonでデフォルト引数にリストや辞書を使うな

Pythonでは関数のデフォルト引数が定義時に評価されるため、リストや辞書のように変更可能なオブジェクトを置くと、呼び出しのたびに前回の状態が残ってしまいます。

本記事では、なぜそれが危険なのか、内部の仕組みから安全な書き方、実践的なチェック方法までを丁寧に解説します。

デフォルト引数にリストや辞書は危険

定義時に一度だけ評価される

Pythonの関数定義では、デフォルト引数は関数定義が実行されたタイミングで一度だけ評価されます。

つまり、def func(x, acc=[])[] は関数が読み込まれた瞬間に1つ作られ、その後ずっと使い回されます。

Python
# デフォルト引数が定義時に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度だけ作られ、呼び出しごとに同じオブジェクトが使われていることが分かります。

ミュータブル共有で状態が残る

リストや辞書はミュータブル(可変)なオブジェクトです。

同じオブジェクトを共有してしまうと、前回の呼び出しで加えた変更が次回の呼び出しにも残ります。

これは多くの場合、意図しないバグの原因になります。

Python
# 呼び出すたびに値が累積してしまう危険な例
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']

予期せぬ重複や前回の値が混入

「毎回空のリストから始めたい」「毎回空の辞書で集計したい」といった前提で関数を書くと、意図に反して以前の値が混ざることがあります。

Python
# 前回のデータが混入して重複カウントされる例
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__ に保持します。

そこに格納されたオブジェクトは、関数を呼び出すたびにそのまま再利用されます。

リストや辞書のようなミュータブルを置くと、変更が次回以降にも反映されてしまいます。

Python
# __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

グローバルでないのに副作用が広がる

デフォルト引数は関数のスコープ外に保持されます。

あたかも関数間で共有されるグローバル変数のように振る舞い、呼び出し元が異なっても副作用が広がります。

これはローカル関数のつもりで書いたコードを不必要に密結合にし、テストもしづらくします。

イミュータブルなら安全

イミュータブル(不変)なオブジェクトをデフォルトにする場合、そもそもオブジェクトを変更できないため、同じものが再利用されても安全です。

例えば None0""()frozenset() は安全です。

ただし、タプルでも中身にミュータブルが入っていると安全ではありません。

Python
# タプルは不変なのでデフォルトにしても安全
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 を置き、関数の中で必要に応じて新しい listdict を作る「遅延初期化」を行うことです。

Python
# 安全なパターン: 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 チェックの分岐内でだけ新しいコンテナを生成します。

このとき、毎回 []{} を返すのではなく「一度作ったオブジェクトに対して処理する」という目的を明確に保ちます。

Python
# 辞書でも同様に安全な書き方
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以前では typingList, Dict, Optional を使います。

Python
# 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は安全な選択

不変なコレクションである tuplefrozenset はデフォルトにしても安全です。

ただし、操作時に新しいオブジェクトを返す形にし、元のデフォルトを変更しようとしない実装にします。

タプルの中にミュータブルを入れると不安全になるので避けます。

以下に、代表的な型と安全性をまとめます。

ミュータブルデフォルトに安全か備考
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 に設定を追加すると、プロジェクト全体をチェックできます。

toml
# pyproject.toml の例
[tool.ruff]
select = ["E", "F", "B"]  # E/F は一般的なエラー、B はbugbear系
ignore = []

検出例は次のとおりです。

Python
# 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でも同様に警告が出ます。

Python
# 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 にし、関数内で新しい listdict を作る遅延初期化パターンです。

型ヒントで Optional を用いて意図を明確化し、必要に応じて tuplefrozenset といった不変コレクションを選ぶと安全です。

さらにRuffやPylintなどのリンターを活用すれば、プロジェクト全体で一貫した品質を保ちながら、ミュータブルなデフォルト引数によるバグを未然に防げます。

この記事を書いた人
エーテリア編集部
エーテリア編集部

人気のPythonを初めて学ぶ方向けに、文法の基本から小さな自動化まで、実際に手を動かして理解できる記事を書いています。

クラウドSSLサイトシールは安心の証です。

URLをコピーしました!