1. 深浅拷贝到底是什么回事
1.1 从一次线上事故说起
先讲一个我印象特别深的真实经历。有一次我负责维护一个数据处理服务,某天凌晨突然有用户反馈说“明明我只改了第一组数据,为什么其他组的数据也跟着变了”。我当时第一反应是“并发冲突了吧”?查了半天日志,最后定位到是一行copy = old_data的赋值语句——这不是拷贝,这只是在给同一个对象多贴了一个名字。这个bug最终让一批订单的价格全部被改成了同一个值,损失虽然不大,但复盘时真的让人冷汗直冒。
如果你也是一个刚接触Python不久的同学,或者虽然写了一阵子Python但一直没有系统梳理过对象赋值、浅拷贝、深拷贝这三者区别的人,那么这篇内容就是为你准备的。我尽量用大白话把机制讲清楚,再用真实的代码把“坑”踩一遍给你看。学完之后,你再遇到“改一处全盘变”的诡异问题,心里就有底了。
1.2 三个关键词,先建立整体认知
深浅拷贝这个话题,本质上回答的是三个问题:
- 把一个变量赋值给另一个变量时,两个变量指向的是不是同一个对象?
- 用
copy.copy()拷贝一层容器时,容器里的子对象是新对象还是原对象的引用? - 用
copy.deepcopy()递归复制所有层级之后,是不是就和原对象彻底无关了?
我先把答案放在这里,方便你脑子里有个框架:
赋值(
=)只是给同一个对象新增了一个引用,指向的还是那块内存。
浅拷贝(copy.copy())会创建一个新容器对象,但容器内的元素引用不变。
深拷贝(copy.deepcopy())会递归创建所有层级的对象,和原对象彻底脱离关系。
这个框架看起来简单,但一旦遇到嵌套列表、字典、自定义类的实例,就会出现各种意想不到的细节问题。下面我一个个拆开讲。
2. Python对象模型的底层逻辑
2.1 变量是标签,不是盒子
我记得自己刚学Python的时候,用的教材上画了一个很形象的比喻:把变量想象成贴在对象上的标签,而不是装东西的盒子。这比“变量是盒子”这个思维模型要准确得多。当你写a = [1, 2, 3]的时候,实际上是创建了一个列表对象[1, 2, 3],然后在这块内存上贴了一个名为a的标签。你再写b = a,并不是把列表复制一份给b,而是又拿了一张标签贴在同一个列表对象上。
验证方法很简单,用内置函数id()查看对象的内存地址:
a = [1, 2, 3] b = a print(id(a), id(b)) print(a is b) # True这也就是为什么你改b的内容,a也会跟着变。因为它们本来就是同一个对象,只是名字不同。明白了这一点,后面理解浅拷贝和深拷贝就会顺理成章。
2.2 可变对象与不可变对象的区别
在Python的世界里,对象分两类:可变对象(如列表、字典、集合)和不可变对象(如整数、字符串、元组)。这个区别在深浅拷贝里至关重要。
对于不可变对象来说,赋值、浅拷贝、深拷贝在多数情况下“看起来”没有区别。你用copy.copy()拷贝一个整数,得到的就是同一个对象,因为整数本身不可变,你不可能在不改变引用的情况下修改它的值。但对可变对象来说,情况就完全不同了,列表里的元素可以被原地修改,这就会导致“共享内容”的问题。
很多面试题喜欢问“元组是不可变对象,那元组里的列表可变吗”?答案是:元组本身不可变,你没法给元组增加或删除元素,但如果元组里存的是一个列表,你可以修改这个列表的内容。这个特性在后文讲深拷贝处理元组时也需要注意。
2.3 引用计数与内存共享的正常状态
在Python中,多个变量引用同一个对象,其实是一件非常普遍且正常的事情。比如a = 1,b = 1,两个变量很可能指向同一个整数对象,因为Python对小整数做了缓存。比如c = "hello",d = "hello",在CPython中也可能指向同一个字符串对象。
这种内存共享本身不是问题,问题只出在“你希望它们独立,但实际上它们共享”的场景。所以学习深浅拷贝,重点不是学会用哪个函数,而是学会判断“这个场景下,多个引用之间是否允许互相影响”。这是工程上的判断力问题,不是纯粹的API记忆问题。
3. 复制操作的三个层级实操拆解
3.1 第一层:普通赋值的本质是“贴标签”
为了让你直观看到差异,我写一段对比代码:
original = [1, 2, 3] assigned = original assigned.append(4) print(original) # [1, 2, 3, 4]当你执行assigned.append(4)时,你改的是original和assigned共同引用的那个列表对象。所以用print(original)查看,内容也变了。这个行为在很多新手看来非常反直觉,但从“变量是标签”这个模型来看,完全顺理成章。
在工程实践中,普通赋值最常见的坑出现在函数传参里。你写一个def process(data):的函数,内部对data做了修改,外部传入的变量也会被你改掉。如果你不希望外部变量被影响,就必须在函数内部第一行做一个拷贝。我见过很多代码就是因为少了这一行,导致函数的“副作用”泄漏到外部,bug排查起来极其费劲。
3.2 第二层:浅拷贝只复制最外层
Python中实现浅拷贝有多种方式。最常用的有两种:
copy.copy()- 列表的
.copy()方法,或者list()构造器 - 切片
[:]也是浅拷贝
我用一段代码来演示浅拷贝的“浅”体现在哪里:
import copy original = [[1, 2], [3, 4]] shallow = copy.copy(original) # 外层是新对象 print(original is shallow) # False # 但内层子对象并没有被复制 print(original[0] is shallow[0]) # True关键点就在第二行判断:original[0] is shallow[0]的结果是True。这说明浅拷贝创建了一个新的外层列表对象,但新列表里的元素还是指向原来那些子对象。你可以理解为:浅拷贝把一个“文件夹”复制了一份,但文件夹里的文件还是原来那几份。
如果此时你修改内层列表的元素,惊吓就来了:
shallow[0].append(99) print(original) # [[1, 2, 99], [3, 4]]外层的original和shallow已经是两个不同的列表了,但内层的子列表仍然共享。所以内层一改,两边都变。这个现象是深浅拷贝文章里老生常谈的重点,但真正在项目里写过不少代码的人,才更深刻地体会到它有多容易踩坑。
再补充一个很多人忽略的细节:copy.copy()对不可变对象和可变对象的处理策略不同。对于不可变对象,它很多时候会直接返回原对象;对于可变对象,它会新建一个同类型的容器。这个细节你不需要死记硬背,只需记住“浅拷贝关注的是容器本身是否独立,不关注容器内元素是否独立”。
3.3 第三层:深拷贝递归到底
copy.deepcopy()做的事,就是递归地检查容器里的每一个元素,如果是不可变对象就直接引用,如果是可变对象就再创建一个副本。这个过程会一直递归下去,直到所有层级都被复制完毕。
import copy original = [[1, 2], [3, 4]] deep = copy.deepcopy(original) print(original is deep) # False print(original[0] is deep[0]) # False deep[0].append(99) print(original) # [[1, 2], [3, 4]]可以看到,深拷贝之后,内层子列表也是独立的对象。你修改deep里的任意层级,original都不会受到影响。这就是“彻底复制”的含义。
不过这里我想提醒一个容易误判的场景:嵌套的数据结构里如果有很多层、有很多重复引用,deepcopy会如何处理?Python 的deepcopy内部维护了一个记忆字典(memo),用来记录已经复制过的对象。如果同一个对象在数据结构中出现了多次,deepcopy会复用同一个新副本,而不是复制出多个独立副本。这个特性在后续讲自定义对象时会再次遇到,先有个印象。
3.4 特殊对象与自定义类的拷贝行为
自定义类的实例默认情况下如何被拷贝?如果你不重写任何方法:
class Person: def __init__(self, name, hobbies): self.name = name self.hobbies = hobbies p1 = Person("张三", ["篮球", "阅读"]) p2 = copy.copy(p1) p3 = copy.deepcopy(p1) p2.hobbies.append("编程") print(p1.hobbies) # ['篮球', '阅读', '编程']浅拷贝p2的hobbies和p1的hobbies指向同一个列表对象,所以p2改了列表,p1跟着变。深拷贝p3则完全独立。
在实际项目中,如果你定义了一个类,且这个类的实例会被到处传递、被多个地方修改,我建议你在__init__里就注意:传入的可变对象应该在内部做一次拷贝再存起来。这是一种“防御式拷贝”的习惯,能省掉很多后续排查的麻烦。例如:
class Person: def __init__(self, name, hobbies): self.name = name self.hobbies = hobbies.copy() # 防止外部修改影响内部状态4. 边界情况与坑点避雷指南
4.1 不可变对象嵌套可变对象的诡异表现
我一直觉得元组是很好的“坑王”材料,因为它表面不可变,实际上却可能包含可变元素。来看这个例子:
import copy t1 = ([1, 2], "hello") t2 = copy.copy(t1) t3 = copy.deepcopy(t1) print(t2[0] is t1[0]) # True,浅拷贝的元组内部列表仍是同一个 print(t3[0] is t1[0]) # False,深拷贝递归复制了列表浅拷贝一个元组,因为元组本身不可变,容量上似乎没有必要新造一个。事实上copy.copy(t1)返回的可能就是t1本身。但如果你认为“元组不可变,所以拷贝不拷贝无所谓”,那就掉坑了——元组内部的列表是可变的,你拿t2[0].append(3)依然会影响t1[0]。
我自己的习惯是:任何包含可变对象的数据结构,不管外层是列表还是元组,只要需要独立性,就直接用deepcopy。不要抱着“它不可变应该没问题”的侥幸心理,因为你省掉的判断时间,很可能换来一个深夜排查的bug。
4.2 循环引用能否被deepcopy处理
循环引用是指一个对象直接或间接地引用了自身。比如:
a = [] a.append(a)这种结构在递归函数里很容易意外生成。如果此时你用deepcopy复制,会发生什么?答案是可以正常复制,因为前面提到的记忆字典(memo)机制会记住已经复制过的对象,当遇到循环引用时,直接返回之前创建的新对象,避免了无限递归。
import copy a = [] a.append(a) b = copy.deepcopy(a) print(b[0] is b) # True,循环引用关系被保留了如果你自己实现一个__deepcopy__方法,也需要处理好循环引用,否则会爆栈。这个知识点在面试里可能会被追问,日常开发则更多是帮你理解“deepcopy为什么不会死循环”的底层机制。
4.3 deepcopy的性能开销与注意事项
完美的深拷贝是有代价的。如果一个对象非常庞大,比如一个几万层嵌套的配置字典,deepcopy的时间开销和内存开销都会很可观。我在实际项目中遇到过把一个大DataFrame转成Python对象后反复深拷贝的场景,一次拷贝就要几十毫秒,性能完全扛不住。
应对方式有几个:
- 优先避免拷贝,改用不可变数据结构(如元组、
frozenset),或者只读访问。 - 在需要传递数据时,明确是“只读共享”还是“必须独立”,避免无意义的深拷贝。
- 自定义类的
__deepcopy__方法里,可以只复制关键字段,跳过那些不需要复制的资源型字段(如数据库连接、文件句柄)。
4.4 混合深浅拷贝导致的数据杂乱问题
还有一种情况在工程里很常见:同一个数据结构,有的部分是浅拷贝,有的部分是深拷贝,拼在一起后整个数据的一致性和独立性变得混乱。比如你把一个配置字典做了深拷贝,但其中的某些子字典是用浅拷贝拼进去的,结果一处修改牵动多处,出现“半独立”的诡异状态。
所以我的建议是:在一个项目的边界处,统一规定拷贝策略。比如所有外部传入的数据,在入口处一律深拷贝后再在内部流转。不要在代码里各个地方随手用copy.copy(),让策略变得不可预测。
5. 判断该用哪种拷贝的实战流程图解
5.1 一个快速判断的四个问题
在实际开发中,我每次犹豫“要不要用深拷贝”时,都会在脑内过一遍判断流程。整理出来就是这四个问题:
第一个问题:你要传递的数据结构里是否包含可变对象(列表、字典、集合,或者包含这些对象的自定义类实例)?如果完全没有,普通赋值或者浅拷贝就够了。
第二个问题:接收方或者后续代码是否会修改你传入的数据?如果只读,浅拷贝够用;如果要修改,且你不能接受修改影响原数据,需要深拷贝。
第三个问题:这个对象是否可能被多个地方同时持有引用?如果只是局部临时用一下,浅拷贝就行;如果全局共享并需要隔离,必须深拷贝。
第四个问题:数据结构的深度是否很深、数据量是否很大?如果很大,要考虑深拷贝的性能成本,有时宁愿重构数据结构,也不要做无谓的复制。
5.2 实战案例一:函数调用场景
假设你写了一个函数,内部要往传入的列表里添加数据,但又不想影响外部列表:
def add_item(items, item): # 如果你不希望外部 items 被修改 local_items = items.copy() local_items.append(item) return local_items这里用浅拷贝就够,因为items的元素大概率是不可变对象。但如果items内部是嵌套列表,且函数内部要修改内层列表,浅拷贝就失效了,需要用深拷贝。
5.3 实战案例二:数据缓存与备份场景
你可能遇到过这种需求:从一个全局配置对象生成一个临时配置,在临时配置上做一些修改和试验,但不能影响原始全局配置。这种场景必须用深拷贝,因为配置对象通常是嵌套的字典结构:
import copy config = { "database": { "host": "localhost", "port": 3306, "options": ["a", "b"] } } temp_config = copy.deepcopy(config) temp_config["database"]["port"] = 3307 print(config["database"]["port"]) # 3306如果这里用浅拷贝,temp_config["database"]和config["database"]就会指向同一个字典,改端口时会把全局配置也改掉。这种bug在测试环境不易发现,一上生产就出事。
5.4 实战案例三:类内部状态的保护
当你的类内部保存了一个外部传入的可变对象,最好的习惯是在__init__里做一次防御性拷贝:
class Analyzer: def __init__(self, data): self.data = copy.deepcopy(data) def run(self): # 方法内部会修改 self.data,但不会影响外部传入的 data ...这样做短期内看起来有点浪费内存,但长期来看,它能帮你守住类的边界,避免对象状态被外部意外篡改。很多系统性的bug,本质上都是边界没守好导致的。
6. Python官方copy模块的高级用法
6.1 copy.copy 与 copy.deepcopy 的完整签名
copy.copy(x)和copy.deepcopy(x)都是函数式接口,deepcopy还有一个可选参数memo。在没有特殊需求时,我们不传memo,但当你需要精细控制复制过程中的对象复用时,可以手动传入一个字典。
import copy memo = {} new_obj = copy.deepcopy(original, memo) print(memo) # 可以看到复制过程中新旧对象的映射关系这个memo参数在某些复杂场景下很有用,比如你知道复制过程中遇到某种对象时希望直接复用某个特定实例,可以先在memo里预设映射。
6.2copy和deepcopy方法自定义
你可以通过实现__copy__和__deepcopy__来定制类的拷贝行为。例如你的类内部有一个不希望被复制的资源型属性(比如已经打开的日志文件、线程池),就可以在__deepcopy__里选择跳过它。
一个简单的例子:
class Resource: def __init__(self, data): self.data = data self.logger = open("log.txt", "a") def __deepcopy__(self, memo): new_obj = Resource.__new__(Resource) new_obj.data = copy.deepcopy(self.data, memo) # logger 不复制,新对象直接指向原来的 logger,或者新建一个 new_obj.logger = self.logger return new_obj重写拷贝方法不常用,但在一些大型项目中控制资源复制时非常关键。面试时能说出这个点,也很有加分效果。
6.3 与 pickle 模块的关系
pickle模块也能实现对象的序列化与反序列化,某种程度上可以做到“深拷贝”的效果:
import pickle a = [[1, 2], [3, 4]] b = pickle.loads(pickle.dumps(a)) b[0].append(99) print(a) # [[1, 2], [3, 4]]但用pickle做拷贝有个前提:对象必须可以被序列化。自定义类实例、lambda 函数、文件句柄等很多对象都无法序列化,这时候就会抛异常。而且 pickle 的性能通常比 deepcopy 差,所以常规深拷贝优先用copy.deepcopy。pickle 的优势主要在跨进程传递、持久化存储这些场景,不要混用。
7. 常见问题速查与独家排查技巧
7.1 常见问题排查表
我整理了三个最常见的问题现象和对应排查方向,方便你遇到问题时快速定位:
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 修改一个列表,另一个列表也变了 | 使用了普通赋值=,两个变量指向同一个对象 | 检查是否用了copy()或deepcopy() |
| 浅拷贝后修改内层嵌套数据,原数据仍然变化 | 浅拷贝没有复制子对象,内层仍然是共享引用 | 检查数据结构是否有嵌套,考虑用深拷贝 |
| 深拷贝后程序变慢或内存暴涨 | 深拷贝复制了所有层级,可能有循环引用或超大对象 | 检查是否有无谓的深拷贝,必要时自定义__deepcopy__或改用只读共享 |
7.2 独家排查技巧:用id()定位共享引用
当你不确定一个数据对象是否和另一个对象共享内存时,最快的办法是用id()打印两边的地址,或者在两个关键节点打印同一个子对象的地址:
print(id(data["options"])) print(id(temp["options"]))如果两个id()相同,说明它们是同一个对象。这个手法比单纯靠看代码来得直观得多。我在排查不确定的bug时,习惯先写几行这种检查代码,把“嫌疑对象”的地址打出来,再结合日志一步步缩小范围。
7.3 个人最常用的三种安全写法
我在工作里总结了几种“无脑安全”的写法,分享给你:
第一种:函数接收外部可变数据时,如果后续会修改数据且不希望影响外部,开头直接深拷贝:
def process(data): data = copy.deepcopy(data) ...第二种:类初始化接收外部数据时,存内部副本:
class Processor: def __init__(self, data): self._data = copy.deepcopy(data)第三种:构造配置快照时,一律用深拷贝,哪怕你觉得当前配置层级不深:
snapshot = copy.deepcopy(config)这些写法的共同点是把“边界策略”统一化了,不用每次调用时再现场判断,减少认知负担,也减少出错概率。
8. 从面试到实战的延伸思考
8.1 面试中常见的深浅拷贝追问
深浅拷贝是Python面试中的高频题,经常会变着花样追问。比如:
a = [1, 2, 3],b = a[:],两者有什么关系?copy.copy和copy.deepcopy的区别是什么?- 元组里的元素可变时,深浅拷贝表现如何?
- 如何在自定义类中控制浅拷贝和深拷贝行为?
这些问题的核心都是考查你是否真正理解了“引用 vs 对象”的区别。理解了我前面讲的内容,再配合一些代码实验,回答起来并不困难。我的建议是面试前一定要手写几遍验证代码,不要只背概念,因为面试官容易让你现场画内存示意图或写代码验证。
8.2 工程中什么时候不用深拷贝
深拷贝是好东西,但不要滥用。在性能敏感的场景里,比如单次处理数百万行数据的循环里,每行都做一次深拷贝,性能会直线下降。这种情况下,更好的方案是:
- 设计成不可变对象,每次修改返回一个新对象;
- 使用显式的读操作,避免修改原数据;
- 只拷贝真正会变的那一小部分,而不是全量拷贝。
这说明深浅拷贝不是终极答案,它只是一种工具。真正重要的是你对自己代码的数据流动有清晰的认知。
8.3 与Python其他特性的结合
深浅拷贝还经常和默认参数、闭包、装饰器这些Python特性一起出现。比如默认参数为可变对象是一个经典反模式:
def add_item(item, items=[]): items.append(item) return items print(add_item(1)) # [1] print(add_item(2)) # [1, 2]第二次调用时,items已经不是空列表了,因为默认参数在函数定义时就被创建并缓存了。解决方案是改成items=None,然后在函数内部新造一个列表。这和深浅拷贝背后的“可变对象共享”问题同根同源。
我个人在工作中的体会是,深浅拷贝看似是个小知识点,但它牵涉到Python对象模型的根基。把这一块真正吃透,很多“莫名其妙”的bug都会消失。遇到不确定的时候,多写几行验证代码,比翻文档空想来得可靠得多。最后再分享一个小技巧:随手在代码里打印id(),你会更直观地看到对象引用的变化,这算是排查这类问题最朴实也最有效的办法。