资讯动态

Python深浅拷贝实战:从共享引用事故到数据隔离与性能账单

发布时间:2026/9/30 5:05:01 来源:尧图企业网站定制
如果你用 Python 写过一段时间大概率被这个问题缠过一个列表明明只在一个函数里改了另一个函数里的同一个列表却跟着变了。我最早遇到这类问题时第一反应是宿主环境出了 Bug或者是哪个线程在背后偷偷改数据后来才意识到这压根不是环境的问题而是 Python 可变对象的共享引用机制在作祟。这篇是 Python 精进系列的第六篇我想认真聊聊深浅拷贝。我会从一次真实的数据串改事故出发先把赋值、浅拷贝、深拷贝三者之间的内存差异讲透再拆解不可变对象的驻留机制、深层嵌套结构的复制链路、循环引用的处理方式最后结合工程实战聊聊配置隔离、数据快照和 deepcopy 的性能账单。无论你是刚入门想搞懂浅拷贝和深拷贝的区别还是写了不少年 Python 但偶尔还会被嵌套结构坑到的老手这篇的排查思路和实操经验都能给你一些参考。1. 一场由改个配置引发的数据串改事故1.1 现场还原单服务进程里的三个模块为什么共享同一份配置先说事故本身。我们的单体服务进程启动时在入口处统一加载了一次全局配置然后分别传给三个业务模块。代码大致长这样# 服务入口 config load_config_from_center() module_a ModuleA(config) module_b ModuleB(config) module_c ModuleC(config)config 的内部结构很简单config { timeout: 10, retry: 3, endpoints: [a.service, b.service], }问题出在模块 A 的一段业务逻辑。它为了给自己调高重试次数直接写了config[retry] 5从模块 A 的角度看它只是想给自己调整参数。但同一时刻模块 B 和模块 C 读到的 retry 也变成了 5整个调用链路的节奏全部被打乱。当时组内几个同学互相排查了很久先查配置中心的下发策略再查网络同步延迟最后甚至怀疑到了运维的灰度发布谁都没往代码层面想。这个场景比大多数人想象中要常见得多。很多人写代码时默认把一个变量赋给另一个变量就相当于复制了一份数据但 Python 里的赋值操作对可变对象来说真的只是换了个名字。三个模块拿到的 config 指向的是内存里同一个 dict 对象任何一方做了就地修改其他两方立刻就能看到。1.2 排查链路用 id() 和 is 锁定同一个内存对象为了让排查思路可复现这里展示一下我当时的操作过程。先在模块 B 的入口处打印print(id(config))再到模块 A 的入口处打印print(id(config))两个 id 完全一致。再用 is 判断config_a is config_b # True这里先做个澄清id() 返回的是对象在内存中的唯一标识CPython 里它本质上是对象地址但语言规范只保证同一对象 id 相同不同对象 id 不同实现细节我们不需要纠结太多。is 判断的本质就是比较 id所以is 为 True意味着两个变量名指向同一个对象。如果我们打印 id 发现两个值不同那说明两个变量各自指向了独立对象——即使里面的内容完全一样也只是内容碰巧相同修改其中一个不会影响另一个。这段排查过程的核心收获是当一个奇怪的数据串改出现时不要先去怀疑框架或中间件先确认这个对象在内存中到底被多少处代码共享。用 id() 沿着引用链一路打点很快就能画出一张共享关系图。Python 没有真正的私有内存比 C 里按值传参和 const 引用更容易出现这种问题所以必须主动通过拷贝来划清边界。1.3 事故的本质可变对象被多个名字共同持有从上面的排查可以清晰看到事故的本质不是配置中心出问题也不是网络同步延迟而是可变对象被多个名字共同持有。每一个别名看起来都像是一份独立的数据副本实际上修改任何一个名字都会穿透到所有名字。这引出了今天的主角拷贝。当我们需要一份真正独立的副本时赋值做不到必须要用 copy 模块或者对应容器类型自带的拷贝方法。但拷贝也分深浅两层选错的话事故会以另一种形式继续出现——浅拷贝能挡住外层的增加、删除、替换却挡不住对内存层对象的就地修改。2. 赋值、浅拷贝、深拷贝三种操作在内存中的分水岭2.1 赋值只给同一个对象再贴一个标签先看最基础的赋值操作a [1, 2, 3] b a a is b # True b.append(4) print(a) # [1, 2, 3, 4]关键在于Python 里的变量更像是一个标签而不是盒子。a [1, 2, 3] 创建了一个列表对象然后把 a 这个标签贴上去。b a 做的事情是再拿一个标签 b贴到同一个列表对象上整个过程完全没有创建新的列表。用生活类比最直观你把家里钥匙配了一把给朋友朋友拿着钥匙进屋重新摆了下家具等你回家一看家具已经挪了位置——因为你们进的是同一个家。b.append(4) 就像朋友往房间里加了一把椅子a 看到的自然是加完以后的屋子。这里顺带提一个经典陷阱函数默认参数。很多初学者上过一课上得非常惨烈def add_item(item, data[]): data.append(item) return data print(add_item(1)) # [1] print(add_item(2)) # [1, 2] 第二行输出的不是我期待的新函数吗原因是默认参数 data[] 这个列表只在函数定义时创建一次之后所有不传第二个参数的调用都共享这一个列表。本质还是赋值语义下的共享引用。正确的写法是def add_item(item, dataNone): if data is None: data [] data.append(item) return data2.2 浅拷贝外层换了新容器内层还是原住户浅拷贝要解决赋值共享同一个容器的问题做法是新建一个外层容器然后把原容器里的元素引用逐一复制进去。用代码看最清楚import copy a [1, [2, 3]] b copy.copy(a) print(a is b) # False外层列表已经是新对象 print(a[1] is b[1]) # True内层列表还是原来那个所以浅拷贝的效果是外层结构独立了内层对象还是共享的。具体表现分两类如果你只动外层比如 b.append(4) 或者 b[0] 100原列表 a 完全不受影响因为新列表的下标绑定在另一个对象上。如果你修改内层可变对象比如 b[1].append(4)那 a 也会变成 [1, [2, 3, 4]]因为两个列表里保存的 1 号下标元素是同一个子列表。浅拷贝这个词本身就提示了它的特性浅意味着只复制了一层皮。对只有一个可变层的结构浅拷贝与深拷贝效果相同但只要层数超过一层浅拷贝就开始露出马脚。后续会专门讲如何判断浅拷贝够不够用。2.3 深拷贝对整棵对象树做一次彻底搬家深拷贝的逻辑是不仅新建容器还要递归地复制容器里的每一个对象直到所有层级都产生独立副本。import copy a [1, [2, 3]] b copy.deepcopy(a) print(a is b) # False print(a[1] is b[1]) # False b[1].append(4) print(a) # [1, [2, 3]]原对象纹丝不动深拷贝之后a 和 b 从外层到内层没有任何共享对象修改任何一侧的任何层级另一侧都感知不到。代价也很明显deepcopy 需要遍历整棵对象树可能还要配上递归栈、memo 表开销远比浅拷贝高。工程上不能见拷贝就上 deepcopy要看你的数据结构到底有几层、可变对象分布在哪里。2.4 三种操作的关键行为对照表操作是否新建外层容器内层可变对象处理修改外层是否影响原对象修改内层是否影响原对象赋值 b a否共享影响影响浅拷贝 b copy.copy(a)是共享不影响影响深拷贝 b copy.deepcopy(a)是递归复制不影响不影响这张表是理解深浅拷贝的分水岭。我建议先把它记下来后续遇到任何拷贝相关的问题先回到这张表判断当前场景在改哪一层那个操作会不会穿透穿透了是我想要的还是我必须挡住的。3. 不可变对象的驻留机制以及元组这个夹心饼干3.1 小整数与字符串的驻留缓存为什么 is 的结果忽真忽假接下来要搅一搅浑水Python 里的不可变对象有些时候连 is 的比较结果都会反直觉。原因是解释器对部分不可变对象做了缓存驻留也就是提前创建好一批常用对象后续代码里同一值的字面量直接复用它们。典型的是小整数a 256 b 256 a is b # True c 257 d 257 c is d # 通常为 False不同解释器可能有差异CPython 启动时会预先创建 -5 到 256 的整数对象所以 a 和 b 看起来是两条赋值其实都引用到同一个驻留对象。超过这个范围每次执行 257 字面量都可能临时新建对象is 就变成 False。字符串也有类似驻留策略短字符串、看起来像标识符的字符串常常会被复用。这个问题对深浅拷贝研究真的有影响吗有而且是正面影响。因为不可变对象没有就地修改的能力所谓修改不可变对象本质上只是把变量重绑定到另一个新对象上。比如import copy a [1, 2, 3] b copy.copy(a) b[0] 99 print(a) # [1, 2, 3] print(a[0] is b[0]) # Falseb[0] 99 并不是把原来那个整数 1 改成 99——整数 1 在内存里还好好的只是 b 的下标 0 重新指向了另一个对象。因此浅拷贝一个只包含不可变对象的容器时修改任何位置都安全效果与深拷贝完全一致。3.2 元组的夹心饼干结构外层不可变内层照样能改元组是绕不开的迷惑点。很多人看到元组不可变就直接放下了戒心实际上元组的不可变只保证它的直接元素集合不变不保证元素内部不可变。如果元素里藏着列表、字典这类可变对象修改照样穿透。import copy t (1, [2, 3]) t2 copy.copy(t) print(t[1] is t2[1]) # True t2[1].append(4) print(t) # (1, [2, 3, 4])这个过程很难直观地从元组不可变推导出来因为从语法层面看你没有替换元组里的任何元素只是顺着下标拿到了那个列表对象然后把列表改了。元组对这件事毫无防护能力。所以在设计数据结构时如果希望传出去就放心不会被改不要只依赖元组。更可靠的做法是把内部可变对象也换成不可变对象或者使用像 types.MappingProxyType 这样的只读视图否则就老老实实做拷贝。3.3 判断浅拷贝够不够用的三条经验法则我自己的判断顺序很固定分享给你容器的所有层都只包含不可变对象int、float、str、tuple、frozenset浅拷贝直接用安全容器里有可变对象但业务只会做整体替换外层元素不会钻进去修改内层浅拷贝可以撑住容器里有可变对象而且业务逻辑会就地修改内层对象必须上深拷贝别犹豫。这三条法则本质上是围绕写操作会发生在哪一层做判断。如果你能提前知道业务只会重绑定外层引用浅拷贝足够一旦业务出现拿到内层对象后调用它的修改方法浅拷贝就失守了。4. 浅拷贝的五种姿势切片、工厂函数、copy.copy 的差异实测4.1 五种写法都能得到新列表但背后的逻辑不太一样很多人平时会用各种语法做浅拷贝但未必清楚它们之间的细微差异。以一维列表为例import copy a [1, 2, 3] b1 a[:] # 切片 b2 list(a) # 工厂函数 b3 [i for i in a] # 列表推导 b4 copy.copy(a) # copy 模块通用方法 b5 a.copy() # 列表自带的 copy 方法 print(all(x is not a for x in [b1, b2, b3, b4, b5])) # True全部是新列表对一个简单的一维列表五种写法结果一致性能也都在同一量级。差异体现在扩展性和可读性上copy.copy 是通用接口对自定义类也能生效前提是对象实现了拷贝协议或者 copy 模块能找到合理的默认行为切片、工厂函数则局限于具体容器类型。a[:] 在可读性上很微妙老手一眼能看出是浅拷贝但对团队里的新手不友好。列表推导 [i for i in a] 本质上也是新建列表并逐个引用元素但它可读性最差而且你可以在推导过程里做筛选或映射严格说已经不算是纯粹的拷贝。list(a) 对任何可迭代对象都有效不只是列表a.copy() 则是列表自带的同类型浅拷贝。如果你的目标只是浅拷贝一个列表我通常优先写 a.copy() 或者 copy.copy(a)语义最直白不容易被误读。4.2 字典浅拷贝的实用技巧{**d} 与 copy() 的边界字典的浅拷贝同样常见而且写法更多d {a: 1, b: [2]} d1 d.copy() d2 {**d} d3 copy.copy(d) d1[a] 100 # 不影响 d d2[b].append(3) # 影响 d因为 b 指向同一个列表{**d} 这种字典解包语法是 Python 3.5 之后非常流行的浅拷贝写法简洁且直观尤其适合在函数调用里快速构造一个新字典传递参数。但它和 d.copy()、copy.copy(d) 一样都只复制一层新字典的键和值如果包含可变对象依然和原字典共享。如果你的业务需要把某个字典改几个字段但不想动原字典浅拷贝是标准答案new_params {**params, timeout: 30}如果 params 里还有嵌套的列表、子字典而且后续会深入修改它们这里就要改成 copy.deepcopy(params)。经验是看到嵌套结构先别急着解包先把读写边界想清楚。4.3 numpy 的切片是视图跟 Python 原生切片完全两回事很多从纯 Python 转到数据分析的朋友会在 numpy 上踩一个非常痛的坑。Python 原生列表切片是浅拷贝——新列表新外层容器而 numpy 数组切片默认是视图——新数组对象但底层数据共用同一块内存缓冲区。import numpy as np arr np.array([1, 2, 3]) sub arr[:2] sub[0] 99 print(arr) # [99 2 3]原数组被改了对比 Python 原生列表a [1, 2, 3] sub a[:2] sub[0] 99 print(a) # [1, 2, 3]原列表不受影响如果你在 numpy 里需要一份真正的独立副本必须显式调用 .copy()sub arr[:2].copy()这也是深浅拷贝概念在生态内发生漂移的典型案例——同是切片语言原生和科学计算库给出完全相反的内存语义。遇到自己不熟悉的库先查文档确认返回的是视图还是副本不要拿 Python 原生容器的惯性去套。5. 深拷贝的递归链路、memo 缓存与循环引用处理5.1 deepcopy 的递归穿透过程从外层容器一路复制到叶子copy.deepcopy 的实现比想象中更系统化。它内部维护了一个调度机制根据对象类型分发到对应的复制策略比如 list 递归复制每个元素dict 同时递归复制键和值自定义对象则调用其deepcopy方法。我们可以写一个极简版 deepcopy 来理解递归过程def my_deepcopy(obj, memoNone): if memo is None: memo {} if id(obj) in memo: return memo[id(obj)] if isinstance(obj, list): new [] memo[id(obj)] new for item in obj: new.append(my_deepcopy(item, memo)) return new if isinstance(obj, dict): new {} memo[id(obj)] new for k, v in obj.items(): new[my_deepcopy(k, memo)] my_deepcopy(v, memo) return new return obj注意这个极简版的判断顺序遇到不可变对象int、str、tuple、frozenset直接返回原对象不做复制。这背后的道理和前面 3.1 节一致不可变对象不可能被就地修改共享是绝对安全的复制反而浪费内存。真正的 deepcopy 对不可变对象也采用类似直接返回的处理方式。5.2 memo 参数既防止循环引用也保住对象图里的共享关系深拷贝面临的第一个技术难题是循环引用。比如a [] a.append(a)如果 deepcopy 只是简单地递归复制遇到 a 的 0 号元素还是 a就会无限递归下去。copy.deepcopy 的解法是 memo 字典记录原始对象 id - 新对象的映射。每处理完一个对象就登记一次后续再遇到同一个原始对象直接从 memo 里取已经新建的副本而不是再造一个新对象。用我上面的极简版 my_deepcopy 也能验证import copy a [] a.append(a) b copy.deepcopy(a) print(b[0] is b) # True循环结构被完整保留这引出一个容易被忽略的细节深拷贝不仅仅解决循环引用它还保证了原对象图里的多引用指向同一个对象关系在拷贝后被忠实保留。举个例子shared [1, 2] data [shared, shared] new copy.deepcopy(data) print(new[0] is new[1]) # Truedata 的两个元素原本指向同一个 shared 列表深拷贝后 new[0] 和 new[1] 也指向同一个新列表。这个语义对业务非常重要如果两个地方本来共享同一个对象说明它们在逻辑上是一体的拷贝后如果变成两份后续修改就会打破原来的关联。memo 的作用就是锁住这种共享拓扑。5.3 自定义类的拷贝接管实现copy和deepcopy默认情况下copy.deepcopy 对一个自定义类的处理逻辑是新建一个实例再复制它的dict属性表。这个默认行为对普通数据类好用但对持有资源句柄或跨进程素材的对象就不合适了。比如类里有文件句柄、socket 连接、线程锁强行 deepcopy 要么拷贝出不可用的句柄要么复制一个谁都操作不了的锁。这时需要显式接管拷贝逻辑import copy class Node: def __init__(self): self.children [] self.parent None def __deepcopy__(self, memo): new Node() memo[id(self)] new new.children copy.deepcopy(self.children, memo) new.parent copy.deepcopy(self.parent, memo) return new这里有两个要点先创建空的新对象并在 memo 里登记再处理 children 和 parent。登记这一步特别关键如果这个对象直接或间接引用了自身比如 parent 链形成环后续 deepcopy(self.parent, memo) 再回到当前实例时能从 memo 里找到尚未填充完的 new从而正确构造出等价的环。如果不想深拷贝某个属性可以像下面这样直接跳过class Connection: def __init__(self, host, port): self.host host self.port port self.session object() def __deepcopy__(self, memo): new Connection(self.host, self.port) # session 不参与拷贝新对象重新分配 return newcopy的写法类似只是签名少一个 memo 参数。日常使用中我建议只有确认默认深拷贝行为会产生副作用时才自定义否则尽量让 copy 模块自动处理自定义逻辑写多了也是维护成本。6. 工程实战配置隔离、数据快照与 deepcopy 的性能账单6.1 配置模块隔离的标准写法加载后先深拷贝再分发回到第一节课的事故。最直接的修复方式就是让每个模块拿到配置后先深拷贝一份各改各的import copy GLOBAL_CONFIG { timeout: 10, retry: 3, endpoints: [a.service, b.service], } def load_config(): return copy.deepcopy(GLOBAL_CONFIG)模块 A 拿到的是自己的副本改 retry 不会影响别人。如果配置结构简单、全是不可变对象深拷贝和浅拷贝成本差别不大但只要配置里出现列表和子字典深拷贝的隔离效果就是实打实的。另一个常见场景是数据库连接对象或第三方 SDK 的客户端。这类对象通常不适合拷贝因为它们内部有 socket、连接池等状态。最佳实践是做成单例由服务持有原始对象所有模块共享它同时明确约定连接对象只读、不修改内部状态。配置隔离的判断标准是配置被写入方修改之前先问一句这是它的私有配置还是全局共享配置。私有就深拷贝共享就靠只读约定与不可变结构。6.2 数据快照与回滚用 deepcopy 保存处理前的状态批量任务里常见一个需求执行一组操作前保存状态快照出错时回滚到操作前状态。state 通常是嵌套的 dict 和 list保存快照最无脑的写法就是 deepcopysnapshot copy.deepcopy(current_state) try: apply_batch_changes(current_state) except Exception: current_state.clear() current_state.update(snapshot) raise这里有个特别重要的细节回滚时一定要用 clear() update() 在原对象上恢复数据而不是给 current_state 整体重新赋值。原因还是 2.1 节那个道理如果其他模块持有 current_state 的引用整体赋值只会让 current_state 指向一个新字典其他引用方拿到的还是那个已经被改得乱七八糟的旧对象。在内存里用 id 一查就会发现出现了两个 state。这个坑我在项目里见过不止一次。有人觉得 snapshot current_state 就够了操作失败后原样赋回去结果 snapshot 和 current_state 本来就是同一个对象改完以后 snapshot 也跟着变了等于没有快照。所谓快照必须是一份真正的独立副本浅拷贝在嵌套状态下也不可靠直接 deepcopy 最稳。6.3 deepcopy 的性能账单一组量级实测数据与替代方案deepcopy 好用但贵。下面这组是我在 CPython 3.11 环境下的粗略量级数据机器配置不同会有些出入但趋势很有参考价值数据结构操作量级耗时十万个整数的一维列表copy.copy百微秒级十万个整数的一维列表copy.deepcopy十毫秒级十万个两层嵌套列表copy.deepcopy几十毫秒级十万个三层混合 dict/listcopy.deepcopy百毫秒级以上可以看到同样规模的数据deepcopy 比 copy.copy 慢两个数量级并不稀奇。对象层级越深对象图越复杂deepcopy 的时间与内存开销涨得越快。如果你把 deepcopy 放在每秒调用几十次的热点路径上性能账单很快就会追上来。替代方案按优先级排列只复制需要修改的那一层其他层继续共享引用把数据改成不可变结构比如 dataclasses(frozenTrue)或者元组 只读映射对大数组使用专属复制方法如 numpy 的 .copy()它比通用 deepcopy 快得多对极端大的对象树考虑序列化快照pickle / json但要评估序列化和反序列化本身的开销。实际项目里我见过太多因为滥用 deepcopy 导致接口耗时翻倍的案例。拷贝本身不是目的隔离写操作才是。6.4 绕开 deepcopy 的三个判断标准我做了几年 Python 之后已经很少无脑用 deepcopy 了。判断要不要深拷贝我只看三点第一这个对象会不会被修改。如果只是传给某函数做只读读取赋值就够了传参本身零成本拷贝才是花钱的地方。很多资深工程师会觉得稳健起见拷贝一份结果在热点函数里白白消耗性能。第二写操作发生哪些层。如果只发生在最外层比如给字典加一个新键浅拷贝完全足够只有写操作会钻到内层可变对象里深拷贝才有意义。第三修改之后谁需要看到。如果数据改完本来就是要同步给所有引用方的那共享引用不是 Bug而是需求。比如集中式的配置中心、监控状态、任务进度这种情况下拷贝反而破坏了联动关系。最后一个我个人的习惯拿到一个结构复杂的对象先在代码里把谁会写、谁只读标出来。只读的一方放心共享唯一会写的那个地方才考虑拷贝。这个习惯能省下大量不必要的 deepcopy也能避免大部分共享引用事故。深浅拷贝本身没有优劣但从工程需求和对象图结构出发你总能找到够用且不浪费的那一级拷贝。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑