Python的字典合并坑了我三天,原来是踩了这个坑

一个让我在配置系统上翻车的Bug

去年我在写一个微服务的配置管理模块,需要把用户的个性化配置和系统默认配置合并在一起。代码大概是这样的:

makefile 复制代码
default_config = {
    "database": {
        "host": "localhost",
        "port": 5432,
        "pool_size": 10
    },
    "logging": {
        "level": "INFO",
        "file": "/var/log/app.log"
    }
}

user_config = {
    "database": {
        "port": 5433,
        "pool_size": 20
    }
}

# 合并配置------我当时是这么写的
default_config.update(user_config)
print(default_config)

我期待的输出是:

bash 复制代码
{
    "database": {
        "host": "localhost",   # 保留了默认的host
        "port": 5433,          # 用户覆盖了port
        "pool_size": 20        # 用户覆盖了pool_size
    },
    "logging": {
        "level": "INFO",
        "file": "/var/log/app.log"
    }
}

但实际输出是:

arduino 复制代码
{
    "database": {
        "port": 5433,
        "pool_size": 20
        # host呢???
    },
    "logging": {
        "level": "INFO",
        "file": "/var/log/app.log"
    }
}

host丢了!

盯着屏幕看了十分钟,我脑子里只有一个念头:为什么update()把整个嵌套字典替换掉了,而不是递归合并?

这个Bug让我花了三天才彻底搞明白。今天我把字典合并的各种坑一次性讲清楚,希望能帮你省下这三天时间。


坑一:update()的"浅层覆盖"------嵌套字典整块消失

dict.update()是Python里最常用的字典合并方法,但它的行为是浅层更新 (shallow update)。当键对应的值是嵌套字典时,它会直接替换整个子字典,而不是递归合并其内容。

css 复制代码
d1 = {"a": {"x": 1, "y": 2}}
d2 = {"a": {"z": 3}}
d1.update(d2)
print(d1)  # {"a": {"z": 3}} ------ x和y全丢了!

这就是我配置系统里host消失的原因。update()只看到database这个键存在,就直接把整个database子字典替换成了用户配置里的那个,默认配置里的host就这么没了。

为什么Python这样设计?

因为字典在Python里被视为扁平的键值映射 ,update()遵循"键存在则覆盖"的原则,不预设嵌套结构的语义。而且递归遍历所有层级会引入额外开销,违背了update()高效原地更新的初衷。

解决方案:

如果确实需要深度合并嵌套字典,得自己写递归函数:

python 复制代码
def deep_merge(dict1, dict2):
    """递归合并两个字典,dict2的值优先"""
    result = dict1.copy()
    for key, value in dict2.items():
        if key in result and isinstance(result[key], dict) and isinstance(value, dict):
            result[key] = deep_merge(result[key], value)
        else:
            result[key] = value
    return result

d1 = {"a": {"x": 1, "y": 2}}
d2 = {"a": {"z": 3}}
merged = deep_merge(d1, d2)
print(merged)  # {"a": {"x": 1, "y": 2, "z": 3}} ------ 完美!

或者用第三方库,比如deepmerge。


坑二:update()会原地修改------你的原始数据被污染了

update()的另一个特点是原地修改------它会直接修改调用它的字典,而不是返回一个新字典。

ini 复制代码
defaults = {"theme": "dark", "language": "en"}
user_settings = {"language": "zh"}

defaults.update(user_settings)  # defaults被直接修改了!
print(defaults)  # {"theme": "dark", "language": "zh"}

# 如果后面其他地方还想用原始的defaults,就出问题了

很多人在配置合并场景中写defaults.update(user_config),结果后续其他模块读defaults时拿到的是已被污染的值。

正确做法:先复制再更新。

ini 复制代码
safe_settings = defaults.copy()
safe_settings.update(user_settings)

坑三:解包语法{**d1, **d2}------看起来很美,但坑一样在

Python 3.5引入了**解包运算符用于字典合并,语法很简洁:

ini 复制代码
merged = {**default_config, **user_config}

优点是不修改原字典,返回新字典。但嵌套字典的覆盖问题依然存在。

ini 复制代码
d1 = {"a": {"x": 1, "y": 2}}
d2 = {"a": {"z": 3}}
merged = {**d1, **d2}
print(merged)  # {"a": {"z": 3}} ------ x和y还是丢了!

解包只是把两个字典的顶层键值对合并到一起,遇到嵌套字典照样直接替换。

注意 :{**d1, **d2}和d1.update(d2)在键冲突时的行为是一致的------后者覆盖前者。如果两个字典有同名键,最终结果以右侧字典的值为准。


坑四:|运算符(Python 3.9+)------新语法,老问题

Python 3.9通过PEP 584引入了|和|=运算符。

ini 复制代码
# Python 3.9+
merged = d1 | d2   # 返回新字典
d1 |= d2           # 原地更新,相当于d1.update(d2)

语法确实优美,PEP的作者形容这是为了解决过去合并字典时感受到的"痛苦"。

但嵌套覆盖的问题依然存在:

ini 复制代码
d1 = {"a": {"x": 1}}
d2 = {"a": {"y": 2}}
merged = d1 | d2
print(merged)  # {"a": {"y": 2}} ------ x又丢了

|运算符只做浅层键值叠加,不递归处理嵌套结构。

另外要注意 :字典联合不符合交换律 ------d | e和e | d的结果可能完全不同。因为右侧字典的键值会覆盖左侧的。

css 复制代码
d1 = {"a": 1, "b": 2}
d2 = {"b": 3, "c": 4}
print(d1 | d2)  # {"a": 1, "b": 3, "c": 4}
print(d2 | d1)  # {"b": 2, "c": 4, "a": 1} ------ 结果不同!

坑五:ChainMap的"假合并"------你以为合并了,其实没有

collections.ChainMap可以把多个字典在逻辑上变成一个字典。

css 复制代码
from collections import ChainMap
a = {"a": 1, "b": 2}
b = {"b": 3, "c": 4}
c = ChainMap(a, b)
print(c["a"])  # 1
print(c["b"])  # 3 ------ 来自第一个字典a

但它不会真的把字典合并在一起,而是在内部储存一个Key到每个字典的映射。读取时先去第一个字典找,找不到再去第二个。

隐藏坑1:修改ChainMap会影响原始字典。

css 复制代码
c["new_key"] = 100
print(a)  # {"a": 1, "b": 2, "new_key": 100} ------ a被改了!

所有写操作([]=、del、pop、clear)都只修改maps[0],也就是第一个字典。

隐藏坑2:如果同一个Key在多个字典中存在,从ChainMap删除它只会从第一个字典中删除。

隐藏坑3 :ChainMap存储的是对原始字典的引用,不是拷贝。修改原始字典,ChainMap会同步变化。

css 复制代码
a["a"] = 999
print(c["a"])  # 999 ------ 跟着变了

所以ChainMap适合只读场景(比如多层配置的查找),不适合真正需要合并出一个独立字典的场景。


一张表总结五种合并方式

| 方法 | 是否修改原字典 | 嵌套字典是否递归合并 | 适用场景 |
|--------------------|-----------|------------|-------------------|------|-----------|-------------------|
| d1.update(d2) | ✅ 是 | ❌ 否(直接覆盖) | 原地更新,不关心原数据被改 |
| {**d1, **d2} | ❌ 否 | ❌ 否(直接覆盖) | 需要新字典,Python 3.5+ |
| `d1 | d2` | ❌ 否(` | ) / ✅ 是( | =`) | ❌ 否(直接覆盖) | Python 3.9+,函数式风格 |
| ChainMap(d1, d2) | ❌ 否(逻辑视图) | 不适用(不真正合并) | 只读的多层配置查找 |
| 自定义deep_merge | 取决于实现 | ✅ 是 | 需要深度合并嵌套字典 |


回到开头的Bug

我那个配置系统最后用了自定义的deep_merge函数。用户配置只覆盖自己指定的字段,默认配置里没被覆盖的字段全部保留。

ini 复制代码
final_config = deep_merge(default_config, user_config)
# database.host 保住了,port和pool_size被用户覆盖了

三天时间没白花------从此再也没被字典合并坑过。

记住 :Python内置的字典合并方法都是浅合并 。如果你处理的是嵌套字典(配置文件、JSON数据、API响应),默认情况下嵌套层会被整块覆盖 ,而不是递归合并。需要深度合并?自己写递归函数,或者用deepmerge库。

希望你能少加三天班。

相关推荐
cc_瀚海知行20 分钟前
工具调用 6|助手什么时候该停下来?
java·服务器·前端·人工智能·python·ai
溪语流沙35 分钟前
【Web全栈进阶】Nginx反向代理 + HTTPS:正式上线
前端·vue.js·python·nginx·https·fastapi
全栈弄潮儿42 分钟前
Python实战第11期:面向对象编程基础
后端·python·编程语言
估值探索者1 小时前
【Python量化技术指标实战 02】MACD金叉也分强弱?手工算一次 DIF/DEA 就懂了
java·开发语言·python·股票数据·股票数据api接口
魔都吴所谓1 小时前
【我的开源】Python实现一个离线 PDF 工具箱:架构、骑缝章与踩坑记录
python·pdf·开源
用户0332126663671 小时前
Python 自动化办公:如何轻松在 PDF 中导入与导出表单数据
python
MoRanzhi12032 小时前
第5篇(上)· 使用 PyQt5 实现 2048 算法求解版:五类搜索算法内核
python·启发式算法·强化学习·搜索算法·蒙特卡洛树搜索·2048游戏·expectimax
星川皆无恙2 小时前
基于知识图谱的百科问答系统:Django + Neo4j 与实现
人工智能·python·机器学习·自然语言处理·django·知识图谱·neo4j
qq_448011162 小时前
EXEC字典沙箱仅是名字空间隔离,模块和解释器仍共享带来的坑
python
打工仔折腾 AI2 小时前
介入式脑机接口的跨尺度解码与闭环调控:从算法落地到后端工程实践
人工智能·后端·python·深度学习·算法·ai agent 实战