数据"备份"引发的血案
先从我上周差点被开除的事儿说起。
我们有个系统,每天半夜要处理一份大客户名单。名单是个列表,每个客户又是一个字典,装着各种信息。领导要求加个功能:在处理之前,先备份一下原始数据,万一处理出错还能恢复。
我一想,这还不简单?Python 里复制列表嘛,list.copy() 或者直接用切片 [:] 不就完事了?
于是我潇洒地写下了这行代码:
ini
original_data = [
{"name": "张三", "balance": 1000},
{"name": "李四", "balance": 2000},
{"name": "王五", "balance": 500}
]
# 优雅地备份一下,多么经典的切片操作
backup_data = original_data[:]
然后开始处理。处理逻辑很简单:把所有余额大于 1000 的客户,余额打九折。我一边处理 original_data,一边美滋滋地想------就算改出 bug 也不怕,反正有 backup_data 兜底。
结果第二天一来,发现数据全乱了。不止 original_data 乱了,连 backup_data 也跟着乱了。
我当场就懵了。明明备份了,怎么两个一起乱?
我检查了代码,确认自己没有写类似 backup_data = original_data 这样的引用赋值。我用的可是切片 [:] 啊,都说切片是复制,怎么复制了个寂寞?
后来我才知道,切片 [:] 确实复制了,但它只复制了最外面那一层。至于里面的字典、列表这些可变对象,它压根儿没动,只是把引用抄了一份。
这就好比你要复印一份文件,结果只复印了封面目录,里面的每一页还是指着原来那份。最后目录是新的,但里面的内容一改,大家都跟着变。
这件事之后,我终于把"浅拷贝"和"深拷贝"这六个字刻进了脑子里。
引用 vs 拷贝:别再傻傻分不清
要搞懂切片为什么"背刺"你,得先明白 Python 里"赋值"和"拷贝"到底在干嘛。
先看最基础的:
css
a = [1, 2, 3]
b = a
b[0] = 999
print(a) # 输出 [999, 2, 3]
这个大家应该都懂。b = a 不是复制,是贴标签。a 和 b 指向同一个盒子,你改盒子里的东西,另一个当然看得见。
那切片呢?
css
a = [1, 2, 3]
b = a[:]
b[0] = 999
print(a) # 输出 [1, 2, 3],a 没变
这回对了。因为切片会新建一个列表,然后把原列表里的元素塞进去。所以 a 和 b 是两个不同的盒子。
问题在于------如果盒子里装的不是数字、字符串这种"不可变"的东西,而是列表、字典这种"可变"的东西呢?
css
a = [[1, 2], [3, 4]]
b = a[:]
b[0][0] = 999
print(a) # 输出 [[999, 2], [3, 4]]------a 被改了!
看到了吗?切片只复制了外层的列表,但外层列表里的两个子列表,还是原来那两个 。b[0] 和 a[0] 指向的是同一个子列表。所以你改 b[0] 里面的东西,a[0] 当然也跟着变。
这就是传说中的 "浅拷贝" ------只拷贝最外层,里面的东西还是共用。打个比方,浅拷贝就像是复印了公司的通讯录,通讯录的封面是新复印的,但里面的每一个电话号码还是原来那张纸上的,没人帮你重新抄一遍。
切片是浅拷贝,但不只是切片
踩坑之后我才发现,Python 里"浅拷贝"到处都是。
- 列表的
copy()方法 :a.copy(),和a[:]一毛一样 - 字典的
copy()方法 :d.copy(),只复制键值对的引用 list()构造函数 :list(a),也是浅拷贝- 解包操作 :
b = [*a],还是浅拷贝 - 集合推导式 :
{x for x in a},依然浅拷贝
基本上,所有你不假思索拿来"复制"东西的操作,十有八九都是浅拷贝。
这些操作在日常写代码的时候,你根本感觉不到问题------除非你往里装的是可变对象。一旦装了列表、字典、自定义对象,浅拷贝就变成了定时炸弹,你不知道什么时候一改就炸。
救命稻草:copy.deepcopy
那到底怎么才能"完整地、彻底地"复制一份数据,让新数据和旧数据没有半毛钱关系?
答案是 copy.deepcopy,Python 标准库 copy 模块里的深拷贝函数。
css
import copy
a = [[1, 2], [3, 4]]
b = copy.deepcopy(a)
b[0][0] = 999
print(a) # 输出 [[1, 2], [3, 4]]------a 纹丝不动!
这就是深拷贝的威力。它不只是复制最外层,而是递归地把里面的每一层都复制一份。不管你的数据结构有多复杂------列表套字典、字典套列表、再套个自定义对象------它都能给你复制出一份完全独立的新数据。
还是用通讯录打比方:浅拷贝是复印封面,深拷贝是把通讯录里每一页的每一个电话号码都重新抄一遍。两份通讯录从此再无瓜葛,你改你的,我改我的。
回到文章开头的例子,正确的写法应该是这样的:
ini
import copy
original_data = [
{"name": "张三", "balance": 1000},
{"name": "李四", "balance": 2000}
]
# 这才是真正的救命稻草
backup_data = copy.deepcopy(original_data)
# 随便改 original_data,backup_data 纹丝不动
original_data[0]["balance"] = 900
print(backup_data[0]["balance"]) # 还是 1000
这下你可以放心大胆地改数据了,备份就是真正的备份。
深拷贝不是万能的(那些藏得更深的坑)
听到这儿,你是不是觉得以后所有复制都无脑用 deepcopy 就行了?
先别急。深拷贝也不是银弹,它有几个麻烦的地方。
第一,慢。
deepcopy 要递归遍历整个数据结构,数据越大越慢。如果你的数据结构有几千层嵌套、几百万个元素,deepcopy 可能会让你等到怀疑人生。在性能敏感的场合,浅拷贝和深拷贝之间需要权衡一下。
第二,有些东西拷不了。
比如文件对象、网络连接、线程锁这些东西,根本没法制成一个独立的副本。你强行 deepcopy,Python 会直接报错。
go
import copy
f = open("test.txt", "r")
copy.deepcopy(f) # 报错:无法 pickle 文件对象
第三,循环引用可能出问题。
如果你的数据结构里 A 引用了 B,B 又引用了 A(循环引用),deepcopy 虽然能处理,但如果不小心写错了自定义的 __deepcopy__ 方法,可能会无限递归。
所以深拷贝虽然强,但不是无脑用的。
那到底该怎么选?
踩完这些坑之后,我给自己理了一套决策流程,每次要"复制"东西就按这个来:
如果你要复制的数据里全是不可变类型 ------数字、字符串、布尔值、None、元组(且元组里也都是不可变类型)------用浅拷贝就够了 。这时候切片、copy() 随便用,完全没问题,效率高。
如果数据里有可变类型------列表、字典、集合,或者自定义对象------那就要想清楚:
- 你只是要复制"容器本身",里面的内容打算只读不写 → 浅拷贝就行
- 你要的是完全独立的副本,修改副本不能影响原数据 → 用
deepcopy
如果是自定义对象 ,可以给类实现 __copy__ 和 __deepcopy__ 方法,精确控制拷贝行为,但这属于进阶用法了。
写在最后
那次数据事故之后,我养成了一个习惯:每次写切片或者 copy() 之前,都先看一眼数据结构里面装的是什么。
如果只是 [1, 2, 3] 这样的简单列表,切片大法好。如果是 [{"a": 1}, {"b": 2}] 这样的复杂结构,我一定老老实实写上 import copy 和 copy.deepcopy。
其实回头想想,Python 把浅拷贝作为默认行为是有道理的------速度快、内存省,大多数情况下也够用。但问题在于,很多人(包括当时的我)根本不知道浅拷贝的存在,想当然地认为"复制"就是"完完整整复制一份"。
这两个词在 Python 里完全是两码事。
希望我的这次教训,能帮你少熬一个通宵。下次再用切片复制数据的时候,记得多看一眼------里面装的是不是会"牵连"你的东西。