浅拷贝:原字典为何被改?深拷贝与陷阱详解)
先直接说结论dict.copy()的作用是浅拷贝。它能为原字典生成一个新的字典对象但是只复制了最外层容器内层的可变对象比如嵌套的列表、字典仍然共享引用。很多教程在讲这个知识点时只说一句话“用 copy() 复制字典修改副本不会影响原字典”这句话你拿着去测试最简单的场景——字典里全部是字符串、整数这类不可变对象——确实成立没毛病。但只要你往字典里多塞一层列表或者嵌套一个字典这句话就翻车了。我在实际项目的排错中见过不少这种“复制后原字典还是被改了”的诡异 bug根本原因几乎全出在浅拷贝这三个字上。这篇文章我把copy()的底层原理、深浅拷贝的边界条件、以及实际开发中最容易踩的坑完整梳理一遍。标题虽然是“dict.copy()复制字典dict原字典不变化”但真正讲透这个标题其实必须回答好几个问题copy()到底把什么复制了什么叫原字典不变化在什么场景下原字典会“不受控制地”变化这些问题搞清楚你才算真正会用这个API。1. 从一次诡异的“原字典被修改”说开去先讲一个我实际遇到过的情况。当时在做一个配置模块代码大致长这样default_config { database: { host: 127.0.0.1, port: 3306, options: [charsetutf8, use_unicodeTrue] }, cache: { ttl: 3600, backend: redis }, debug: False } def get_config(): return default_config.copy() cfg get_config() cfg[debug] True cfg[database][port] 5432 print(default_config[debug]) # False符合预期 print(default_config[database][port]) # 5432原字典被改了第一行输出False因为copy()确实生成了新字典修改这个新字典的顶层键值不会影响原字典。但第二行输出5432因为cfg和default_config的database键指向的是同一个内存中的字典对象。你通过cfg[database][port] 5432改的是那个共享对象的字段原字典的database键虽然引用没变但引用指向的字典内容已经变了。这种“外层没变内层被改”的现象是浅拷贝最典型的特征也是日常使用中几乎必定会踩的坑。1.1 浅拷贝到底复制了什么用最直白的话说dict.copy()创建一个新字典然后把这个字典的每个键和值都进行引用复制。键和值本身如果是字符串和数字这样的不可变对象那没问题——因为不可变对象压根改不了自己你只能“替换引用”。但如果某个值是可变的比如列表、字典、集合那么新旧字典里的这个键就指向同一个可变对象。结合上图理解新旧字典是两个不同的大箱子但里面装的“小盒子”可变对象是同一个。1.2 不可变对象为什么安全Python 中str、int、float、tuple、frozenset等对象一旦创建就不能修改。所以cfg[debug] True做的事情是在cfg这个新字典里把debug这个键原来指向False的引用替换成指向True。原字典里debug的引用仍然是False互不影响。这也解释了为什么最基础的用法——全字符串/数字的扁平字典——copy()完全够用因为压根没有可变的共享对象存在。2. 深入剖析copy() 的底层逻辑与三种复制方式对比copy()在 CPython 中调用的其实是dict.copy这个 C 级别的方法它做的事情非常直接分配一个新的字典对象然后遍历原字典的条目把每个键值对的引用插入新字典。整个过程不涉及对被引用对象本身的复制或遍历。要彻底搞清楚“原字典是否变化”这个问题必须把 Python 复制相关的三个概念放在一起对比。2.1 引用赋值 vs 浅拷贝 vs 深拷贝复制方式代码示例新字典对象内层可变对象典型场景引用赋值new_dict old_dict不创建两个名字指向同一字典完全共享需要两个名字操作同一个字典时浅拷贝new_dict old_dict.copy()创建新字典共享只改顶层、不修改内层元素时深拷贝new_dict copy.deepcopy(old_dict)创建新字典完全独立复制需要完全隔离的副本时很多人不理解为什么引用赋值不配叫“复制”其实原因很简单new_dict old_dict之后你改new_dict等于改old_dict两个名字就是同一个对象谈不上“副本”。验证方式看对象的id()或者用is判断即可a {k: [1, 2]} b a c a.copy() print(b is a) # True print(c is a) # False2.2 copy() 的时间与空间复杂度copy()的时间复杂度是 O(n)n 是键值对数量空间上需要额外分配一个字典容器。理论上浅拷贝的速度非常快因为不会递归复制内部对象。如果字典有 100 万个键值对copy()大约要遍历 100 万个条目做引用插入而deepcopy()需要递归遍历整个对象图可能要做更多操作慢一个数量级甚至更多。实际开发中我见过有人用copy.deepcopy()复制一个只有 20 个键但内层全是字符串的字典这属于性能和语义上的双重浪费。2.3 什么时候必须用 deepcopy判断标准很简单你是否需要在修改副本的内层结构时不影响原字典。比如你管理一个全局配置树不同模块需要拿到配置后做本地修改import copy base_model { layers: [ {type: dense, units: 128, activation: relu}, {type: dropout, rate: 0.5} ], optimizer: {name: adam, lr: 0.001} } # 改其中一层的 units但不能影响 base_model model_a copy.deepcopy(base_model) model_a[layers][0][units] 256如果不 deepcopybase_model[layers][0][units]会被连带改成 256下一层循环复用时就出错了。另外还有一种半吊子做法自己手动复制内层可变对象。def manual_shallow_fix(d): new d.copy() for k, v in new.items(): if isinstance(v, dict): new[k] v.copy() elif isinstance(v, list): new[k] v.copy() return new这种写法只能复制一层嵌套两层以上又失效。最直观的例子data {a: {b: {c: [1, 2, 3]}}} fixed manual_shallow_fix(data) fixed[a][b][c].append(4) print(data[a][b][c]) # [1, 2, 3, 4]还是被改了所以只要结构层级超过两层老老实实用copy.deepcopy()别自己写递归容易漏且性能不一定好。3. 实战场景dict.copy() 真正的用武之地在哪里搞清楚了浅拷贝的边界就能理解为什么 Python 标准库和大量项目代码里依然大量使用copy()而不是一律deepcopy()。3.1 函数参数默认值的防污染这是最经典的copy()应用场景。Python 函数默认参数在定义时只评估一次默认值对象在多次调用之间是共享的def add_item(item, container[]): container.append(item) return container print(add_item(1)) # [1] print(add_item(2)) # [1, 2] ← 默认值被污染了改成这样就没问题def add_item(item, containerNone): if container is None: container {} # 对空字典进行业务修改 return container但有些场景你确实需要传入一个“默认配置字典”给多个函数调用每个调用只改自己的副本DEFAULT_OPTIONS { retries: 3, timeout: 30, headers: {User-Agent: my-app} } def make_request(optionsNone): opts DEFAULT_OPTIONS.copy() if options: opts.update(options) # 这里如果改 opts[headers]会连带影响 DEFAULT_OPTIONS[headers] # 因为它也是浅拷贝headers 仍是同一个 dict ...注意这里用copy()能够保护顶层opts不会被DEFAULT_OPTIONS影响但headers这个内层字典依然是共享的。如果你的函数会修改内层对象必须deepcopy()或者连headers也手动复制一份。这种浅拷贝写法在大量真实项目中非常常见因为多数场景下业务代码只是对顶层 key 做更新和覆盖很少直接修改内层可变对象。只要团队约定好“副本的内层不可修改”copy()的性能优势就很香。3.2 配置项的“分层合并”需求做配置模块时典型的写法是GLOBAL_CONFIG { level: INFO, handlers: [console, file], format: %(asctime)s - %(name)s - %(levelname)s - %(message)s } def get_logger_config(overridesNone): config GLOBAL_CONFIG.copy() if overrides: config.update(overrides) return config logger_config get_logger_config({level: DEBUG})这个场景里config.update()只改顶层键值不会修改GLOBAL_CONFIG[handlers]这个列表。但如果业务代码后续会执行config[handlers].append(email)麻烦就来了——GLOBAL_CONFIG[handlers]也会多出email。所以使用浅拷贝后需要时刻提醒自己只能替换不能修改。想修改内层对象就把它先复制一层或直接用 deepcopy。3.3 数据清洗管道中的中间态保存数据处理有个常见需求对一条记录做多阶段清洗每阶段保留一个中间快照用于回溯。record { name: Alice , tags: [new, vip], meta: {age: 30, city: Shanghai} } snapshots [] snapshots.append(record.copy()) # 清洗前 record[name] record[name].strip() snapshots.append(record.copy()) # 第一次清洗后 record[tags].append(active) # 注意这里改了内层列表 snapshots.append(record.deepcopy()) # 第二次清洗后需要用 deepcopy 才能保存独立快照如果不注意第一、二个快照的tags列表和原record是共享的最终三个快照的 tags 全是[new, vip, active]根本起不到快照的作用。这种中间态保存需求核心原则就是每个快照必须是完整独立的深拷贝否则你保存的只是一个“引用视图”而不是“当时的数据状态”。4. 除了 copy()dict 复制还有哪些替代方案在实际代码里复制字典的手段远不止copy()一个。不同写法在可读性、性能、适用场景上有细微差别选对了能让代码更 Pythonic。4.1 dict() 构造方法与 dict(**) 解包a {x: 1, y: {z: 2}} b dict(a) # 浅拷贝等同于 a.copy() c {**a} # 浅拷贝也是创建新字典dict(a)和{k: v for k, v in a.items()}在功能上等价于a.copy()都是浅拷贝。从性能上看a.copy()在 CPython 里通常最快因为它直接调用 C 层方法字典推导式最慢因为要经过 Python 层循环。这三种方式的共同点都是浅拷贝理解和记忆时完全可以把它们归为一类。4.2 copy.update() 的“选择性复制”有时你希望新字典只包含某些 key而不是全部复制后删除。可以这样source {name: Tom, age: 20, city: NYC, email: tomexample.com} target {} for key in [name, email]: target[key] source[key]这算不上严格意义的“复制字典”但它在构建 DTO数据传输对象时很常用。类似的还可以用字典推导式selected {k: source[k] for k in [name, email] if k in source}4.3 最容易被忽视的 .setdefault() 与 defaultdict有些同学在复制字典时其实想要的不是复制而是“安全地初始化”。比如d {a: 1} # 想要往 d[b] 对应的列表里追加值时 d.setdefault(b, []).append(10)这和使用copy()无关但它是处理嵌套可变对象时避免手动判空的一种干净写法。类似的collections.defaultdict(list)能让你在不确定 key 是否存在时直接d[key].append(...)省掉一堆if判断。4.4 使用 JSON 序列化做“深拷贝”的坑还有一类野路子——用json.loads(json.dumps(data))做深拷贝。对于纯 JSON 可序列化数据这个方案确实可行而且有些项目就这么干。但它有两个硬伤只支持 JSON 兼容类型dict、list、str、int、float、bool、None遇到set、tuple、datetime、自定义对象直接报错或变形。性能远低于copy.deepcopy()因为它涉及完整的序列化和反序列化过程。copy.deepcopy()是通用方案JSON 方案只适合在“确保数据结构简单 不想引入 import copy”的极简场景下用。我个人不太建议因为深拷贝本身就是一个标准库操作没必要绕这么大弯。5. 哪些对象能放进字典的 key对 copy 有什么影响这一节算是进阶内容但和copy()能否正确工作强相关。5.1 哈希与不可变性的关系字典的 key 必须是可哈希的即实现了__hash__方法且在生命周期内哈希值不能变化。字符串、整数、元组内部元素也需可哈希天然满足。列表、字典、集合等可变对象不可哈希不能作为 key。5.2 元组作为 key 时的隐坑元组本身不可变但如果元组内部嵌套了列表则这个元组不可哈希不能当 keybad_key (1, [2, 3]) # TypeError: unhashable type: list但一个全部由不可变对象组成的 tuple 可以作为 key。对于dict.copy()来说key 无论如何都是引用复制这点不受影响。只是如果你用可变对象做 value浅拷贝的共享问题依旧存在。另一个实际开发的技巧是需要将一个自定义对象作为 key 时必须同时实现__hash__和__eq__并且保证参与哈希计算的属性不可变。否则会出现明明“看起来相等”却取不到值的问题。5.3 自定义对象的浅拷贝陷阱如果你把自定义类的实例作为 value 放进字典copy()也只会复制这个实例的引用而不会调用你自定义的__copy__方法。要控制自定义对象的复制行为需要给类实现__copy__和__deepcopy__这样copy.copy()和copy.deepcopy()就会调用你定义的方法。import copy class ModelConfig: def __init__(self, name, params): self.name name self.params params # dict def __copy__(self): # 浅拷贝自定义行为 new ModelConfig(self.name, self.params) return new def __deepcopy__(self, memo): # 深拷贝自定义行为 new ModelConfig( copy.deepcopy(self.name, memo), copy.deepcopy(self.params, memo) ) return new这段代码展示了如何通过实现协议方法让copy.deepcopy()对自定义类型做出符合预期的复制。在复杂项目里这个技巧能避免大量手工复制代码。6. 经验总结dict.copy() 使用时的自查清单每次写完代码遇到“字典复制问题”我都会按下面这个清单过一遍基本能避免绝大多数浅拷贝引发的隐性 bug。6.1 三个问题副本是否需要完全独立如果需要修改副本的内部结构并且不希望影响原字典必须用copy.deepcopy()如果只修改顶层键值浅拷贝就够。字典里有没有可变 value如果一个 value 是 list、dict、set 或自定义对象浅拷贝后它就是共享的。用之前想清楚是否会有原地修改操作append、insert、pop、赋值给它的子字段等。有没有人可能在想不到的地方修改这个字典比如同事写了一个函数拿着字典就config[xxx].append(...)或者库内部会修改传入字典。这种情况下为了安全宁可深拷贝。6.2 几个快速判断示例操作用 copy() 的风险正确做法new old.copy(); new[a] 1无风险顶层替换copy()new old.copy(); new[lst].append(1)原字典的 lst 也会被改deepcopynew old.copy(); new[sub][x] 1原字典的 sub 也会被改deepcopynew old.copy(); new.update({k: v})无风险顶层覆盖copy()将字典作为函数默认值默认值对象全局共享用 None copy()6.3 关于性能的经验数值在我本机CPython 3.10普通配置简单测过100 万元素的扁平字典copy()大约耗时 30~50ms 级别deepcopy()至少多出数倍到几十倍不等取决于值类型。如果内层有大量自定义对象deepcopy()可能要上百毫秒。所以性能敏感型代码里能用浅拷贝就绝不深拷贝。但前提是语义正确否则为了几十毫秒的性能买单的是半夜排查线上 bug 的自己。6.4 再分享一个最后的小技巧如果你实在纠结“这个字典复制完会不会被意外修改”可以在项目里写一个工具函数import copy def safe_copy(data, deepTrue): if deep: return copy.deepcopy(data) return data.copy()然后约定涉及外部接口传参、全局配置传递的场景一律safe_copy(data, deepTrue)纯内部临时处理用safe_copy(data, deepFalse)。写清楚注释能少很多无谓的心智负担。我在实际项目里被浅拷贝坑过好几次现在看到copy()都会下意识问一句这个字典内部是扁平的还是嵌套的里面有没有可变对象会不会有人在别的地方往内层塞东西问完这几个问题再决定用 copy 还是 deepcopy基本不会再出幺蛾子。希望这篇文章能帮你在“复制字典”这件事上少踩几个坑。