Python | データ構造強化:不変性とバグ

Python Python
スポンサーリンク
  1. 概要(不変性は「勝手に変わらない」ことでバグの連鎖を止める安全装置)
  2. 不変性がバグを防ぐメカニズム(ここが重要)
    1. 参照共有でも中身が動かないから「意図外の連動」が起きない
    2. “変更”は常に「新しいオブジェクトを作る」ので影響範囲が限定される
  3. 典型的なバグの温床(重要部分を深掘り)
    1. 参照共有による連動(mutableを“コピーせず”渡す)
    2. デフォルト引数の罠(mutableが“定義時から”共有され続ける)
    3. シャローコピーの誤用(外側だけコピーして中身は共有のまま)
    4. “不変の中に可変”で油断(タプルの内側がリスト)
    5. 辞書キーに可変を使って壊す(ハッシュ不能/値が変わる)
    6. 破壊的関数と“純粋関数”の混同(副作用が見えない)
  4. 実務での防御テクニック(壊れない設計)
    1. 防御的コピー戦略(“必要階層だけ”を独立)
    2. API契約の明文化(副作用の有無、返すもの)
    3. データを不変化して安全性を上げる(frozen dataclass / namedtuple)
    4. “返り値新規作成”パターンで副作用を遮断
    5. テストで境界条件を締める(空・1件・大量)
  5. 例題で身につける(定番から一歩先まで)
    1. 例題1:参照共有の連動と防御的コピー
    2. 例題2:不変レコードで安全なキーと設定
    3. 例題3:破壊的と純粋を分けた安全API
    4. 例題4:デフォルト引数の罠をテストで発見・修正
  6. まとめ

概要(不変性は「勝手に変わらない」ことでバグの連鎖を止める安全装置)

不変性(immutable)は「作った後に中身が変わらない」性質です。勝手に変わらないという保証は、参照共有や関数の副作用でデータがいつの間にか書き換わるバグを防ぎます。一方、変更可能(mutable)を無自覚に共有すると、別名経由の更新が広がり、原因追跡が難しいバグになります。どこを不変にすべきか、どこを変えてよいかを意図的に設計することが肝心です。

不変性がバグを防ぐメカニズム(ここが重要)

参照共有でも中身が動かないから「意図外の連動」が起きない

同じオブジェクトを複数の変数で参照していても、不変なら中身が変わりません。辞書キーやキャッシュに安心して使えます。

key = ("user", 101)       # タプルは不変→キーに安全
index = {key: "alice"}    # 参照共有しても中身は変わらない
Python

“変更”は常に「新しいオブジェクトを作る」ので影響範囲が限定される

文字列やタプルの“変更”は再代入=新規作成であり、既存の参照には影響しません。

s1 = "ab"
s2 = s1
s2 = s2 + "c"             # 新しい文字列
print(s1, s2)             # ab abc(s1は安全)
Python

典型的なバグの温床(重要部分を深掘り)

参照共有による連動(mutableを“コピーせず”渡す)

catalog = {"coffee": ["latte", "espresso"], "tea": ["earl"]}
alias = catalog           # 同じ辞書を指す
alias["coffee"].append("mocha")
print(catalog["coffee"])  # ['latte', 'espresso', 'mocha'] ← 想定外に増える
Python

「= はコピーではなく同じものを指す」ことを忘れると、別名からの更新が元データへ波及します。編集前に必要階層だけコピーします。

safe = {k: v[:] for k, v in catalog.items()}  # 値のリストまでコピー
Python

デフォルト引数の罠(mutableが“定義時から”共有され続ける)

def append_bad(x, buf=[]):  # NG
    buf.append(x)
    return buf

print(append_bad(1))  # [1]
print(append_bad(2))  # [1, 2] ← 前回が残る
Python

回避は「Noneを使って都度新規作成」。

def append_good(x, buf=None):
    if buf is None:
        buf = []
    buf.append(x)
    return buf
Python

シャローコピーの誤用(外側だけコピーして中身は共有のまま)

import copy
a = [[1], [2]]
b = a.copy()          # 外側だけ新規
b[0].append(9)
print(a)              # [[1, 9], [2]] ← 中身共有で汚染
Python

入れ子の中身まで独立させたいなら、必要階層を手動コピーするか deepcopy を使います(コストは重めなので使い分けが大事)。

safe = [row[:] for row in a]            # 一次入れ子まで
deep = copy.deepcopy(a)                 # 完全独立
Python

“不変の中に可変”で油断(タプルの内側がリスト)

外側が不変でも内側が mutable なら変わってしまいます。完全不変が必要なら内側も不変に揃えます。

t = (1, [2, 3])
t[1].append(4)
print(t)  # (1, [2, 3, 4])
Python

辞書キーに可変を使って壊す(ハッシュ不能/値が変わる)

リストや辞書はキーに使えません(ハッシュ不可)。タプルをキーにする場合も、中身まで不変にします。

ok_key = (1, 2)
bad_key = [1, 2]          # TypeError: unhashable type: 'list'
Python

破壊的関数と“純粋関数”の混同(副作用が見えない)

関数が引数の中身を直接変更する(破壊的)か、変更せず新しい値を返す(純粋)かを曖昧にすると事故になります。契約を明記し、破壊的なら関数名で伝えるか、可能なら純粋に設計します。

# 破壊的
def inplace_upper(names: list) -> None:
    for i, n in enumerate(names):
        names[i] = n.upper()

# 純粋
def to_upper(names: list) -> list:
    return [n.upper() for n in names]
Python

実務での防御テクニック(壊れない設計)

防御的コピー戦略(“必要階層だけ”を独立)

  • 引数で受けた構造を加工するなら、最初に編集対象階層だけコピーしてから操作。
  • 巨大構造で deepcopy は重いため、どの階層が汚染し得るかを特定して最小コピー。
def transform(doc: dict) -> dict:
    out = dict(doc)                  # 外側
    out["tags"] = list(doc.get("tags", []))  # 値リストを独立
    out["tags"].append("processed")
    return out
Python

API契約の明文化(副作用の有無、返すもの)

「関数は元データを変更しない/する」「新しい構造を返す」などを docstring で明記。呼び出し側が安心して使えます。

def normalize(rows):
    """
    rowsは変更しない。小文字化した新リストを返す。
    """
    return [r.lower() for r in rows]
Python

データを不変化して安全性を上げる(frozen dataclass / namedtuple)

「変更されては困るレコード」は型で不変を担保すると事故が減ります。

from dataclasses import dataclass

@dataclass(frozen=True)
class User:
    id: int
    name: str

u = User(101, "alice")
# u.name = "bob"  # FrozenInstanceError
Python

“返り値新規作成”パターンで副作用を遮断

編集系は常に新しい構造を返す方針にすると、共有参照の連動バグが激減します。再代入で差し替えるだけ。

cfg = {"retry": 2}
def bumped(c): 
    out = dict(c); out["retry"] += 1; return out
cfg = bumped(cfg)  # 安全
Python

テストで境界条件を締める(空・1件・大量)

副作用と共有は境界で漏れやすいので、空入力・1件・大量の3ケースを必ず通しておきます。破壊的関数なら元データが期待通りに変わるかも検証します。

例題で身につける(定番から一歩先まで)

例題1:参照共有の連動と防御的コピー

data = {"A": [1], "B": [2]}
alias = data
alias["A"].append(99)
print(data["A"])  # 連動

safe = {k: v[:] for k, v in data.items()}
safe["A"].append(100)
print(data["A"])  # 影響なし
Python

例題2:不変レコードで安全なキーと設定

from dataclasses import dataclass

@dataclass(frozen=True)
class Config:
    mode: str
    retry: int

cfg = Config("fast", 2)
index = {("cfg", cfg.retry): "ok"}   # タプルキーで安全
Python

例題3:破壊的と純粋を分けた安全API

def add_tag_inplace(doc: dict, tag: str) -> None:  # 破壊的
    doc.setdefault("tags", []).append(tag)

def with_tag(doc: dict, tag: str) -> dict:         # 純粋
    out = dict(doc)
    out["tags"] = list(doc.get("tags", []))
    out["tags"].append(tag)
    return out

d = {}
add_tag_inplace(d, "new")   # dが変わる
e = with_tag(d, "next")     # dは保たれ、eは新規
Python

例題4:デフォルト引数の罠をテストで発見・修正

def push_bad(x, buf=[]):
    buf.append(x); return buf

assert push_bad(1) == [1]
assert push_bad(2) == [2]   # 失敗([1,2]になる)

def push_good(x, buf=None):
    if buf is None: buf = []
    buf.append(x); return buf
Python

まとめ

不変性は「勝手に変わらない」保証で、副作用と参照共有による連動バグを根本から減らします。逆に、mutable を無自覚に共有すると、別名からの更新が広がり原因特定が難しくなります。重要なのは、どこを不変にするか(キー・設定・共有レコード)、どこを変えるか(作業用バッファ・集計)を設計で明示し、防御的コピー・純粋関数・API契約・不変データ型(frozen dataclass 等)で支えること。デフォルト引数の罠、シャローコピーの誤用、「不変の中の可変」に注意し、境界テストで締める。これだけで、日々の“原因不明の汚染”が目に見えて減り、読みやすく壊れないコードに進化します。

タイトルとURLをコピーしました