| やりたいこと | 結論 |
| 数値・文字列だけの配列を複製 |
list(x) / [...x] で足りる |
| 入れ子のdict・オブジェクトを複製 |
copy.deepcopy / structuredClone
|
| 速度が要る大量データ | JSON往復が2〜3倍速い(型は落ちる) |
| そもそも壊されたくない | 複製せず、毎回新しく作る |
b = a はコピーじゃない。同じ中身に名前がもう1つ増えるだけ。
.copy() や [...a] は1階層目だけを複製する。入れ子は共有されたまま残る。
入れ子を切り離したいなら Python は copy.deepcopy、JavaScript は structuredClone。ただし実測で 1176倍 / 397倍 遅くなる。
読者前提
- Python か JavaScript を書いていて、リスト・配列・dict・オブジェクトを日常的に触る人
- 「なぜか元データが書き換わっている」バグを一度でも見たことがある人
- 言語仕様の解説より、手元で再現できるコードが欲しい人
Rust の所有権や C のポインタを普段から意識している人には、既知の話しか出てきません。
検証環境
| 項目 | バージョン |
|---|---|
| macOS | 15.7.4 (24G517) |
| Python | 3.14.5 (Clang 17.0.0) |
| Node.js | v26.0.0 |
目次
- コピーしたはずの配列が、元まで書き換わる
-
=が何をしているのか - 浅いコピーはどこまで守ってくれるのか
- 入れ子を切り離す正解
-
[[0]*2]*3とArray(3).fill([])は同じ地雷 - デフォルト設定が汚染される実例
- deepcopy の代償を実測する
- ケース別の使い分けと、よくある質問
- 今日・今週・今月やること
再現:コピーしたはずの配列が、元まで書き換わる
まず一番シンプルな形から。自分の手元で実際に走らせた出力をそのまま貼ります。
# Python 3.14.5
a = [1, 2, 3]
b = a # ここで「コピーした」と思い込む
b.append(999) # b だけを触ったつもり
print("a =", a)
print("b =", b)
print("a is b ->", a is b) # 同一オブジェクトかどうか
a = [1, 2, 3, 999]
b = [1, 2, 3, 999]
a is b -> True
b にしか append していないのに a も伸びています。自分が最初にここで詰まったときは、テストコードのほうを疑って30分溶かしました。JavaScript でも同じです。
// Node.js v26.0.0
const a = [1, 2, 3];
const b = a; // 代入しただけ
b.push(999);
console.log("a =", a, "| a === b ->", a === b);
a = [ 1, 2, 3, 999 ] | a === b -> true
ここで多くの人が「じゃあ const は何を守っているんだ」とハマります。正直、自分もそこで一度止まりました。const が固定しているのは「どの中身を指すか」であって、中身そのものではありません。だから b = [] は TypeError になるのに、b.push() は普通に通ります。
= が何をしているのか
a = [1, 2, 3] は2段階の操作です。
- メモリ上にリストの実体を1つ作る
- その実体に
aという名前をつける
b = a でやっているのは2番目だけ。実体は増えず、名前が1つ増えます。結局 id() を見るのが一番早いです。
a = [1, 2, 3]
b = a
print(id(a) == id(b)) # True → 同じ実体を指している
c = a.copy()
print(id(a) == id(c)) # False → 別の実体
Python公式FAQも「リスト y を変更したらリスト x も変わったのはなぜか」という項目でこの挙動を説明しています(Python公式 Programming FAQ)。バグではありません。
自分がラクになったのは、変数を「箱」と呼ぶのをやめてから。変数は箱じゃなくて名札です。名札を2枚つけても、荷物は1つのまま。
不変な型なら気にしなくていい
同じ代入でも、文字列や数値では事故が起きません。
s = "abc"
s2 = s
s2 += "d" # 新しい文字列を作って s2 に付け替えている
print(s, s2) # abc abcd → s は無傷
文字列は変更できない型なので、+= は書き換えを起こしません。新しい文字列を作って、名札をそっちに移しているだけです。実際に事故るのは、リスト・dict・set・自作クラスのインスタンスといった、後から中身を変えられる型だけです。
浅いコピーはどこまで守ってくれるのか
.copy() や [...a]、list(a) は「浅いコピー」と呼ばれます。1階層目は新しく作り、その中に入っている要素は元と同じものを指し続けます(MDN 浅いコピー)。
平坦な配列なら、正直これで完全に足ります。自分も9割はここで済ませています。
a = [1, 2, 3]
b = a.copy()
b.append(999)
print(a) # [1, 2, 3] → 無傷
入れ子になった瞬間に破綻します。実際にやってみます。
orig = [[0, 0], [0, 0]]
sh = orig.copy() # 外側のリストだけ新品
sh[0][0] = 9 # 内側は共有されたまま
print("orig =", orig)
orig = [[9, 0], [0, 0]]
JavaScript のスプレッド構文も同じ性質です(MDN スプレッド構文)。
const users = [{ name: "sato", tags: ["admin"] }];
const shallow = [...users]; // 配列は新品、中の {} は共有
shallow[0].tags.push("hacked");
console.log(users[0].tags);
[ 'admin', 'hacked' ]
自分の経験だと、この形が一番タチが悪い。テストデータが1階層だけだと通ってしまうので、結局、入れ子のデータが流れ込む本番でだけ壊れます。うまくいかない場所とテストが通る場所がズレるので、原因にたどり着くまでが長い。
入れ子を切り離す正解
Python は標準ライブラリの copy.deepcopy が正解です(copy — 浅いコピーおよび深いコピー操作)。
import copy
orig = [[0, 0], [0, 0]]
dp = copy.deepcopy(orig) # 再帰的に全部作り直す
dp[0][0] = 9
print("orig =", orig) # [[0, 0], [0, 0]] → 無傷
print("dp =", dp) # [[9, 0], [0, 0]]
JavaScript は structuredClone が入って状況が変わりました。ブラウザにもNode.jsにもグローバル関数として組み込まれていて、ライブラリを足さずに深いコピーが作れます(MDN structuredClone / Node.js Globals)。
const users = [{ name: "sato", tags: ["admin"] }];
const deep = structuredClone(users);
deep[0].tags.push("safe-only");
console.log("orig =", users[0].tags); // 元は無傷
console.log("deep =", deep[0].tags);
実体は HTML 仕様の structured clone アルゴリズムで、Date や Map、Set、ArrayBuffer まで型を保ったまま複製されます(WHATWG HTML Standard)。実際に確かめると instanceof がちゃんと生き残ります。
const d = structuredClone({ when: new Date("2026-08-07"), m: new Map([["k", 1]]) });
console.log(d.when instanceof Date, d.m instanceof Map);
true true
JSON.parse(JSON.stringify(x)) を長年使ってきた人は、ここで乗り換えられます。JSON往復だと Date は文字列に、Map は空オブジェクトに潰れるので。自分はこの1行を知ってからユーティリティ関数を1つ消しました。
[[0]*2]*3 と Array(3).fill([]) は同じ地雷
これは「コピー」と言われないのに、原因がまったく同じパターンです。自分は二次元配列を作ろうとして踏みました。
grid = [[0] * 2] * 3 # 内側のリスト1個を3回参照しているだけ
grid[0][0] = 1
print(grid)
print([id(r) for r in grid])
[[1, 0], [1, 0], [1, 0]]
[4364668096, 4364668096, 4364668096]
id が3つとも同じ。行が3本あるように見えて、実体は1本です。JavaScript の Array.fill も同じで、引数として渡した1個のオブジェクトを全マスに配ります。
const bad = Array(3).fill([]);
bad[0].push("x");
console.log(bad); // [ [ 'x' ], [ 'x' ], [ 'x' ] ]
const ok = Array.from({ length: 3 }, () => []); // 呼ばれるたびに新品を返す
ok[0].push("x");
console.log(ok); // [ [ 'x' ], [], [] ]
覚え方は1つで足ります。* や fill は「実体を増やす」のではなく「同じ実体への参照を並べる」。実体を増やしたいなら、要素の数だけ生成式を回すしかありません。Python なら [[0] * 2 for _ in range(3)]、JavaScript なら Array.from({length: n}, () => []) です。
実例:デフォルト設定が汚染される
ここまでは説明用のコードでした。自分が一番現実的だと思うのは、共通のデフォルト設定を毎回コピーして使うパターン。実際に手元で再現させました。
DEFAULT = {"retry": 3, "headers": {"UA": "app/1.0"}}
def build(overrides):
conf = DEFAULT.copy() # 浅いコピー。headers は共有されたまま
conf["headers"].update(overrides)
return conf
print(build({"Auth": "Bearer A"})["headers"])
print(build({})["headers"]) # 何も渡していないのに…
assert "Auth" not in DEFAULT["headers"], f"DEFAULT が汚染された: {DEFAULT['headers']}"
{'UA': 'app/1.0', 'Auth': 'Bearer A'}
{'UA': 'app/1.0', 'Auth': 'Bearer A'}
Traceback (most recent call last):
File "<stdin>", line 10, in <module>
AssertionError: DEFAULT が汚染された: {'UA': 'app/1.0', 'Auth': 'Bearer A'}
2回目の build({}) に、1回目の Auth ヘッダーが残っています。正直、これを初めて見たときは目を疑いました。これがユーザーAのトークンをユーザーBのリクエストに載せる形で本番に出たら、単なるバグでは済みません。汚染2件、修正は1行です。
import copy
DEFAULT = {"retry": 3, "headers": {"UA": "app/1.0"}}
def build(overrides):
conf = copy.deepcopy(DEFAULT) # ここだけ変えた
conf["headers"].update(overrides)
return conf
print(build({"Auth": "Bearer A"})["headers"])
print(build({})["headers"])
print("DEFAULT:", DEFAULT["headers"])
{'UA': 'app/1.0', 'Auth': 'Bearer A'}
{'UA': 'app/1.0'}
DEFAULT: {'UA': 'app/1.0'}
汚染2件が0件になりました。ついでに、モジュール直下に可変なグローバル定数を置くこと自体が引き金になっています。dataclasses を使うなら field(default_factory=dict) で「呼ばれるたびに新品を作る」形にできます(dataclasses 公式ドキュメント)。
tuple なら安全、とは限らない
「不変なら大丈夫」も半分しか正しくない。自分はここで一度失敗しました。
t = ([1, 2], 3)
t[0].append(9) # tuple 自体は変えていない。中のリストを変えている
print(t) # ([1, 2, 9], 3)
固定されているのは「タプルの何番目が何を指すか」だけで、指した先の中身は関係ありません。const の話とまったく同じ構図です。
deepcopy の代償を実測する
深いコピーは万能に見えますが、遅い。1000要素・各要素が入れ子dictという現実的なデータで、実際に200回平均を取りました。
import copy, timeit, json
data = [{"id": i, "tags": ["a", "b"], "meta": {"x": i}} for i in range(1000)]
n = 200
t_sh = timeit.timeit(lambda: copy.copy(data), number=n) / n * 1000
t_dp = timeit.timeit(lambda: copy.deepcopy(data), number=n) / n * 1000
t_js = timeit.timeit(lambda: json.loads(json.dumps(data)), number=n) / n * 1000
print(f"copy.copy : {t_sh:.3f} ms")
print(f"copy.deepcopy : {t_dp:.3f} ms")
print(f"json round-trip: {t_js:.3f} ms")
print(f"deepcopy / copy = {t_dp/t_sh:.0f}x")
copy.copy : 0.001 ms
copy.deepcopy : 1.369 ms
json round-trip: 0.648 ms
deepcopy / copy = 1176x
Node.js でも同じ形で測りました。
spread : 0.001 ms
structuredClone : 0.561 ms
JSON round-trip : 0.203 ms
structuredClone/spread = 397x
| 手段 | Python | Node.js |
|---|---|---|
| 浅いコピー | 0.001 ms | 0.001 ms |
| 深いコピー(標準) | 1.369 ms | 0.561 ms |
| JSON往復 | 0.648 ms | 0.203 ms |
読み取れることが3つあります。
- 浅いコピーは事実上タダ。守れる範囲なら迷わずこっち
- 深いコピーは1000要素で1ミリ秒前後。1リクエストに1回なら誤差、ループの内側で回すと効く
- JSON往復のほうが Python で2.1倍、Node で2.8倍速い。ただし
DateもMapもundefinedも落ちる
留意点
自分が測ってみて、事前の予想と違ったのが3の部分。JSON往復は遅い代替手段だと思い込んでいたけれど、実測では標準の深いコピーより速い。結局、型が落ちても構わない前処理では、まだ選択肢に残ります。
数字の限界も書いておきます。この計測は1000要素・深さ2の均質なデータ1種類だけ。ネストが深いデータや巨大な文字列を含むデータでは比率が変わるので、自分のデータで測り直してください。timeit の実行部分をそのまま持っていけば5分で出ます。
structuredClone の弱点もあります。関数を含むオブジェクトは複製できず、エラーで落ちます。
try {
structuredClone({ fn: () => 1 });
} catch (e) {
console.log(e.constructor.name + ":", e.message);
}
DOMException: () => 1 could not be cloned.
コールバックやクラスのメソッドを持ち回るオブジェクトには使えません。ぶっちゃけ、そこは設計を見直すサインだと自分は受け取っています。
ケース別の使い分けと、よくある質問
Python の場合
| 状況 | 選ぶもの |
|---|---|
| 数値・文字列だけのリスト |
list(x) または x.copy()
|
| 入れ子のdict・自作クラス | copy.deepcopy(x) |
| 速度優先・JSON化できるデータ | json.loads(json.dumps(x)) |
| 関数のデフォルト引数 | 可変オブジェクトを書かず None を初期値に |
JavaScript の場合
| 状況 | 選ぶもの |
|---|---|
| プリミティブだけの配列 |
[...x] / x.slice()
|
| 入れ子のオブジェクト | structuredClone(x) |
| 関数を含む | 複製せず、必要なプロパティだけ組み直す |
| React の state 更新 | 変えた階層だけ新しく作る(全部deepcopyしない) |
Q. Object.assign({}, obj) は深いコピーですか?
違います。浅いコピーです。スプレッド構文とまったく同じ動きをします。
Q. structuredClone はどの環境で使えますか?
主要ブラウザとNode.js 17以降でグローバルに使えます。手元の Node v26.0.0 では import なしで動きました。古いランタイムを相手にするなら、事前に typeof structuredClone === "function" で確認してください。
Q. 全部 deepcopy しておけば安全では?
安全側には倒れますが、実測で1176倍の差があります。もう1つ問題があって、無条件にdeepcopyすると「どこを共有しているか」を考えなくなります。共有していい箇所と切りたい箇所を毎回決めるほうが、結果的にバグが減ると自分は考えています。
Q. 一番いいのは、そもそもコピーしないことでは?
そのとおりです。関数がもらった引数を書き換えず、新しい値を返す形にすれば、この記事の問題は全部消えます。自分もそっちに寄せたい。コピーの話は、既存コードを直すときの話だと思ってください。
今日・今週・今月やること
今日(10分)
手元のプロジェクトで浅いコピーを探します。
# Python: .copy() と dict(...) の呼び出し箇所を列挙
grep -rn "\.copy()\|copy\.copy(" --include="*.py" .
# JavaScript/TypeScript: スプレッドと Object.assign
grep -rn "Object\.assign(\s*{}\|\.\.\." --include="*.ts" --include="*.js" .
ヒットした行の「複製した先の中に、dict や配列が入っているか」だけを見ます。入っていれば候補。自分の手元では、これで7分ぐらいで一巡しました。
今週(30分)
Array(n).fill(...) と [[...]] * n を潰します。
grep -rn "Array([0-9a-zA-Z_]*)\.fill(" --include="*.ts" --include="*.js" .
grep -rnE "\[\[.*\]\] *\* *[0-9a-zA-Z_]+" --include="*.py" .
引数がオブジェクトや配列リテラルなら、Array.from({length: n}, () => ...) と内包表記に置き換えます。プリミティブ(0 や "")なら触らなくて構いません。
今月(1時間)
共有される可変オブジェクトの回帰テストを1本足します。「デフォルト設定を使う関数を2回呼んで、1回目の変更が2回目に漏れないこと」を assert するだけで、この記事の事故は自動で検知できます。上の exp_config_bad.py がそのままテストの雛形として使えます。
あわせて、copy.deepcopy を呼んでいる箇所の実測を取ってみてください。ループの内側にあるなら、結局そこが最初の削りどころです。