概要(不変性は「勝手に変わらない」ことでバグの連鎖を止める安全装置)
不変性(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
PythonAPI契約の明文化(副作用の有無、返すもの)
「関数は元データを変更しない/する」「新しい構造を返す」などを 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 等)で支えること。デフォルト引数の罠、シャローコピーの誤用、「不変の中の可変」に注意し、境界テストで締める。これだけで、日々の“原因不明の汚染”が目に見えて減り、読みやすく壊れないコードに進化します。
