「Python 进阶之路」系列 Day25
写在前面
模块五(内存管理与性能)到今天收官,把三个流传很广、但很少有人真正实测过的性能话题一次讲清楚:字符串+=拼接优化的真实边界在哪、"全局变量转局部变量更快"这条老经验现在还成不成立、以及一个看起来无关紧要的方括号,为什么在某些场景下能造成上万倍的性能差距。
一、字符串拼接:+= 优化的真实前提
Day24 发现现代 CPython 对+=拼接字符串做了原地优化,实测差距没有传说中"O(n²)"那么悬殊。今天把这个优化的真实前提验证清楚:只有当被拼接的字符串引用计数恰好是 1 时,才能走原地扩容这条快速路径;一旦这个字符串还被别的地方引用着,优化立刻失效,退化回每次都创建新字符串的慢速路径。
defconcat_normal(n):s=""foriinrange(n):s+=str(i)# s 全程只有一个引用,能走原地优化returnsdefconcat_with_extra_ref(n):s=""foriinrange(n):keep_ref=s# 故意多留一个引用,破坏refcount==1的条件s+=str(i)returns# 实测(n=300000):# 正常+=拼接: 0.063s# 多一个引用后+=拼接: 9.748s ← 慢了 155 倍!这个实测结果说明:+=的原地优化是真实存在的,但它是一个依赖运行时内部状态、代码里完全看不出来的隐式优化——你没法从源码字面上判断某次+=到底走了快路径还是慢路径,只要这个字符串在拼接过程中被额外引用了一次(哪怕只是调试时顺手加了一行print(s)或者存进了另一个变量),性能就可能断崖式下跌。更稳妥的做法始终是:需要拼接大量字符串时,把片段收集进一个列表,最后用"".join(list)一次性拼接——join的行为是可预测的,不依赖任何"隐藏优化是否生效"这种脆弱的前提。
二、全局变量查找:曾经的经验法则,现在还成立吗
LEGB 规则(Day04 讲过)里,局部变量和全局变量的读取方式在字节码层面是不同的:
g=10defuse_global():returng+gdefuse_local():local_g=greturnlocal_g+local_g用dis模块看字节码:use_global用的是LOAD_GLOBAL,use_local用的是LOAD_FAST——LOAD_FAST本质是按数组下标直接取值,LOAD_GLOBAL需要去模块的命名空间字典里查找,理论上后者更慢。老版本 Python 里,"把频繁访问的全局变量/模块属性提前赋值成局部变量"是一条广为流传的性能优化技巧。
实测这条经验法则在当前版本还成不成立:
defaccess_global(n):total=0for_inrange(n):total+=greturntotaldefaccess_local(n):local_g=g total=0for_inrange(n):total+=local_greturntotal# 实测(n=5000000):# 循环里直接访问全局变量: 0.282s# 先转成局部变量再访问: 0.278s# 局部变量只快了 1.01 倍 —— 几乎没有差别!为什么差距几乎消失了:用dis.dis(use_global, adaptive=True)能看到,Python 3.11+ 的自适应解释器(specializing adaptive interpreter)会把反复执行的LOAD_GLOBAL自动"特化"成LOAD_GLOBAL_MODULE这种带内联缓存的版本,跑过几次之后基本不用再真的去查字典,开销被优化掉了大半。“把全局变量转成局部变量能提速"这条经验法则,在旧版本 Python 里是成立的,但在启用了自适应解释器的现代 Python 上,实测已经测不出有意义的差异了——这类性能经验法则有明确的"版本保质期”,写文章、做优化建议时最好标注清楚是在哪个版本上验证的,不能拿旧经验不加验证地套用到新版本上。
三、列表 vs 生成器:短路场景下的巨大差异
Day09 从内存和整体遍历速度的角度对比过列表推导式和生成器表达式,今天补一个新的、影响可能更大的角度:配合any()/all()这类"一旦满足条件就能提前结束"的短路函数时,两者的差异会被急剧放大。
call_count_list=0call_count_gen=0defcheck_list(x):globalcall_count_list call_count_list+=1returnx==3defcheck_gen(x):globalcall_count_gen call_count_gen+=1returnx==3data=list(range(1000))result1=any([check_list(x)forxindata])# 列表推导式print(call_count_list)# 1000 —— 全部1000个元素都被判断了一遍result2=any(check_gen(x)forxindata)# 生成器表达式print(call_count_gen)# 4 —— 只判断到第4个就命中True,提前停止了!原因:any([check_list(x) for x in data])里,方括号[]会先把列表推导式完整跑完,生成一个包含 1000 个结果的列表,any()才开始遍历这个现成的列表;而any(check_gen(x) for x in data)里,生成器表达式是惰性的(Day08 讲过),any()每次问它要一个值就算一个,一旦拿到True立刻停止向后请求,后面的元素根本不会被求值。
实测一个 100 万元素的数据集,用生成器表达式配合any()找到第一个满足条件的元素:
defwith_list():returnany([x==3forxinrange(1_000_000)])defwith_gen():returnany(x==3forxinrange(1_000_000))# 实测(10次调用总耗时):# 列表推导式+any(): 0.1290s# 生成器表达式+any(): 0.0000s ← 快了 15000+ 倍结论很明确:只要是"找第一个满足条件的元素"这种短路场景(any、all、或者手写的for...break),永远应该用生成器表达式,绝对不要多此一举地先套一层[]变成列表推导式——这个方括号看起来只是语法上的一个小差异,实际代价可能是成千上万倍的性能差距。
四、面试追问
Q1:字符串+=拼接的原地优化,生效的前提是什么?
被拼接字符串的引用计数恰好为 1 时才能触发原地扩容;只要这个字符串同时被别的变量或容器引用着,优化立刻失效,退化成每次创建新字符串的慢速路径,实测能相差上百倍。由于这个前提在源码层面完全不可见,更稳妥的做法是需要拼接大量字符串时用列表收集再"".join(),行为可预测、不依赖隐藏优化。
Q2:"把全局变量转成局部变量访问更快"这条经验法则现在还成立吗?
理论上局部变量用LOAD_FAST(数组下标访问)比全局变量用LOAD_GLOBAL(字典查找)更快,但 Python 3.11+ 引入的自适应解释器会把反复执行的LOAD_GLOBAL自动特化成带内联缓存的版本,实测两者速度已经几乎没有差异。这条经验法则在旧版本 Python 上是成立的,但在现代 Python 上已经过时,不应该再当作通用优化建议来套用。
Q3:列表推导式和生成器表达式配合any/all使用,有什么关键区别?
列表推导式会先把所有元素的判断结果完整计算完、生成一个列表,any/all再去遍历这个已经生成好的列表;生成器表达式是惰性求值的,any/all一边向它索要值一边判断,一旦满足短路条件(比如any遇到第一个True)就立刻停止,不会继续计算剩余的元素。
Q4:为什么短路场景下生成器表达式能快出几个数量级?
因为短路函数本来只需要计算到满足条件为止,用生成器表达式时确实只会按需计算这么多次;用列表推导式则会不管三七二十一把全部元素都计算一遍再交给any/all,白白浪费了短路之后本不需要的那部分计算量,数据量越大、命中位置越靠前,浪费的比例就越夸张,实测中 100 万元素的场景能差出 15000 倍以上。
Q5:这几个性能经验法则给我们的共同启示是什么?
性能结论不能只靠背书或者道听途说,一定要实测验证;而且很多结论是有"版本保质期"的(比如全局变量查找的优化在 3.11+ 已经基本失效),随着解释器本身不断进步,旧版本上成立的经验放到新版本可能已经不再成立,写代码或给性能优化建议之前,最好实际跑一下 benchmark 确认,而不是凭印象或者过时的教程下结论。
下一篇预告
模块五(内存管理与性能)到这里全部完成。Day26 开始进入模块六——常用数据结构与标准库进阶,第一篇讲dict的底层实现:哈希表的原理,以及 Python 3.7 之后dict为什么变得有序了。