先跟你讲个我亲身经历的事,那会儿我刚工作第二年。
公司有个客户管理系统,每个客户有个标签列表,存着“VIP”“高潜”“已转化”这类信息。运营同事要定期给部分客户批量更新标签,但更新前需要先复制一份原始数据做备份。
我的代码大概长这样:
original_customers = [
{"name": "张三", "tags": ["VIP", "高潜"]},
{"name": "李四", "tags": ["新客"]},
{"name": "王五", "tags": ["已转化", "复购"]},
]
# 备份一份
backup = original_customers.copy()
# 运营修改第一个客户的标签
backup[0]["tags"].append("待回访")
print(original_customers[0]["tags"])
你猜怎么着?原始数据里的标签也被改了!
我看着屏幕上打印出的['VIP', '高潜', '待回访'],整个人都懵了。明明备份的是backup,改的也是backup,为什么original_customers遭了殃?
那天下午,我把copy和deepcopy的文档翻了十几遍,才终于明白:原来copy()只复制了表面一层,里面藏着的引用根本没动。
今天我就把这个坑彻底给你讲清楚。
从“赋值”说起——你以为的拷贝,其实就是个外号
先得从最基础的赋值讲起。
在Python里,b = a并不是复制了一个新东西,而是给a取了个小名。a和b指向的是内存里同一个对象。
a = [1, 2, [3, 4]]
b = a
b.append(5)
print(a) # [1, 2, [3, 4], 5] —— a也跟着变了
这就好比你叫“张三”,你的好朋友叫你“三儿”。不管是叫“张三”还是“三儿”,喊的都是你这个人。你脸上长了个痘,两个名字描述的都是同一张脸。
所以赋值不叫拷贝,叫引用绑定。你操作b,其实就是操作a。
copy模块登场——浅拷贝长什么样
为了能复制出独立的对象,我们用copy模块。
import copy
a = [1, 2, [3, 4]]
b = copy.copy(a) # 浅拷贝
b.append(5)
print(a) # [1, 2, [3, 4]] —— 没变,好!
print(b) # [1, 2, [3, 4], 5]
看起来不错,外层列表确实分开了。但问题出在嵌套对象上。
b[2].append(5)
print(a) # [1, 2, [3, 4, 5]] —— 啊哦,里面的列表被改了!
看到了吧?外层列表是新的,但内部的子列表还是同一个。浅拷贝只复制了最外层的容器,里面的元素如果是个可变对象,它只复制了引用,没有复制对象本身。
用我们刚才的比喻:浅拷贝就像你用复印机复印了一本书的封面,但里面的每一页还是原来那本书的页——你撕掉复印封面里的某一页,原来那本书的那页也跟着没了。
deepcopy——真正的“克隆”
深拷贝就不同了。它像克隆人技术——不光复制了外形,里面所有的器官、细胞都是全新的。
import copy
a = [1, 2, [3, 4]]
b = copy.deepcopy(a) # 深拷贝
b[2].append(5)
print(a) # [1, 2, [3, 4]] —— 纹丝不动
print(b) # [1, 2, [3, 4, 5]] —— 独立修改
深拷贝会递归地复制对象内部的所有层次,直到遇到不可变对象(数字、字符串、元组等)才停止。因为不可变对象本身就不能改变,共享也无所谓。
实战排雷:这些“拷贝”其实都是浅拷贝
很多人不知道,Python里很多常见操作其实都是浅拷贝。
1. 列表的copy()方法和切片
a = [[1, 2], [3, 4]]
b = a.copy() # 浅拷贝
c = a[:] # 切片也是浅拷贝
d = list(a) # 构造器也是浅拷贝
这三者等价于copy.copy(a)。
2. 字典的copy()方法
d = {"a": [1, 2], "b": [3, 4]}
e = d.copy()
e["a"].append(5)
print(d["a"]) # [1, 2, 5] —— 原字典也被改了
3. 集合的拷贝
s = {1, 2, [3, 4]} # 注意,集合不能包含列表,用frozenset举例
# 但集合的copy()也是浅拷贝
4. 嵌套元组(元组本身不可变,但内部元素可变)
t = (1, 2, [3, 4])
u = copy.copy(t)
u[2].append(5)
print(t) # (1, 2, [3, 4, 5]) —— 还是被改了
所以,只要你的数据结构是嵌套的,浅拷贝就有隐患。
到底什么时候用浅拷贝,什么时候用深拷贝?
知道了区别,关键是怎么选。我给你的原则很简单:
用浅拷贝的情况:
- 你的数据结构是一维的,只包含不可变对象(数字、字符串、布尔值等)
- 或者你明确知道嵌套的内部对象不会被修改
- 性能敏感的场景(深拷贝递归开销大)
- 你只是想复制一个容器,但里面的元素本来就不打算动
必须用深拷贝的情况:
- 数据结构是嵌套的可变对象(列表套列表、字典套列表、类对象等)
- 你要对副本做修改,但绝对不想影响原数据
- 数据是配置、快照、备份等需要完全独立的场景
- 你不确定内部结构有多深,保险起见用深拷贝
话说回来,深拷贝也不是万能的。它有两个小毛病:
- 性能开销大:递归复制所有层级,大数据量时会慢。
- 循环引用问题:如果对象自己引用自己(比如列表a包含a),深拷贝会无限递归。不过Python的
deepcopy已经处理了这种情况,它会维护一个“已复制”字典,遇到循环引用会复用已复制的对象,不会死循环。
但deepcopy默认不复制一些特殊对象(比如文件对象、socket、线程锁等),复制会抛出异常。这些对象不能被拷贝,你需要手动处理。
实战案例:配置文件的灾难
再讲一个真实案例,我那会儿维护一个游戏服务器配置。
配置是一个大字典,里面有各种嵌套结构,比如:
config = {
"level": 10,
"players": [
{"name": "alice", "items": ["sword", "shield"]},
{"name": "bob", "items": ["bow"]}
],
"settings": {"difficulty": "hard", "cheats": False}
}
有一天,我们要给某个玩家临时调整道具,先复制一份配置做试验:
test_config = config.copy()
test_config["players"][0]["items"].append("potion")
结果你懂的,原配置里的alice也多了个药水,导致所有正式服玩家都错了。那天下线修复花了两小时,还被领导质问。
后来我们把所有配置拷贝都改成了deepcopy,这类问题再也没出现过。
一个有趣的细节:不可变对象不需要深拷贝
你可能会问:深拷贝会复制不可变对象吗(比如整数、字符串)?
答案是不会。因为不可变对象没法修改,共享它们没有任何风险。deepcopy在遇到不可变对象时,不会复制一份新的,而是直接共享引用,节省内存。
a = [1, 2, "hello"]
b = copy.deepcopy(a)
print(id(a[0]) == id(b[0])) # True,是同一个整数对象
但如果元组里包含可变对象,深拷贝会复制那个可变对象,因为可变对象可以改。deepcopy会逐层检查。
怎么判断一个对象是浅拷贝还是深拷贝?
你可以用id()或is来判断最外层对象是否不同,但要判断内部嵌套对象,得递归检查。最省事的方法是:改一下里面的内容,看看原数据变没变——这是最直接的测试方法。
写代码的时候,如果拿不准,就先用deepcopy,然后观察性能,如果没问题就保留。性能优化是后期的事,先保证正确性。
总结一张速查表
| 操作 | 类型 | 风险 |
b = a |
赋值(引用) | 修改b就是修改a |
b = a.copy() |
浅拷贝 | 外层独立,内层共享 |
b = a[:] |
浅拷贝 | 同上 |
b = list(a) |
浅拷贝 | 同上 |
b = dict(a) |
浅拷贝 | 同上 |
b = copy.copy(a) |
浅拷贝 | 同上 |
b = copy.deepcopy(a) |
深拷贝 | 完全独立,但可能慢 |
记住这几句话,够你应付大部分场景:
- 赋值不是拷贝,是贴标签。
- 浅拷贝只救急不救穷——管得了第一层,管不了里面。
- 深拷贝是救火队——无论多深,全都给你复制一份。
- 没有银弹——深拷贝虽好,但别滥用,注意性能和特殊对象。
从那以后,我养成了一个习惯:只要数据结构里有list、dict、set等可变容器嵌套,一律用deepcopy做备份。宁可慢一点,也绝不深夜加班改bug。
希望你别重蹈我的覆辙。下次写代码前,先问问自己:我要的是“复印封面”还是“克隆整个人”?选对了,能省下好几个小时的调试时间。
好了,不说了,我现在得去把以前项目里所有的copy()都检查一遍。你也赶紧去吧。