快轉到主要內容
  1. 教學文章/

Python copy 實戰:淺拷貝、深拷貝與物件圖陷阱

·6 分鐘· loading · loading · ·
Python Copy Deepcopy Object-Model Standard-Library
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 103: 本文

featured

一. 前言:你改的是副本,為什麼原本的資料也變了?
#

先看一段很普通的 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

originalalias 是兩個名字,但指向同一個 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。 淺拷貝的精確意思是:

  1. 建立新的外層容器。
  2. 把原容器裡的參照放進新容器。
  3. 不遞迴複製那些參照指向的物件。 拍拍君的口訣是:新盒子,舊零件。

六. 深拷貝:遞迴建立獨立物件圖
#

需要連巢狀內容也分開時,可以使用 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)

這裡有兩個關鍵:

  1. 新物件建立後,先放進 memo
  2. 遞迴複製成員時,把同一個 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 最重要的不是「怎麼複製」。 而是逼你回答一個設計問題:哪些資料應該共享,哪些資料必須隔離? 回答清楚,很多神祕的連動修改就會消失。 你的測試資料也比較不會半夜互相污染。拍拍。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 103: 本文

相關文章

Python unicodedata 實戰:文字正規化、搜尋與去重
·6 分鐘· loading · loading
Python Unicodedata Unicode Text-Normalization Search Standard-Library
Python shlex 實戰:安全拆解 Shell 參數、Quote 與迷你指令語法
·6 分鐘· loading · loading
Python Shlex Shell Cli Security Standard-Library
Python sysconfig 實戰:安裝路徑、編譯資訊與環境診斷
·6 分鐘· loading · loading
Python Sysconfig Standard-Library Packaging Virtualenv Developer-Tools
Python socket 實戰:TCP client/server、timeout 與簡易通訊協定
·8 分鐘· loading · loading
Python Socket TCP Networking Standard-Library Developer-Tools
Python shutil 實戰:檔案複製、搬移、壓縮與安全清理
·7 分鐘· loading · loading
Python Shutil Filesystem Automation Standard-Library Developer-Tools
Python inspect 實戰:看懂函式簽名、物件結構與開發工具自動化
·6 分鐘· loading · loading
Python Inspect Introspection Standard-Library Developer-Tools