引言
这是第六章「列表、元组与字典」的第 5 篇。前三篇讲完四种容器,你大概已经会写「建列表、塞数据、取数据」的代码了。但有一类 bug 不报错、不崩溃,只在数据悄悄变样时发作:你以为自己在操作副本,其实在操作原件;或者反过来,你想共享,却被复制了一堆没用的数据。这类问题的根源是 Python 的引用语义——第二章第 2 篇讲过「赋值只是把名字绑定到对象」,本篇把这条原则往前推一大步:当容器套容器(列表里装列表、字典里装列表)时,「拷贝」到底拷贝了什么?
读完本篇,你将能精确回答三件事:b = a 与 b = a.copy() 的本质区别;为什么 a.copy() 之后改内层数据,原对象还是会「被连坐」;以及什么时候该用标准库的 copy.deepcopy 做彻底隔离。全文每个层次都用 id()/is 亲眼验证——不是背结论,而是看见结论。
概念与原理
容器里装的是引用,不是对象本身
第二章已经建立的心智模型:变量是名字,数据是堆上的对象,赋值做的是「登记名字」。今天再加一层:容器(列表、字典、集合、元组)的每一个槽位本身也是一个「名字」——它不持有对象本体,只持有指向对象的引用(reference)。xs = [1, 2] 的真实结构是:列表对象在堆上分配一块连续空间,里面两个槽位分别指向 int 对象 1 和 2。这个「槽位里是引用」的事实,是理解一切拷贝问题的地基:
b = a:复制的是名字,两个名字指向同一个列表对象;b = a.copy():新建一个列表对象,把 a 的每一个槽位原样复制进去——复制的是「引用」本身。于是外层独立了(改 b 的长度不影响 a),但槽位指向的对象还是同一个(改「被指向的内层列表」会两边都变)。
一句话概括浅拷贝的边界:复制了一层壳,壳里的内容还是共享的。这是 Python 所有容器拷贝方法(list.copy()、dict.copy()、切片 [:]、list() 构造、copy.copy)的共同行为,不是某个方法的特例。
浅拷贝与深拷贝:递归深度的区别
- 浅拷贝(shallow copy):只复制最外层容器,槽位里的引用原样复制。结果:外层独立、所有内层对象共享。
- 深拷贝(deep copy):从外层开始递归,每一层容器都新建,直到遇到不可再拆的「原子对象」(int、str 等)才停止。结果:整棵对象树完全独立,两边互不相干。
深拷贝不是免费的:递归复制意味着要遍历整棵结构的所有节点,节点越多越慢(性能实测见操作部分)。浅拷贝只复制一层引用数组,成本近乎固定。所以「闭眼一律 deepcopy」不是好习惯,正确姿势是先想清楚你要隔离哪一层。
copy.copy 的一个不变量:不可变对象「拷了等于没拷」
copy.copy 对不可变对象(int、str、tuple、frozenset)有一个特殊行为:直接返回原对象本身,不新建。理由是显然的——不可变对象永远不会被原地修改,「复制一份」没有任何存在意义,返回自身还能省内存和耗时。于是「嵌套容器含元组」时有个微妙结论:外层元组本身不可变、安全共享,但元组里面的可变元素(比如元组里装列表)浅拷贝后依旧共享——「不可变」只保证元组的槽位不会被改写,不保证槽位指向的东西不会变。这一点实践中反复踩坑,见易错点。
copy 模块的实现背景
标准库 copy 模块提供 copy.copy(x)(浅)与 copy.deepcopy(x)(深)两个入口。深拷贝的内部实现基于 pickle 协议(序列化协议):先把对象图「拆平」成可复制的数据流,再重建。这个实现细节带来两条可观测的行为:其一,深拷贝能正确处理自引用(列表装自己)与共享引用(两个槽位指向同一对象,副本里也保持「指向同一对象」)——这是手写递归复制最容易搞错的两点;其二,凡是不能序列化的对象(文件对象、网络连接、生成器、锁),deepcopy 会直接抛 TypeError: cannot pickle ...。所以 deepcopy 不是万能隔离器,它对「外部资源」无能为力。
操作与实现
绑定与拷贝的第一课
先回到最朴素的一步:b = a 什么都不复制,之后所有「通过 b 做的修改」都会通过 a 看见:
a = [1, 2, 3]
b = a
print(a is b) # True:同一个对象,两个名字
b.append(4)
print(a) # [1, 2, 3, 4]:a 也跟着变
c = a.copy() # 复制一份独立副本
c.append(5)
print(a, c)
print(a is c) # False
输出:
True
[1, 2, 3, 4]
[1, 2, 3, 4] [1, 2, 3, 4, 5]
False
a is b 为 True 说明两个名字指向同一对象;a.copy() 之后 a is c 为 False——独立副本诞生。注意副本诞生后(c 追加了 5),a 里并没有 5:外层操作互不影响。
引用语义在真实代码里的第一处爆发:函数参数
第四章还没正式讲函数,但列表的共享已经会让它提前「中招」。函数参数传进来的本来就是对列表的引用,函数内部 append 修改的是调用方那个列表:
def add_task(tasks, name):
tasks.append(name)
todo = ["write", "review"]
add_task(todo, "publish")
print(todo) # 函数内部的 append 改到了调用方的列表
输出:
['write', 'review', 'publish']
乍看像是「Python 函数传引用」的怪癖,其实没有例外:tasks 只是指向 todo 的又一个名字。想让函数不污染外部,要么函数内部 tasks = tasks + [name](创建新列表并重绑),要么调用方自己传副本 add_task(todo.copy(), "publish")。
切片、list()、.copy():常见的浅拷贝三件套
三个最常见的「拷贝」写法——切片 [:]、list() 构造、.copy() 方法——全部是浅拷贝:
matrix = [[1, 2], [3, 4]]
s1 = matrix[:] # 切片
s2 = list(matrix) # list() 构造
s3 = matrix.copy() # 方法
print(s1 is matrix, s2 is matrix, s3 is matrix)
print(s1[0] is matrix[0]) # 内层还是同一个列表
s1[0].append(99)
print(matrix) # 共享的内层被改动
输出:
False False False
True
[[1, 2, 99], [3, 4]]
三个外层容器都是新对象(is 全 False),但 s1[0] is matrix[0] 为 True——内层列表并没有被复制。于是 s1[0].append(99) 这一刀,同时切到了 matrix 身上。对二维及以上的结构,「浅拷贝三件套」都不够隔离,这是本篇最重要的实操结论。
copy.copy:浅拷贝的标准入口,id() 逐层验证
标准库的 copy.copy 与上面的三件套行为一致,但语义更明确(任何容器通用,且对不可变对象返回自身)。用 id() 验证「一层独立、深层共享」:
import copy
data = {"name": "林晚", "scores": [88, 92]}
shallow = copy.copy(data)
print(id(data) == id(shallow)) # False:字典本身是新对象
print(id(data["scores"]) == id(shallow["scores"])) # True:值仍是同一个列表
print(data["scores"] is shallow["scores"]) # True:同一列表对象
shallow["scores"].append(95) # 修改共享的内层列表
print(data["scores"]) # [88, 92, 95]:原字典也变了
shallow["city"] = "北京" # 修改外层:只影响副本
print(data, shallow)
输出:
False
True
True
[88, 92, 95]
{'name': '林晚', 'scores': [88, 92, 95]} {'name': '林晚', 'scores': [88, 92, 95], 'city': '北京'}
读这段输出要盯住两个层次:外层(字典的 id 不同,往 shallow 里加 city 不影响 data)与内层(scores 列表的 id 相同,append 95 两边都看得到)。浅拷贝的承诺到此为止:我只保证「第一层独立」。
再换嵌套列表演示同样的两层结构,内外改动各看一遍影响:
import copy
matrix = [[1, 2], [3, 4]]
shallow = copy.copy(matrix)
print(shallow is matrix) # False:外层是新列表
print(shallow[0] is matrix[0]) # True:内层仍是同一个列表
shallow.append([5, 6]) # 改外层:互不影响
print(matrix, shallow)
shallow[0].append(99) # 改内层:两边都变
print(matrix, shallow)
输出:
False
True
[[1, 2], [3, 4]] [[1, 2], [3, 4], [5, 6]]
[[1, 2, 99], [3, 4]] [[1, 2, 99], [3, 4], [5, 6]]
第一次 print 时 matrix 安然无恙(外层改动隔离成功),第二次 print 时 matrix 的第一行也长出 99(内层改动传染)。浅拷贝隔离的是「增删元素」这一层,隔离不了「元素被就地修改」。
copy.deepcopy:彻底隔离
深拷贝递归重建每一层,任何层次的修改都不再传染:
import copy
matrix = [[1, 2], [3, 4]]
deep = copy.deepcopy(matrix)
print(deep is matrix) # False:外层是新列表
print(deep[0] is matrix[0]) # False:内层也重建了
deep[0].append(99)
print(matrix, deep) # 两边从此互不相干
data = {"name": "林晚", "scores": [88, 92]}
deep_d = copy.deepcopy(data)
print(id(data["scores"]) == id(deep_d["scores"])) # False:值列表也是新的
输出:
False
False
[[1, 2], [3, 4]] [[1, 2, 99], [3, 4]]
False
与浅拷贝对比:deep[0] is matrix[0] 从 True 变成 False——内层列表被完整复制。此后 deep[0].append(99) 只发生在副本世界,matrix 保持 [[1, 2], [3, 4]] 原样。id() 验证同样通过:字典的「值」列表也拿到新 id。深拷贝后的两棵树,除了初值相同,再无任何连接。
自引用与共享引用:deepcopy 的两个独门绝技
手写递归复制会遇到两个陷阱:自引用会让朴素递归死循环;两个槽位指向同一对象时,朴素递归会复制出「两个独立副本」而丢失共享关系。deepcopy 用内部一张「已拷贝对象表」记住每个原对象对应的副本,同时解决两者:
import copy
xs = [1]
xs.append(xs) # 列表引用了自身
print(xs) # [1, [...]]:repr 用 ... 表示循环引用
ys = copy.deepcopy(xs)
print(ys)
print(ys[1] is ys) # 副本中的自引用被正确重建
zs = copy.copy(xs) # 浅拷贝对照:内层仍是原列表
print(zs)
print(zs[1] is xs) # True:自引用仍指向原列表
输出:
[1, [...]]
[1, [...]]
True
[1, [1, [...]]]
True
xs 的尾部元素指向自身,repr 打印为 [...];deepcopy 后 ys[1] is ys 为 True,自引用在副本里重建成功。浅拷贝的 zs 则显示原形:zs[1] is xs 为 True,浅层副本的「内部循环」仍绕回原列表——打印时一路递归到 xs 本身才用 […] 收住。
不可变对象:拷了等于没拷
copy.copy 对不可变对象直接返回自身;这在外层是元组、内层含可变元素时,产生一个易混的组合行为:
import copy
t = (1, 2, 3)
t2 = copy.copy(t)
print(t2 is t) # True:不可变对象拷贝返回自身
s = "hello"
print(copy.copy(s) is s) # True:字符串同理
pair = ([1, 2], [3, 4]) # 元组里装列表
shallow_pair = copy.copy(pair)
print(shallow_pair is pair) # True:外层元组不复制
print(shallow_pair[0] is pair[0]) # True:内层列表共享
deep_pair = copy.deepcopy(pair)
print(deep_pair is pair) # False:深拷贝重建整棵树
print(deep_pair[0] is pair[0]) # False:内层列表也独立
输出:
True
True
True
True
False
False
规矩很整齐:凡是「不可能再变」的层,浅拷贝不动它;凡是「可能被改」的层,浅拷贝拱手共享,深拷贝另起炉灶。pair 外层元组的两个槽位永远指向固定的两个列表(元组不能替换槽位),但列表本身可以被 append——所以浅拷贝对 pair 近乎无效,改 pair[0] 的内容照样穿透。需要「快照」的时候,元组外壳救不了你,得 deepcopy。
深拷贝的性能代价:实测
深拷贝要遍历整棵对象树,成本随节点数线性增长,且常数远大于浅拷贝。本机对「10 万个 [i, i+1] 小列表」构成的大列表实测:
import copy
import timeit
data = [[i, i + 1] for i in range(100_000)]
t_shallow = timeit.timeit(lambda: copy.copy(data), number=100)
t_deep = timeit.timeit(lambda: copy.deepcopy(data), number=10)
print(f"浅拷贝 100 次共 {t_shallow:.3f} 秒(单次约 {t_shallow / 100 * 1e3:.2f} 毫秒)")
print(f"深拷贝 10 次共 {t_deep:.3f} 秒(单次约 {t_deep / 10 * 1e3:.1f} 毫秒)")
print(f"深拷贝/浅拷贝 单次耗时比约 {t_deep / 10 / (t_shallow / 100):.0f} 倍")
输出(本机 CPython 3.12.10):
浅拷贝 100 次共 0.054 秒(单次约 0.54 毫秒)
深拷贝 10 次共 1.475 秒(单次约 147.5 毫秒)
深拷贝/浅拷贝 单次耗时比约 273 倍
数值随机器波动,量级稳定:同样的 10 万个小列表,浅拷贝亚毫秒完成,深拷贝约 150 毫秒,差了 2 个数量级。数据规模再翻十倍,深拷贝直接到秒级。结论不是「别用 deepcopy」,而是「只在需要隔离时用」——每份 deepcopy 都是在为「未来某次可能的修改」预先买单。
易错点与陷阱
陷阱一:可变默认参数——共享的「默认空列表」
这是 Python 面试中的万年经典:默认参数在定义函数时求值一次,之后每次调用都复用同一个列表对象:
def add_item(item, bucket=[]):
bucket.append(item)
return bucket
print(add_item("apple"))
print(add_item("banana")) # 同一个默认列表被反复修改
输出:
['apple']
['apple', 'banana']
第二次调用时桶里已经躺着 apple——默认参数成了跨调用的共享状态(mutable default argument)。标准解法:默认值写成 None,函数体内新建:
def add_item(item, bucket=None):
if bucket is None:
bucket = []
bucket.append(item)
return bucket
原理正是本篇主题:bucket=[] 建立的是「默认名字 → 同一个列表」的绑定,每次调用只复制了名字。任何可变默认参数都有这个病,看见 def f(x, lst=[]) 直接改写。
陷阱二:以为 [:]、.copy()、dict.copy() 是彻底拷贝
一维数据看不出来,嵌套结构立刻翻车:config = default.copy() 之后 config["db"]["port"] = 5433,回头 default["db"]["port"] 也变了——因为两层字典的值都是共享的嵌套字典。判断标准只有一条:结构有两层以上且内层可变,就必须 deepcopy;只改外层,shallow 够用。另外,copy.deepcopy 对「文件对象、生成器、锁、网络连接」这类外部资源会抛 TypeError: cannot pickle 'TextIOWrapper' instances 之类错误——它复制不了「活着的资源」,携带这些对象的容器深拷贝前要先评估。
陷阱三:以为「元组不可变就安全」,于是跳过拷贝
「元组不可变」只保证元组的槽位不能换,不保证槽位里的列表不被改。pair = ([1, 2], [3, 4]) 传给下游函数,下游 pair[0].append(99) 照样污染上游。判断「要不要拷贝」看的不是外层类型,而是整棵结构里有没有会被改动的可变节点——有,就要深拷贝或文档化「只读契约」。
陷阱四:性能敏感的循环里盲目 deepcopy
在热路径(比如每请求一次)深拷贝大配置,等于把吞吐打穿。实操里的三层决策:纯读 → 不拷贝,直接共享;只改顶层 → copy.copy(或切片/.copy(),同级别);要改深层 → deepcopy,且尽量把深拷贝放在初始化阶段做一次,后续传引用而不是每步重拷。
小结
容器槽位里存的是引用,所以「拷贝」的深与浅决定着数据隔离的边界:b = a 只复制名字;[:]、list()、.copy()、dict.copy()、copy.copy 全部是浅拷贝——一层壳独立、壳内共享;copy.deepcopy 递归重建整棵对象树,同时正确处理自引用与共享引用,但对文件、生成器等资源无能为力且成本高两个数量级(实测约 0.5 毫秒 vs 约 148 毫秒)。判断「要不要拷贝、拷多深」,问三个问题:会不会改?改哪一层?改的频率多高?答案分别是共享、浅、深。带着这套引用模型,下一篇嵌套容器的遍历与安全访问就顺理成章了。
练习与思考题
- 写一段代码验证:
a = [[1]]; b = a * 3之后b[0][0] = 99会发生什么,并解释为什么(提示:列表乘法复制的是引用数组)。这是列表乘法最经典的坑。 - 不使用 deepcopy,手写一个
my_deepcopy处理「列表/字典/元组/基础类型」四类对象,再用它复制练习 1 的结构验证隔离效果。 x = {"a": [1, 2]}; y = x.copy(); y["a"].append(3); print(x)的输出是什么?解释每一层发生了什么。- 思考题:为什么
copy.copy对不可变对象「返回自身」是安全且合理的?如果它强行复制一份,会带来什么浪费或错误? - 挑战题:实现一个「快照」函数
snapshot(config),要求:只对「会被改的层」做深拷贝、其余层共享——先想想这个需求本身成立吗?在什么条件下它才能节省成本?