浅拷贝与深拷贝

Python 如何复制对象图、保留共享引用、正确处理循环,并为类和不可变风格记录定义字段替换行为。

难度 进阶 时长 标准深度约 13分钟
版本 Python 3.14
what

赋值只是把另一个名称绑定到对象;copy.copy() 创建新的外层对象,copy.deepcopy() 则递归复制其中的内容。

trap

浅拷贝仍会共享嵌套的可变对象;深拷贝会保留别名与循环,而不是把每条引用都变成独立对象。

fix

先确定必须相互独立的边界,再用 is 测试;如果类需要自己的策略,应使用传入的 memo 实现 __deepcopy__()

是什么,为什么存在

Python 变量保存的是对象引用。backup = settings 这样的赋值会把另一个名称绑定到同一个对象,并不会复制对象。如果任一名称访问并修改了可变对象,另一个名称也会看到变化。

对象的 对象标识(object identity) 可判断两个引用是否指向同一个对象。is 运算符比较标识,== 则询问对象的值是否相等。因此,两个复制得到的列表可以满足 left == right,同时 left is right 为假。

如果代码必须保留源值,同时修改派生值,就需要复制。常见边界包括:从配置模板创建单次请求的设置、在多项测试之间复用测试夹具,或把可变快照传给不得修改存储状态的代码。应该由所需的边界决定使用哪种操作,而不是只看对象是否嵌套。

copy 模块提供 3 种相关操作。copy.copy() 创建浅拷贝,copy.deepcopy() 创建深拷贝,copy.replace() 创建相同类型的对象并替换指定字段。第 3 种操作在 Python 3.13 中加入,也包含在这里验证的 Python 3.14 目标中。

这些操作并不保证每个可达对象都获得新标识。不可变值通常可以安全共享,函数和类会原样返回,一些持有资源的对象则无法复制。有用的复制是一项明确的对象所有权策略,不是内存的逐字节复刻。

工作原理

可以把复合对象看成 对象图(object graph) 。容器和实例是节点,它们对其他对象的引用是边。复制操作决定哪些节点是新的,以及哪些边仍指向现有节点。

赋值不会创建新的图节点。 浅拷贝(shallow copy) 创建一个新的外层节点,并把源对象直接子项的引用放进去。 深拷贝(deep copy) 遍历可达对象,并在复制协议支持时递归构造替代对象。

图中说明了两种效果:改写 shallow 的顶层字段不会重新绑定 source 中的字段,但修改共享的 items 列表会同时影响两者。深拷贝指向另一个列表,因此通过这条边发生的修改彼此隔离。

浅拷贝

copy.copy(value) 会询问值的类型应如何进行浅拷贝。对于内置容器,records.copy()dict(mapping) 或完整切片等直接形式可能更清楚地表达「只复制当前容器」的意图。复制子类时,这些直接形式可能返回基类实例,而 copy.copy() 通常保留源对象的类型。

只要复制操作对该类型有意义,它只保证外层对象是新的。列表元素、字典值以及普通实例的属性仍然是原来的引用。替换副本中的某个槽位不会影响源对象,但修改通过共享槽位访问到的对象仍会影响两者。

常见写法的作用范围如下:

写法结果嵌套引用
alias = source同一个对象相同
source.copy()新的内置容器共享
copy.copy(source)新的浅拷贝结果共享
copy.deepcopy(source)按协议生成的新对象图通过 memo 递归解析
copy.replace(source, field=value)新的受支持记录未变字段遵循替换语义

这张表描述的是所有权,而不是相等性。刚构造完成时,这 5 种结果都可能与源对象比较相等。真正重要的差异要通过对象标识和后续修改来观察。

深拷贝

copy.deepcopy(value) 会递归跟随引用,但不会独立克隆每一条边。在一次遍历中,它维护一个 备忘字典(memo dictionary) ,把源对象标识映射到已复制对象。再次遇到同一个源对象时,它会复用已经创建的副本。

memo 解决了两个结构问题。遇到循环时,新对象可以指回自身,不会无限递归;源对象中两条边共享同一个子对象时,复制后的两条边也可以共享对应的一个子对象。保留这种拓扑关系是正确深拷贝行为的一部分。

深拷贝也可能复制过多内容。类中可能含有本应共享的不可变目录、需要清空的缓存,或应该重新创建而不是复制的锁。因此,类可以定义 __deepcopy__(self, memo),无需接受通用的逐字段策略。

选择操作

先从计划执行的修改出发,画出从根对象到目标对象的路径。凡是修改必须隔离的路径,复制操作都要沿途创建新节点。如果所有权清楚,其他位置保持共享完全可以是正确且有用的选择。

按以下顺序判断:

  1. 确实需要共享标识时,使用赋值。
  2. 只有外层容器或实例会变化时,使用浅拷贝。
  3. 可达的可变对象图必须隔离,而且所有成员都支持合理复制策略时,使用深拷贝。
  4. 需要创建新记录并按名称修改字段,而不是递归复制时,使用 copy.replace()

测试应同时断言值和标识关系。== 用于确认数据符合预期,经过选择的 is 检查则确认哪些可变部分共享、哪些相互独立。完成这些检查后,再修改一个副本,就能通过行为验证所有权边界,而不只是检查结构。

API 设计中的复制边界

在每个入口和出口都复制,并不会自动形成防御式设计。这样做可能掩盖不清楚的所有权,调用者也无法判断 API 返回的是实时视图、浅快照,还是隔离的对象图。应在操作名称或文档中写明这项契约。

不可变返回类型往往比深拷贝更能清楚表达边界。由不可变记录构成的元组,无需在每次读取前递归复制。如果确实需要修改,可以用创建下一个有效领域值的方法,并在同一边界校验变更。

对于可变输入,要明确被调用方是仅在调用期间借用对象、保存引用、取得浅快照,还是拥有隔离副本。这项决定也会影响并发调用者能观察到什么。复制不会让原本不安全的共享服务自动获得线程安全性。

最窄且正确的边界通常最容易测试。如果只会修改某个列表,就只复制该列表。如果字段遵循不同的所有权规则,应选择类级策略或具名构造函数。

示例

下面的示例依次展示普通容器、对象图拓扑、结构化替换和类自定义策略。所有输出均由 Python 3.14 实际生成。

赋值、浅拷贝与深拷贝

这个示例从同一个嵌套字典出发,分别采用 3 种处理方式。修改操作有意同时涉及一个顶层字段和两个嵌套列表,以便清楚显示边界。

copy_levels.py
import copy

source = {"owner": "Ada", "items": [{"sku": "A-1", "qty": 1}]}
alias = source
shallow = copy.copy(source)
deep = copy.deepcopy(source)

alias["owner"] = "Grace"
shallow["items"][0]["qty"] = 2
deep["items"].append({"sku": "B-2", "qty": 1})

print("source:", source)
print("shallow:", shallow)
print("deep:", deep)
print("same outer:", alias is source, shallow is source, deep is source)
print("same items:", shallow["items"] is source["items"], deep["items"] is source["items"])
source: {'owner': 'Grace', 'items': [{'sku': 'A-1', 'qty': 2}]}
shallow: {'owner': 'Ada', 'items': [{'sku': 'A-1', 'qty': 2}]}
deep: {'owner': 'Ada', 'items': [{'sku': 'A-1', 'qty': 1}, {'sku': 'B-2', 'qty': 1}]}
same outer: True False False
same items: True False

修改 alias["owner"] 会改变 source,因为这两个名称指向同一个字典。浅拷贝已经拥有自己的顶层 owner 槽位,因此这次重新绑定不会影响它。

源字典与浅拷贝字典都指向同一个 items 列表。更新列表中的字典时,两个根对象都能看到变化。深拷贝拥有不同的列表和嵌套字典,所以新增元素以及原来的数量值都保持隔离。

共享子对象与循环

深拷贝会保留对象图内部的关系。这里有两个键共享同一个角色列表,self 键则指回根字典。

graph_copy.py
import copy

shared_roles = ["reader"]
account = {"primary": shared_roles, "backup": shared_roles}
account["self"] = account

clone = copy.deepcopy(account)

print("new root:", clone is not account)
print("new roles:", clone["primary"] is not account["primary"])
print("alias preserved:", clone["primary"] is clone["backup"])
print("cycle preserved:", clone["self"] is clone)

clone["primary"].append("editor")
print("source roles:", account["primary"])
print("clone roles:", clone["backup"])
new root: True
new roles: True
alias preserved: True
cycle preserved: True
source roles: ['reader']
clone roles: ['reader', 'editor']

复制得到的角色列表与源列表相互独立,但副本中的 primarybackup 仍共享一个列表。deepcopy() 不会把源对象中的这两条引用变成彼此无关的值。反向引用也会指向 clone,而不是 account

因此,深拷贝测试不能只有修改断言。如果领域模型依赖重复引用和循环,还应验证这些关系。树形测试夹具无法暴露只在对象图中出现的错误。

替换字段而不递归复制

copy.replace() 支持具名元组、数据类,以及实现了 __replace__() 的类。它会创建相同类型的对象并应用具名变更,未变字段则遵循该类型的替换语义。

replace_record.py
from copy import replace
from dataclasses import dataclass


@dataclass(frozen=True)
class Job:
    queue: str
    labels: list[str]


template = Job(queue="normal", labels=["billing"])
urgent = replace(template, queue="urgent")

print(template)
print(urgent)
print("same labels:", urgent.labels is template.labels)

urgent.labels.append("priority")
print("template labels:", template.labels)
Job(queue='normal', labels=['billing'])
Job(queue='urgent', labels=['billing'])
same labels: True
template labels: ['billing', 'priority']

新的 Job 使用了不同的 queue,但 replace() 不会深拷贝 labels。即使数据类已经冻结,其中仍然可以包含可变对象;冻结只会阻止重新绑定字段,不会阻止通过列表引用执行修改。

未变字段可以安全共享或本身就是不可变值时,适合使用替换。如果 labels 必须隔离,应在变更中显式传入复制后的列表:replace(template, queue="urgent", labels=template.labels.copy())

为类定义复制行为

这个 Node 类明确给出了浅拷贝策略,并实现了可安全处理对象图的深拷贝策略。深拷贝钩子会先登记新节点,再递归复制子节点。

custom_copy.py
import copy


class Node:
    def __init__(self, name):
        self.name = name
        self.children = []

    def __copy__(self):
        clone = type(self)(self.name)
        clone.children = self.children
        return clone

    def __deepcopy__(self, memo):
        clone = type(self)(self.name)
        memo[id(self)] = clone
        clone.children = copy.deepcopy(self.children, memo)
        return clone


root = Node("root")
leaf = Node("leaf")
root.children = [leaf, leaf, root]

shallow = copy.copy(root)
deep = copy.deepcopy(root)

print("shallow shares list:", shallow.children is root.children)
print("deep has new list:", deep.children is not root.children)
print("deep keeps alias:", deep.children[0] is deep.children[1])
print("deep keeps cycle:", deep.children[2] is deep)
shallow shares list: True
deep has new list: True
deep keeps alias: True
deep keeps cycle: True

__copy__() 不接收 memo,并且有意复用子节点列表。__deepcopy__() 接收本次遍历的 memo,先登记占位对象,再把同一个字典传给递归调用。memo 应视为 deepcopy() 所拥有的不透明内部记录。

先登记副本非常重要,因为 root 会在自己的子节点中再次出现。递归到达这条边时,memo 已经建立从源根节点到副本的映射。重复出现的 leaf 边也会解析到同一个已复制叶节点。

陷阱

把赋值当成复制

认为浅拷贝能隔离嵌套状态

deepcopy() 当成通用安全包装

破坏 memo 契约

混淆替换与深拷贝

用相等性测试独立性

深入 深拷贝中的对象拓扑

深拷贝中的对象拓扑

源对象图可能是有向图,而不是树。一个子对象可以通过多条路径到达,之后的某条边也可能返回祖先。即使最初打印的值完全相等,独立复制每条边仍会改变这种拓扑。

deepcopy() 会在 memo 中把每个访问过的源对象标识关联到一个目标对象。首次访问时,它创建或开始创建目标对象。之后再次访问时,它直接返回 memo 中的目标对象,不再递归复制源对象。

这个算法解释了 graph_copy.py 中的两个结果:两个角色字段指向同一个已复制列表,复制后的 self 字段则指向复制后的字典。副本既与源对象隔离,又保留了自身内部有意义的共享关系。

memo 只属于一次顶层 deepcopy() 调用。分别调用两次通常会建立两个独立的目标对象图。应用代码不应保留或解释 memo;自定义钩子只用它参与当前遍历。

有些值会被有意复用,而不是重新构造。Python 复制文档明确说明函数和类会原样返回,并排除了若干运行时对象与外部资源类型。类的复制策略应区分持久值状态和进程中的实时资源。

自定义复制协议

copy.copy(obj) 会先遵循注册行为和类型特定行为,其中包括类所定义的 obj.__copy__()。这个钩子不接收额外参数,返回预期的浅拷贝结果。它可以调用构造函数、通过 __new__() 分配,或使用另一个显式工厂,但必须维持类不变量。

如果对象实现了 obj.__deepcopy__(memo)copy.deepcopy(obj) 会调用它。该钩子决定哪些属性递归复制、哪些共享、哪些重置。复制递归组件时必须调用 copy.deepcopy(component, memo),使其加入同一次对象图遍历。

只有当初始化过程没有不需要的外部副作用,而且接受正在重建的状态时,才能安全地从复制钩子调用 __init__()。如果类会在初始化时打开连接、注册回调或分配其他资源,通常需要单独的构造路径。复制操作不应悄悄再次执行外部动作。

Python 还会复用通过 copyreg 注册的归约函数,这套机制也用于支持 pickle。对于已经围绕归约协议设计的类,这种联系很有用,但并不表示复制与序列化可以互换。序列化会跨越表示边界,复制则在当前进程中构造对象。

具名领域方法可能比通用深拷贝更清楚。invoice.revise(lines=...)session.fork_for_request() 会告诉调用方哪些内容保持共享、哪些内容重置。只有标准复制函数无论在何处调用都具有合理语义时,才应实现通用钩子。

复制钩子必须维持与普通构造相同的不变量。如果两个属性必须一致,就应一起重建,而不是分别复制其存储。如果缓存由复制后的状态派生,应清空或重新计算,不能把过期值带入副本。

继承也需要显式测试。硬编码基类构造函数的钩子可能悄悄丢失子类,而使用 type(self) 时,如果子类还要求额外状态,同样可能出错。要明确复制是支持多态、禁止继承,还是委托给受保护工厂。

结构化替换

copy.replace(obj, **changes) 的适用范围有意小于浅拷贝和深拷贝。在 Python 3.14 中,它支持由 namedtuple() 创建的具名元组、数据类,以及实现 __replace__() 的用户自定义类。未知字段会按照受支持类型的行为被拒绝。

替换操作会明确列出发生变化的字段,因此适合不可变风格的更新。它不会递归遍历对象图。未变字段可以继续引用相同对象,就像示例中的可变 labels 字段一样。

自定义的 __replace__(self, /, **changes) 应返回相同类型的新实例。它应该校验字段名称并维持类不变量,不能把任意条目直接写入 __dict__。把 self 设为仅限位置参数后,如果类型确实有名为 self 的字段,该名称仍可出现在 changes 中。

显式字段修改会产生新的记录标识时,应选择替换。整个受支持对象图都需要隔离副本时,应选择深拷贝。如果两种描述都不符合领域,应编写能说明真实状态转换的具名构造函数。

测试复制策略

有效的测试夹具应比生产环境中的顺利路径示例包含更多结构。让两个字段共享同一个可变子对象;如果允许循环,再加入一条反向引用;此外还要包含一个按策略保持共享的属性。只用这一份夹具,就能区分保留拓扑的实现与朴素递归。

复制完成后,检查 4 类关系:

  1. 复制后的根对象具有预期值和类型。
  2. 必须隔离的可变节点具有新标识。
  3. 有意共享或重复的引用保留预期关系。
  4. 修改副本时,只有策略允许的节点发生变化。

还要执行构造函数和自定义钩子的失败路径。尚未构造完成的副本不应逃逸,复制失败前打开的外部资源也必须释放。满足这条规则最简单的办法,是不要在通用复制钩子中获取外部资源。

延伸阅读

检查点

4个问题 · 1 道输出预测题 · 1 道找错题

前置内容 变量与数据类型 Classes objects 即将上线
下一篇 Dataclasses 即将上线 Pickle 即将上线 Gc 即将上线
复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?