Python 浅拷贝并不隔离嵌套数据:copy、deepcopy 与共享引用

举报
yd_254857454 发表于 2026/11/04 13:57:45 2026/11/04
【摘要】 为什么字典已经 copy(),修改其中的列表仍会影响原数据?本文用内存断言区分赋值、浅拷贝和深拷贝,观察重复引用与循环引用,说明 deepcopy() 的复制边界,以及如何按实际修改范围选择策略。

一、先看对象关系,再看内容

准备一份包含列表和字典的配置,再复制一份用于临时修改,是很常见的写法。问题在于,“复制了一份”可能只指外层容器不同,内部仍然指向同一个可变对象。仅打印两份内容,甚至比较它们是否相等,都不能说明后续修改已经互不影响。

alias = original 只是让另一个名称绑定到同一对象,没有复制。对于普通内置字典,original.copy() 和 copy.copy(original) 会新建外层字典,但其中的值仍是原来的对象;普通列表的 copy()、完整切片和浅拷贝也有类似的嵌套共享关系。

可以把检查分成两个问题:外层是否为同一个对象,内部将要修改的节点是否仍共享。== 比较内容或类型定义的相等关系,is 判断对象身份;即使两份容器内容完全相等,也可能是不同对象。下面用内存中的普通容器验证,不读取文件或连接服务。

二、改写外层键与修改内部对象不同

from copy import copy, deepcopy

original = {"name": "base", "tags": ["ready"], "limits": {"retry": 1}}
alias = original
shallow = copy(original)
deep = deepcopy(original)
assert alias is original
assert shallow is not original
assert shallow["tags"] is original["tags"]
assert deep["tags"] is not original["tags"]
shallow["name"] = "branch"
shallow["tags"].append("shared")
deep["limits"]["retry"] = 9
assert original == {
    "name": "base", "tags": ["ready", "shared"], "limits": {"retry": 1}
}
assert deep == {"name": "base", "tags": ["ready"], "limits": {"retry": 9}}
assert shallow["name"] == "branch"
print("浅拷贝与深拷贝修改边界验证通过")

给浅拷贝的 name 键赋新值,只改变了新字典里的一条映射。调用 shallow["tags"].append() 则先找到共享列表,再修改列表本身,所以原字典也能看到新增项。两种操作都写在浅拷贝变量上,影响范围却不同。

若改为 shallow["tags"] = ["local"],则是把新字典的这个键重新指向另一个列表,不会修改原来的列表。但这不能保证其他路径都已隔离:limits 等尚未替换的可变节点仍可能共享。排查时应沿着真正发生修改的路径检查身份,而不是只检查最外层。

本例在任何修改之前创建深拷贝,因此它的 tags 不受后来浅拷贝分支的追加影响;修改深拷贝自己的 limits 也没有改变原对象。这个结果针对这里的普通字典、列表和基础值,不代表任意类型都遵守相同的复制策略。

三、deepcopy 会保留副本内部的共享关系

“递归复制”容易被误解成每一条访问路径都会得到全新对象。如果原数据的两个键本来就指向同一个字典,同一次常规深拷贝会让副本的两个键仍指向同一份新字典,而不是把这层共享关系自动拆开。

from copy import deepcopy

shared = {"marks": []}
original = {"left": shared, "right": shared}
clone = deepcopy(original)
assert clone is not original
assert clone["left"] is clone["right"]
assert clone["left"] is not shared
clone["left"]["marks"].append("changed")
assert clone["right"]["marks"] == ["changed"]
assert shared["marks"] == []

loop = []
loop.append(loop)
copied_loop = deepcopy(loop)
assert copied_loop is not loop
assert copied_loop[0] is copied_loop
print("深拷贝共享关系与循环引用验证通过")

修改副本的 left,副本中的 right 也能看到变化,因为它们仍是同一个节点;原来的 shared 则没有改变。对本例来说,“隔离原对象”和“拆开副本内部的别名”是两种不同需求。

deepcopy() 在一次复制过程中通过 memo 记录已经处理的对象,后面遇到同一对象时复用相应副本。这个机制也让本例的自引用列表复制后仍指向副本自己,而不是沿着循环无限复制。这里的结论限定为这些普通内置容器;自定义复制协议可能改变行为。

如果业务要求 left 和 right 以后独立修改,就需要明确构造两个独立分支,而不能只增加一次对整个字典的深拷贝。同样,分别调用两次深拷贝与一次调用里处理重复引用,不是相同的复制过程。

四、不可变外壳也可能包着可变数据

元组的槽位不能被重新赋值,但元组可以引用列表。列表里的内容仍能修改,所以不能仅凭外层是元组,就认定整棵数据都不可变。判断是否可以安全共享,需要继续看内部对象以及应用是否会修改它们。

另一方面,深拷贝也不保证所有节点的身份都变化。函数和类对象会原样返回;自定义类还可以通过 __copy__()、__deepcopy__() 等协议决定如何复制。一个自定义 __deepcopy__() 甚至可以返回原对象,测试中可以直接观察到这种共享。

这说明调用名里的“deep”不能替代类型契约。使用第三方类型或自己实现复制协议时,应确认哪些字段复制、哪些保留共享,以及循环和重复引用如何处理。需要递归复制组件的自定义实现,还应把收到的 memo 传给组件的深拷贝调用,保持同一次复制的上下文。

五、按需要修改的范围选择策略

只准备增加或替换普通字典的顶层键,而且约定不修改原有嵌套值时,浅拷贝可能已经足够。需要修改普通嵌套容器时,可以考虑深拷贝,也可以依据明确的数据结构,只新建会被修改的分支。

例如一个字段是由仅含数值字段的记录字典组成的列表,可以新建外层字典、新列表及各记录字典,把约定只读的其他字段继续共享。但如果后来记录增加了嵌套列表,逐条 row.copy() 仍会共享这些新字段。选择性复制的正确性依赖数据结构约定,不能把“复制了一层”长期当作任意嵌套都适用的方案。

深拷贝可能复制并持有更多对象,具体成本取决于数据关系及类型实现,不能在没有测量时宣称固定开销或更高性能。文件、套接字等外部资源也不能按普通容器看待,调用复制函数并不等于获得一个语义独立的新资源。

复制接口本身没有提供并发一致快照的保证。若其他执行路径可能在复制过程中修改源,需要由应用确定同步和所有权;本文的单线程示例没有验证并发行为。复制也不会替应用筛选应该传递的字段,自定义复制逻辑仍可能被调用。

六、用身份断言固定修改边界

有意义的验证除了比较初始值,还应检查关键节点是否共享,再修改其中一份,确认另一份是否按约定保持或跟随变化。共享本身并非错误;应用需要决定哪些对象应该共享,并让接口和测试表达这个决定。

本文两个代码块已在独立 CPython 3.9.6 进程运行。另有十二项标准库内存检查覆盖赋值、普通字典与列表浅拷贝、嵌套深拷贝、重复引用、分次复制、循环、元组内的可变节点、自定义协议、函数与类复用,以及按结构选择性复制。实验没有读取私人数据、复制外部资源或验证并发快照,也没有做性能基准。

参考:Python 官方文档 copy 模块;Built-in Types 的字典 copy() 与对象身份比较;Python Tutorial 的 A Word About Names and Objects、More on Lists;CPython 3.9.6 的 Lib/copy.py。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。