昨晚我在工位上加班嘛,外面奶茶都打烊了,我还在盯着一段「看起来没啥」的 Python 代码发呆……就是那个字典遍历,真的,平时写着写着就顺手了,但线上一慢起来,你就会开始怀疑人生:到底是数据库慢,还是我这边 for 循环写得像在刮墙皮。
事情是这样的,我们有个小服务要把一堆配置和指标拼起来,最后吐给下游。数据结构基本就是 dict 套 dict,然后我同事写了个很“朴素”的遍历:
cfg = {"timeout": 3, "retry": 2, "region": "cn"}
for k in cfg:
v = cfg[k] # 这里每次又查一遍
# ... 做点处理
你别笑,这种代码我也写过啊。问题是当 cfg 变成几万项、而且循环里还夹着一些字符串处理的时候,它就开始“肉眼可见”的慢。后来我就改成 items,一下子就顺了很多(原理也不神秘,就是少一次哈希查找,别把字典当数组用就行):
for k, v in cfg.items():
# k 和 v 一次性拿到
pass
你们要是只需要值,其实 values() 也行,省得你拿着 key 还得回头查:
for v in cfg.values():
pass
哦对,有人会问“那 keys() 呢”,keys() 也能遍历,但其实 for k in d: 默认就是 keys 的视图了,别多写一个函数调用把自己感动坏了:
d = {"a": 1, "b": 2}
for k in d: # 就是 keys
pass
然后我当时遇到的第二个坑更离谱:我想边遍历边删除一些脏字段。结果直接炸了,抛异常那种。因为 dict 在迭代的时候你改它的大小(增删键),Python 会很严肃地说“不行”。我当时还以为是我电脑坏了,后来想想就是我蠢。
错误示范(别学):
d = {"ok": 1, "bad": 0, "bad2": 0}
for k, v in d.items():
if v == 0:
del d[k] # 会出事:迭代期间修改 dict
正确一点的写法,一般就两种:要么先把要删的 key 收集起来,再删;要么用 list(...) 做一个快照(注意快照会占内存,别瞎用在超大 dict 上)。
d = {"ok": 1, "bad": 0, "bad2": 0}
to_del = [k for k, v in d.items() if v == 0]
for k in to_del:
d.pop(k, None)
或者快照:
for k, v in list(d.items()):
if v == 0:
d.pop(k, None)
说到 pop,我顺便插一句啊,很多人删 key 喜欢 del d[k],但线上数据有时候会缺字段,del 直接 KeyError 把你送走。pop(k, None) 就稳一点,删不到也不报错,跟你的人生一样,能忍就忍。
再来一个很真实的小场景:你要做计数统计,比如日志里各种状态码出现次数。很多人会写 if 判断:
codes = [200, 200, 404, 500, 404, 200]
cnt = {}
for c in codes:
if c in cnt:
cnt[c] += 1
else:
cnt[c] = 1
这当然没错,但写多了你会烦,而且还慢一点点点(真的就一点点点,但写起来也啰嗦)。我现在更习惯用 get:
cnt = {}
for c in codes:
cnt[c] = cnt.get(c, 0) + 1
如果你还要顺手塞默认结构,比如分组(按用户分日志),setdefault 也能顶一下,不过它会“无脑创建”默认值这事儿,有时候你自己得留点心:
logs = [
{"user": "u1", "msg": "hi"},
{"user": "u2", "msg": "yo"},
{"user": "u1", "msg": "again"},
]
group = {}
for row in logs:
user = row["user"]
group.setdefault(user, []).append(row["msg"])
# group: {"u1": ["hi", "again"], "u2": ["yo"]}
我有一次就踩过 setdefault 的小坑:默认值如果是个可变对象,你又在别的地方复用同一个对象引用,会搞出很魔幻的“串台”。所以你要是写得复杂了,我更建议你显式一点,别把未来的自己坑了。
然后还有个很常见但又很容易忽略的“效率细节”:如果你在循环里频繁用 d.get、d.items,可以先把方法绑定到局部变量(Python 局部变量访问更快),在热点循环里确实有用。别指望它让你从乌龟变火箭,但能少挨点骂:
d = {str(i): i for i in range(100000)}
get = d.get
total = 0
for i in range(200000):
total += get(str(i), 0)
说白了就是:少做重复查找,少做属性解析。你要是循环只跑 100 次,这种优化纯属自嗨;但如果你是在处理大 dict 或者高频请求,这点自嗨能换来 CPU 真实降温。
再聊个你们肯定也干过的:遍历时想要序号。很多人会自己搞个 i += 1,我也干过…后来我就直接 enumerate,少点手滑:
d = {"a": 10, "b": 20, "c": 30}
for idx, (k, v) in enumerate(d.items(), start=1):
# idx 从 1 开始
print(idx, k, v)
哦对,字典现在是有顺序的(按插入顺序),所以 items() 的遍历顺序一般是稳定的。你要是做输出、做报表,这个体验就很好。但你要是依赖这个顺序做业务关键逻辑……怎么说呢,也不是不行,就是你得清楚你在赌什么,别哪天换了数据构建方式顺序变了你还嘴硬。
还有个我经常用的技巧:你只想遍历一部分 key,比如以某个前缀开头的配置项。别先把 keys() 转 list 再过滤,直接在 items 上过滤就完了:
cfg = {"db.host": "127.0.0.1", "db.port": 5432, "cache.ttl": 60}
db_cfg = {k: v for k, v in cfg.items() if k.startswith("db.")}
这个写法看着“挺优雅”,但你别把 comprehension 当银弹,它就是语法糖。糖吃多了也齁,尤其是你在 comprehension 里写一堆函数调用,那就不是糖了,是锅。
最后我给你们放一段我昨天改完的“真实一点”的代码片段,模拟我们那种把事件字典转成统计输出的活儿。你看着会发现:遍历 dict 真不难,难的是你别在循环里干蠢事。
def build_report(events):
# events: list[dict] 例如 {"type": "click", "user": "u1", "cost_ms": 12}
by_type = {}
total_cost = {}
for e in events:
t = e.get("type", "unknown")
u = e.get("user", "anon")
cost = e.get("cost_ms", 0)
by_type.setdefault(t, {}).setdefault(u, 0)
by_type[t][u] += 1
total_cost[t] = total_cost.get(t, 0) + cost
# 生成报告时,用 items 一次拿 key/value,别再回去查
lines = []
for t, users in by_type.items():
cnt = sum(users.values())
avg = (total_cost.get(t, 0) / cnt) if cnt else 0
# 排一下前几名用户(这里 users.items() 就很舒服)
top_users = sorted(users.items(), key=lambda kv: kv[1], reverse=True)[:3]
top_txt = ",".join(f"{u}:{c}" for u, c in top_users)
lines.append(f"type={t} cnt={cnt} avg_cost={avg:.1f} top={top_txt}")
return lines
反正吧,我现在写 dict 遍历,脑子里就三个字:别回头。你都拿到 items 了,就别再 d[k] 回头掏一次;你要删东西就别边走边拆路;你要默认值就 get / setdefault 选一个顺手的,别 if-else 写成祖传腌菜。
行了我先不说了,等会儿还得回消息……啊对了,有人刚在群里问“那 Counter 不更快吗”,嗯,确实,能用就用,但你先把 dict 遍历这些基础写顺了,不然你用啥库都一样会写慢,真的。