一. 前言:你改的是副本,為什麼原本的資料也變了? #
先看一段很普通的 Python:
defaults = {
"theme": "light",
"plugins": ["search", "export"],
}
user_config = defaults
user_config["plugins"].append("拍拍君")
print(defaults)
結果是:
{'theme': 'light', 'plugins': ['search', 'export', '拍拍君']}
咦,我們明明改的是 user_config,怎麼 defaults 也一起變了?
因為 = 沒有複製物件。
它只讓兩個名字指向同一個物件。
這種共享有時很方便,但如果你以為自己拿到獨立副本,bug 就會安靜地長大。
尤其是設定模板、巢狀 JSON、測試資料與快取物件,很容易踩到。
今天拍拍君要用標準庫 copy,把這件事拆清楚:
- 賦值為什麼不是複製。
copy.copy()的淺拷貝到底複製到哪一層。copy.deepcopy()如何走訪整張物件圖。- 循環引用為什麼不會讓
deepcopy()無限遞迴。 - 自訂類別如何控制複製行為。
- 什麼時候根本不該深拷貝。 重點不是背兩個函式。 重點是看懂「容器」和「容器裡的物件」是否仍然共享。
二. 安裝:不用裝,標準庫已經準備好了 #
copy 是 Python 標準庫,不需要 pip install。
建立一個練習專案:
uv init copy-lab
cd copy-lab
確認環境:
uv run python -c "import copy; print(copy.__name__)"
不用 uv 也沒關係:
python -c "import copy; print(copy.__name__)"
本文所有範例只使用標準庫。
三. 第一個觀念:變數保存的是參照 #
先用 id() 看物件身分:
original = ["red", "green"]
alias = original
print(id(original))
print(id(alias))
print(original is alias)
最後一行會得到:
True
original 和 alias 是兩個名字,但指向同一個 list。
因此這個修改:
alias.append("blue")
會同時出現在兩個名字看到的內容裡。 可以把它想成兩張便利貼貼在同一個收納盒上。 多貼一張標籤,不會憑空多出另一個盒子。 如果真的需要另一個盒子,就要明確複製。
四. 淺拷貝:新容器,舊內容 #
最基本的淺拷貝是 copy.copy():
from copy import copy
original = ["red", "green"]
cloned = copy(original)
print(original is cloned)
print(original == cloned)
結果:
False
True
兩個 list 內容相同,但不是同一個 list。 修改外層結構時,彼此不受影響:
cloned.append("blue")
print(original)
print(cloned)
結果:
['red', 'green']
['red', 'green', 'blue']
到這裡看起來很完美。 但別急,巢狀物件才是考題。
五. 巢狀容器:淺拷貝只換最外面的盒子 #
把資料改成巢狀 list:
from copy import copy
original = {
"name": "拍拍醬",
"skills": ["Python", "Git"],
}
cloned = copy(original)
print(original is cloned)
print(original["skills"] is cloned["skills"])
結果是:
False
True
外層 dict 是新的,內層 skills list 仍然是同一個。
所以:
cloned["skills"].append("Rust")
print(original["skills"])
print(cloned["skills"])
兩邊都會看到 Rust。
淺拷貝的精確意思是:
- 建立新的外層容器。
- 把原容器裡的參照放進新容器。
- 不遞迴複製那些參照指向的物件。 拍拍君的口訣是:新盒子,舊零件。
六. 深拷貝:遞迴建立獨立物件圖 #
需要連巢狀內容也分開時,可以使用 copy.deepcopy():
from copy import deepcopy
original = {
"name": "拍拍醬",
"skills": ["Python", "Git"],
}
cloned = deepcopy(original)
print(original is cloned)
print(original["skills"] is cloned["skills"])
這次兩個判斷都是 False。
修改深層內容:
cloned["skills"].append("Rust")
print(original["skills"])
print(cloned["skills"])
結果:
['Python', 'Git']
['Python', 'Git', 'Rust']
deepcopy() 會遞迴處理可複製的成員。
但它不是把所有東西粗暴地複製一遍。
它會保留物件圖裡重要的共享關係。
七. 同一個子物件出現兩次,深拷貝後會怎樣? #
看這個例子:
from copy import deepcopy
shared = {"status": "ready"}
original = [shared, shared]
cloned = deepcopy(original)
print(original[0] is original[1])
print(cloned[0] is cloned[1])
print(original[0] is cloned[0])
結果:
True
True
False
原本兩個位置指向同一個 dict。
深拷貝後,兩個位置仍然指向同一個「新 dict」。
這很重要。
如果 deepcopy() 每次遇到參照都建立不同副本,原本的共享語意就會被破壞。
它不是在複製一棵單純的樹。
它在複製可能有共享節點的物件圖。
八. memo:深拷貝不迷路的地圖
#
deepcopy() 內部會維護一個 memo 字典。
概念上,它記錄:
原物件 id -> 已建立的新物件
當同一個原物件再次出現時,deepcopy() 會重用已建立的副本。
這能解決兩個問題:
- 保留共享參照。
- 避免循環引用造成無限遞迴。 來做一個會指向自己的 list:
from copy import deepcopy
loop = []
loop.append(loop)
cloned = deepcopy(loop)
print(cloned is loop)
print(cloned[0] is cloned)
結果:
False
True
新 list 不是原 list,但它也正確地指向自己。
這就是 memo 的價值。
沒有它,遞迴會一路跑到 Python 翻桌。
九. 自訂深拷貝:別忘了傳遞 memo
#
__deepcopy__() 會收到 memo。
假設 cache 可以重建,我們不想把大量快取一起複製:
from __future__ import annotations
from copy import deepcopy
class Workspace:
def __init__(
self,
name: str,
tasks: list[dict[str, str]],
cache: dict[str, str],
) -> None:
self.name = name
self.tasks = tasks
self.cache = cache
def __deepcopy__(self, memo: dict[int, object]) -> Workspace:
existing = memo.get(id(self))
if existing is not None:
return existing # type: ignore[return-value]
cloned = type(self)(
name=self.name,
tasks=[],
cache={},
)
memo[id(self)] = cloned
cloned.tasks = deepcopy(self.tasks, memo)
return cloned
測試:
original = Workspace(
name="拍拍君",
tasks=[{"title": "寫測試"}],
cache={"expensive": "result"},
)
cloned = deepcopy(original)
print(cloned.tasks == original.tasks)
print(cloned.tasks is original.tasks)
print(cloned.cache)
這裡有兩個關鍵:
- 新物件建立後,先放進
memo。 - 遞迴複製成員時,把同一個
memo傳下去。 如果忽略這兩點,循環引用與共享節點可能被處理錯誤。
十. Python 3.13+:用 copy.replace() 做局部更新
#
如果你用 Python 3.13 以上,標準庫還有 copy.replace()。
它適合 named tuple、dataclass,以及實作 __replace__() 的類別。
from copy import replace
from dataclasses import dataclass
@dataclass(frozen=True)
class Job:
title: str
priority: int
labels: tuple[str, ...]
original = Job(
title="整理文件",
priority=3,
labels=("docs",),
)
urgent = replace(original, priority=1)
print(original)
print(urgent)
這不是一般用途的深拷貝。
它的語意是「保留其他欄位,只替換指定欄位」。
對不可變資料模型來說,這通常比先深拷貝、再修改更清楚。
如果你使用較舊的 Python,可以直接用 dataclasses.replace() 完成 dataclass 的同類工作。
想複習資料模型,可以看 Python dataclasses。
十一. 效能:深拷貝不是免費午餐 #
deepcopy() 必須走訪物件圖,建立必要的新容器與物件。
資料很大時,成本可能包含:
- 更多 CPU 時間。
- 額外記憶體。
- 複製其實不需要修改的內容。
- 觸發自訂物件的複雜複製邏輯。 先問自己:真的要整份複製嗎? 很多情況只要複製修改路徑:
original = {
"name": "拍拍君",
"preferences": {
"theme": "light",
"font_size": 16,
},
"history": tuple(range(10_000)),
}
updated = {
**original,
"preferences": {
**original["preferences"],
"theme": "dark",
},
}
這種 selective copy 只複製會改動的外層 dict 與 preferences。
不可變的 history tuple 可以安全共享。
資料越大,越值得把「可變部分」縮小。
十二. 常見錯誤整理 #
錯誤一:以為 = 會複製
#
alias = original 只新增參照。
錯誤二:以為 .copy() 會處理所有巢狀內容
#
內建容器的 .copy() 通常只是淺拷貝。
錯誤三:遇到共享就無腦 deepcopy()
#
先確認是否只需要複製一條修改路徑。
錯誤四:在 __deepcopy__() 裡忘記 memo
#
這會破壞循環引用處理與共享關係。
錯誤五:試圖複製外部資源 #
檔案、連線與 lock 應該用明確的建立與關閉流程管理。
十三. 快速決策表 #
| 需求 | 建議做法 |
|---|---|
| 只是多一個名稱 | 直接賦值 |
| 複製平坦 list / dict | .copy() 或 copy.copy() |
| 巢狀可變資料要完全隔離 | copy.deepcopy() |
| 不可變資料只改幾個欄位 | copy.replace() 或專用 replace |
| 大型物件只改局部 | selective copy |
| 類別有特殊共享政策 | __copy__() / __deepcopy__() |
| 物件持有檔案、連線、lock | 重新建立資源,不要深拷貝 |
| 如果你不確定,先畫出一小段物件圖。 | |
| 標出哪個節點可以共享、哪個節點必須獨立,答案通常就出來了。 |
十四. 結語 #
今天我們把 Python 複製行為拆成幾個層次:
- 賦值只會讓另一個名字指向同一物件。
- 淺拷貝建立新外層容器,但內層物件仍可能共享。
- 深拷貝遞迴建立新物件圖,並用
memo保留共享與循環關係。 - 自訂類別可以用 copy hook 明確定義複製政策。
- 不可變模型與 selective copy,常比整份深拷貝更清楚也更省資源。
拍拍君覺得,
copy最重要的不是「怎麼複製」。 而是逼你回答一個設計問題:哪些資料應該共享,哪些資料必須隔離? 回答清楚,很多神祕的連動修改就會消失。 你的測試資料也比較不會半夜互相污染。拍拍。