Python 浅拷贝与嵌套对象:外层换了一份,里面为什么仍会一起变

10-01 3阅读

复制一份任务清单做预览,给预览中的标签加个标记,原始清单却也跟着变化。即使两个列表的身份已经不同,这件事仍可能发生。复制容器与复制容器引用的所有内容是两个动作,只检查外层不是同一个对象,无法证明内部修改已经隔离。

理解这类问题时,可以把变量看成通向对象的入口,把列表元素看成继续通向其他对象的连线。浅拷贝新建外层容器,但把原来的连线放进去;因此增删外层元素和修改内层字典,是沿着不同位置发生的操作,影响范围自然不同。

Python 浅拷贝与嵌套对象:外层换了一份,里面为什么仍会一起变

AI概念配图:用抽象物件说明本文讨论的关系,不是实际运行界面或测量结果。

先区分修改对象与替换引用

下方程序可保存为 demo.py,用 Python 3 运行。original 包含一个带标签列表的字典,复制以后先验证外层不同、内部相同。向 copied 追加一项只改变副本长度,向其中已有标签追加内容,则会通过共享引用影响 original。

随后把 copied 的第一个元素替换成新字典。这个赋值修改的是副本外层保存的引用,不会把原字典改成新内容。新字典里的标签再次变化时,原清单也就不再跟着改变。这个对照比笼统地说“浅拷贝不安全”更能指导具体修复。

from copy import copy, deepcopy

original = [{'name': 'draft', 'tags': ['base']}]
copied = copy(original)
assert copied is not original
assert copied[0] is original[0]
copied.append({'name': 'extra', 'tags': []})
assert len(original) == 1 and len(copied) == 2
copied[0]['tags'].append('preview')
assert original[0]['tags'] == ['base', 'preview']
copied[0] = {'name': 'new', 'tags': []}
copied[0]['tags'].append('independent')
assert original[0]['name'] == 'draft'
assert original[0]['tags'] == ['base', 'preview']
print('outer copy and nested mutation checks passed')

shared = ['base']
graph = {'left': shared, 'right': shared}
clone = deepcopy(graph)
assert clone is not graph
assert clone['left'] is clone['right']
assert clone['left'] is not shared
clone['left'].append('local')
assert clone['right'] == ['base', 'local']
assert shared == ['base']
cycle = []
cycle.append(cycle)
cycle_copy = deepcopy(cycle)
assert cycle_copy is not cycle and cycle_copy[0] is cycle_copy
assert copy([]) == [] and deepcopy({}) == {}
print('deepcopy isolates source and preserves internal aliases')
print('cycle and empty-container checks passed')

深拷贝保留的是对象之间的关系

deepcopy 会递归处理可复制的内容,但不应理解为每出现一次元素就无条件造一个新对象。实验故意让两个字段指向同一个标签列表:深拷贝后,副本与原图分开了,副本中的两个字段仍然指向同一个新列表,原来的共享关系得以保留。

这个行为依赖一次复制过程对已处理对象的记录。它也使包含自身的列表能够被复制为新的循环结构,而不会一路递归下去。代码检查副本的第一个元素仍是副本自己,说明深拷贝建立的是新的对象图,不能把它当成打散所有别名的工具。

如果业务真正要求两个字段今后独立变化,应在业务层分别创建两个列表,而不是期待深拷贝猜出这个意图。原来共享库存视图还是原来误用了同一列表,是程序语义问题;通用复制函数不知道哪一条连线应该保留,哪一条应该拆开。

按修改范围决定复制深度

一个实用做法是先写出允许修改的路径。若只替换外层条目,列表浅拷贝可能足够;若需要编辑某个条目的标签,就显式复制该字典和标签列表,再接回外层。这样既说明了修改边界,也避免为完全不会改动的大量内容支付复制成本。

深拷贝也不是所有对象的通用备份方案。自定义类型可以定义复制行为,文件、网络连接等资源不能简单理解成复制后就得到另一个独立资源。本文只使用普通字典、列表和字符串,结论不扩展为对数据库连接或外部服务状态的保证。

排查时同时使用内容相等与对象身份两个断言:相等说明值看起来相同,is 才能检查是否为同一个对象。只打印清单内容,很容易在第一次修改前误以为已经隔离;只检查外层身份,也会遗漏藏在几层嵌套中的共享入口。

回归样例应覆盖外层追加、内层追加、元素重新绑定、重复引用和循环引用。最后还要检查原输入是否仍符合预期。复制策略的验收目标不是某个函数名称出现在代码里,而是实际允许修改的部分彼此独立,需要共享的关系继续成立。

参考资料

文章版权声明:除非注明,否则均为云鹊BLOG原创文章,转载或复制请以超链接形式并注明出处。