小赵那天被一个线上告警折腾到凌晨两点。
他写了一个用户配置读取模块,从接口拿到一个字典,然后取里面的字段来用。逻辑很简单:
config = {"theme": "dark", "font_size": 14}
theme = config["theme"]
font_size = config["font_size"]
language = config["language"] # 用户没设置这个字段
结果程序直接崩了,报了一个KeyError: 'language'。他盯着日志想:用户没设置语言,那给个默认值不就行了?于是他把代码改成了:
language = config.get("language")
print(language) # None
程序不崩了。但他接着写:
if language.startswith("zh"):
load_chinese_font()
又崩了,报AttributeError: 'NoneType' object has no attribute 'startswith'。他这才意识到,get虽然不报错,但返回的None会在下游埋下新的雷。
“我就想取个值,怎么这么难?”
问题的根源,是他没搞清楚get和[]的区别,以及它们各自应该在什么场景下用。
先看一个最直观的对比
我们把两种取值方式放在一起跑一遍:
config = {"name": "Alice", "age": 30}
print(config["name"]) # Alice
print(config.get("name")) # Alice
print(config.get("gender")) # None
print(config["gender"]) # KeyError
区别很清楚:
[]在键存在时正常返回,键不存在时直接抛KeyError,程序中断。
get在键存在时正常返回,键不存在时返回None,程序继续跑。
用一个生活场景来理解。你有一个储物柜,每个格子贴着标签。
[]的做法是:你直接伸手去摸“性别”那个格子。如果格子上贴着“性别”的标签,你拿到里面的东西。如果没有这个标签,你的手就撞在柜子上,当场“报警”。
get的做法是:你先问管理员“有没有‘性别’这个格子?”有就给你,没有就给你一个空盒子(None),你的手不会撞到柜子。
区别一目了然:一个会炸,一个不会炸。但不会炸不代表安全,因为空盒子可能在下游引发别的问题。
[] 的本质:断言“这个键一定存在”
很多人把config["key"]当成“取值”操作,但它其实隐含了一个断言:我确信这个键一定存在。
如果这个断言不成立,Python就抛KeyError。这不是bug,这是设计。它在告诉你:“你的假设错了,这个键根本不在字典里。”
这种“快速失败”在某些场景下是好事。比如你解析一个API返回的固定结构:
response = {"code": 200, "data": {"user_id": 123, "token": "abc"}}
user_id = response["data"]["user_id"]
token = response["data"]["token"]
如果API返回的数据结构变了,少了某个字段,你希望立刻知道,而不是悄悄拿到一个None然后在别的地方出错。KeyError会直接告诉你“字段没了”,定位问题很快。
所以[]适合你确定键存在的场景。它是一把锋利的刀,用对了干净利落,用错了直接见血。
get 的本质:我知道键可能不存在
get的签名是dict.get(key, default=None)。它接受一个键和一个可选的默认值。如果键存在,返回对应的值;如果键不存在,返回默认值(不传就是None)。
config = {"name": "Alice"}
print(config.get("name")) # Alice
print(config.get("age")) # None
print(config.get("age", 0)) # 0
print(config.get("age", "unknown")) # unknown
get不会抛异常,它总是能返回点什么。这看起来很安全,但问题恰恰出在“总是能返回点什么”上。
因为None是一个合法的值,你无法区分“键不存在所以返回了None”和“键存在但值就是None”。
config = {"nickname": None}
print(config.get("nickname")) # None
print(config.get("gender")) # None
两个get都返回None,但一个是“用户设置了昵称为空”,另一个是“用户根本没设置性别”。如果你不区分这两种情况,后续逻辑就可能出错。
坑一:get 返回 None 后继续调用方法
小赵踩的就是这个坑。
language = config.get("language")
if language.startswith("zh"):
load_chinese_font()
如果language不存在,get返回None,None.startswith直接报AttributeError。
正确的做法是给一个合理的默认值:
language = config.get("language", "en")
if language.startswith("zh"):
load_chinese_font()
这样即使键不存在,language也是"en",不会崩。
或者先判断:
language = config.get("language")
if language and language.startswith("zh"):
load_chinese_font()
if language既检查了None,也检查了空字符串。双重保险。
坑二:get 的默认值只在键不存在时生效
这是一个很微妙的点。get的默认值只在键不存在时生效,如果键存在但值是None,默认值不会生效。
config = {"timeout": None}
print(config.get("timeout", 30)) # None,不是 30
因为键"timeout"存在,get返回它的值None,默认值30被忽略。
如果你想要“值是None时也用默认值”,需要写成:
timeout = config.get("timeout") or 30
但or有个副作用:如果值是0、""、False这些“假值”,也会被替换成默认值。所以更严谨的写法是:
timeout = config.get("timeout")
if timeout is None:
timeout = 30
这个细节在配置读取场景中特别常见。用户可能显式把某个字段设成了None,你的默认值逻辑要能正确处理。
坑三:嵌套字典取值
get只能处理一层。如果字典嵌套很深,get会变得很啰嗦。
data = {"user": {"profile": {"name": "Alice"}}}
# 想取 name,但不确定每一层是否存在
name = data.get("user", {}).get("profile", {}).get("name")
这行代码能跑,但很难看。每一层都要给一个空字典作为默认值,否则中间某一层返回None,下一层.get就会报AttributeError。
如果层级更深,或者键名不固定,这种写法就崩了。可以考虑用collections.defaultdict或者第三方库glom,但大多数时候,写个辅助函数就够了:
def deep_get(d, keys, default=None):
for key in keys:
if isinstance(d, dict):
d = d.get(key)
else:
return default
if d is None:
return default
return d
name = deep_get(data, ["user", "profile", "name"], "unknown")
坑四:用 [] 取值但没处理 KeyError
反过来,有些人习惯了[],在不确定键是否存在时也直接用,结果程序时不时崩一下。
for user in users:
print(user["email"]) # 有些用户没有 email 字段
一旦某个用户没有email,整个循环就中断了。
这种场景应该用get:
for user in users:
email = user.get("email", "未填写")
print(email)
或者用in先判断:
for user in users:
if "email" in user:
print(user["email"])
else:
print("未填写")
in判断的好处是,你可以在两个分支里写不同的逻辑,而不是只拿到一个默认值。
那 setdefault 呢?
字典还有一个setdefault(key, default)方法,也常和get混淆。
config = {"name": "Alice"}
result = config.setdefault("age", 30)
print(result) # 30
print(config) # {'name': 'Alice', 'age': 30}
setdefault的行为是:如果键存在,返回对应的值,不修改字典;如果键不存在,把default插入字典,然后返回它。
和get的区别是:get不会修改字典,setdefault会。
config = {"name": "Alice"}
config.get("age", 30)
print(config) # {'name': 'Alice'},没变
config.setdefault("age", 30)
print(config) # {'name': 'Alice', 'age': 30},插入了
setdefault适合“取不到就设一个默认值并记住”的场景。比如统计词频:
counts = {}
for word in words:
counts.setdefault(word, 0)
counts[word] += 1
但这段代码其实可以用collections.Counter更简洁地实现。setdefault在现代Python里用得越来越少,因为大多数场景有更好的替代方案。
get 和 [] 的性能差异
有人可能会问:get和[]哪个快?
答案是:几乎一样快。两者都是哈希查找,时间复杂度都是O(1)。get多了一个“键不存在时返回默认值”的分支,但这个分支的开销可以忽略不计。
import timeit
d = {i: i for i in range(1000)}
t1 = timeit.timeit(lambda: d[500], number=1000000)
t2 = timeit.timeit(lambda: d.get(500), number=1000000)
print(f"[] 耗时: {t1:.4f}")
print(f"get 耗时: {t2:.4f}")
在我的机器上,两者都在0.03秒左右,差距在误差范围内。
所以选择get还是[],不应该基于性能,而应该基于语义:你确定键存在吗?如果确定,用[];如果不确定,用get。
什么时候用 [],什么时候用 get?
判断标准可以总结成一句话:你希望键不存在时发生什么?
如果你希望程序立刻报错,告诉你“假设错了”,用[]。这在解析固定结构的数据时很有用,能快速暴露问题。
如果你希望程序继续跑,用一个默认值兜底,用get。这在处理用户输入、可选配置、外部接口时很常见。
如果你需要区分“键不存在”和“键存在但值是None”,用in判断:
if "key" in d:
value = d["key"]
# 处理 value,可能是 None
else:
# 键不存在
如果你需要在键不存在时插入一个默认值,用setdefault。
一个常见的误区:用 get 代替所有 []
有些人被KeyError坑过之后,走向另一个极端:所有取值都用get,再也不敢用[]。
name = config.get("name")
age = config.get("age")
email = config.get("email")
这样写的问题在于:如果name是必需的,但配置里漏了,get返回None,程序带着一个None继续跑,可能在很远的地方才崩。错误信息指向的是崩溃点,而不是根源。
而如果用[],程序在取name的那一刻就报KeyError: 'name',你立刻知道是配置缺了这个字段。
快速失败比带病运行好。 在关键字段上,用[]让问题尽早暴露;在可选字段上,用get提供合理的默认值。
回到小赵的代码
小赵最终的代码是这样的:
config = {"theme": "dark", "font_size": 14}
theme = config["theme"] # 必需字段,用 []
font_size = config.get("font_size", 12) # 可选字段,用 get 带默认值
language = config.get("language", "en") # 可选字段,默认英文
if language.startswith("zh"):
load_chinese_font()
关键字段用[],缺失就报错,快速定位问题。可选字段用get,给一个合理的默认值,保证程序继续跑。
他在团队文档里写了一条:“[]是断言键存在,get是承认键可能不存在。想清楚你面对的是哪种情况,再决定用哪个。”
这个区分看起来简单,但真到写代码的时候,很多人还是会随手写get,然后在下游被None坑。或者随手写[],然后在生产环境被KeyError炸。
记住那个储物柜的比喻:[]是直接伸手去摸,摸不到就撞柜子;get是先问管理员,没有就给你个空盒子。你要做的,是在伸手之前想清楚:这个格子,我确定有吗?
确定,就用[]。不确定,就用get,并且想好默认值是什么。如果默认值None会在下游出问题,那就给一个真正安全的默认值,或者用in先判断。
字典取值不是难题,难的是想清楚“键不存在时该怎么办”。想清楚了,get和[]就不再是坑,而是两个各司其职的工具。