🐍 Python
list.copy() と参照共有の罠
Pythonのリストはmutable(変更可能)なオブジェクトのため、「コピーしたつもりが実は同じリストを指していた」という事故が起きやすい対象です。AIが生成したコードのバグの中でも、原因が分かりにくいタイプの一つです。
b = a # 代入:aとbは同じリストを指す(参照の共有)
b = a.copy() # 浅いコピー:新しいリストを作るが、中身がリストならそれ自体は共有される
import copy
b = copy.deepcopy(a) # 深いコピー:ネストした中身まで完全に複製する
代入は「参照」の共有であって、コピーではない
最も基本かつ重要な性質です。alias = originalは新しいリストを作らず、同じリストに2つの名前を付けるだけです。片方を変更すると、もう片方からも変更後の状態が見えます。
original = [1, 2, 3]
alias = original
alias.append(4)
print(original) # [1, 2, 3, 4] ← originalも変わってしまう
print(alias) # [1, 2, 3, 4]
copy()で浅いコピーを作る
copy()を使うと、少なくとも一番外側のリストは独立します。
original2 = [1, 2, 3]
shallow = original2.copy()
shallow.append(4)
print(original2) # [1, 2, 3] ← 影響を受けない
print(shallow) # [1, 2, 3, 4]
浅いコピーでも、ネストしたリストは共有されたまま
ここが最大の落とし穴です。リストの中にリストが入っている(二次元リストのような)場合、copy()は外側の箱だけを複製し、中に入っているリストそのものは共有されたままになります。
original3 = [[1, 2], [3, 4]]
shallow2 = original3.copy()
shallow2[0].append(99)
print(original3) # [[1, 2, 99], [3, 4]] ← 中身のリストへの変更が伝わってしまう
print(shallow2) # [[1, 2, 99], [3, 4]]
deepcopyでネストした中身まで完全に複製する
入れ子になったリストまで完全に独立させたい場合は、標準ライブラリのcopy.deepcopy()を使います。
import copy
original4 = [[1, 2], [3, 4]]
deep = copy.deepcopy(original4)
deep[0].append(99)
print(original4) # [[1, 2], [3, 4]] ← 影響を受けない
print(deep) # [[1, 2, 99], [3, 4]]
関連するはまりポイント:ミュータブルなデフォルト引数
関数のデフォルト引数にリストを直接書くと、その同じリストが呼び出しのたびに使い回されるという有名な落とし穴があります。デフォルト引数は関数定義時に1回だけ評価されるためです。
def add_item(item, lst=[]):
lst.append(item)
return lst
print(add_item("a")) # ['a']
print(add_item("b")) # ['a', 'b'] ← 前回の呼び出しの結果が残っている(空リストを期待していると事故)
コピーの種類と対象範囲
- 代入(
b = a) - コピーではない。同じオブジェクトへの別名が増えるだけ
- 浅いコピー(
a.copy()/list(a)/a[:]) - 一番外側の箱だけ複製。中身がさらにリストなどのmutableな場合は共有されたまま
- 深いコピー(
copy.deepcopy(a)) - ネストした中身まで再帰的に複製し、完全に独立させる
💡 AIコードレビュー観点:ミュータブルなデフォルト引数は定番の指摘ポイント
def func(x, lst=[]):やdef func(x, d={}):のように、デフォルト引数にリストや辞書が直接書かれているコードは要注意です。AIが生成したコードでもこのパターンは時々見かけます。正しくはdef func(x, lst=None):としておき、関数の先頭でif lst is None: lst = []と書き直すのが定石です。