先直接说结论:dict.copy()的作用是浅拷贝。它能为原字典生成一个新的字典对象,但是只复制了最外层容器,内层的可变对象(比如嵌套的列表、字典)仍然共享引用。
很多教程在讲这个知识点时只说一句话:“用 copy() 复制字典,修改副本不会影响原字典”,这句话你拿着去测试最简单的场景——字典里全部是字符串、整数这类不可变对象——确实成立,没毛病。但只要你往字典里多塞一层列表或者嵌套一个字典,这句话就翻车了。我在实际项目的排错中见过不少这种“复制后原字典还是被改了”的诡异 bug,根本原因几乎全出在浅拷贝这三个字上。
这篇文章我把copy()的底层原理、深浅拷贝的边界条件、以及实际开发中最容易踩的坑完整梳理一遍。标题虽然是“dict.copy()复制字典dict,原字典不变化”,但真正讲透这个标题,其实必须回答好几个问题:copy()到底把什么复制了?什么叫原字典不变化?在什么场景下原字典会“不受控制地”变化?这些问题搞清楚,你才算真正会用这个API。
1. 从一次诡异的“原字典被修改”说开去
先讲一个我实际遇到过的情况。
当时在做一个配置模块,代码大致长这样:
default_config = { "database": { "host": "127.0.0.1", "port": 3306, "options": ["charset=utf8", "use_unicode=True"] }, "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如果不 deepcopy,base_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, container=None): if container is None: container = {} # 对空字典进行业务修改 return container但有些场景你确实需要传入一个“默认配置字典”给多个函数调用,每个调用只改自己的副本:
DEFAULT_OPTIONS = { "retries": 3, "timeout": 30, "headers": {"User-Agent": "my-app"} } def make_request(options=None): 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(overrides=None): 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": "tom@example.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 时的隐坑
元组本身不可变,但如果元组内部嵌套了列表,则这个元组不可哈希,不能当 key:
bad_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 也会被改 | deepcopy |
new = old.copy(); new["sub"]["x"] = 1 | 原字典的 sub 也会被改 | deepcopy |
new = 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, deep=True): if deep: return copy.deepcopy(data) return data.copy()然后约定:涉及外部接口传参、全局配置传递的场景一律safe_copy(data, deep=True);纯内部临时处理用safe_copy(data, deep=False)。写清楚注释,能少很多无谓的心智负担。
我在实际项目里被浅拷贝坑过好几次,现在看到copy()都会下意识问一句:这个字典内部是扁平的还是嵌套的?里面有没有可变对象?会不会有人在别的地方往内层塞东西?问完这几个问题,再决定用 copy 还是 deepcopy,基本不会再出幺蛾子。希望这篇文章能帮你在“复制字典”这件事上少踩几个坑。