
咱们写 Python 代码时,总绕不开 “优化” 这事儿。不少人一听到 “优化” 就兴奋,拿着各种技巧一通改 —— 把 for 循环改成列表推导式,给变量加一堆奇奇怪怪的注解,结果呢?要么代码改完没快多少,反而变得像 “天书” 一样难维护;要么上线后突然爆内存,查了半天才发现是 “优化” 搞的鬼。
其实啊,优化的核心不是 “炫技”,而是 “看场景下菜”。不是所有代码都需要优化(比如只跑一次的脚本),也不是所有 “快” 的技巧都适合你(比如列表推导式虽快,但处理超大数据会炸内存)。今天这篇文章,就从实战出发,教你怎么科学优化 Python 代码,避开那些踩过的坑,让代码真的又快又稳。
在讲具体技巧前,必须先纠正一个误区:别一上来就优化,先找瓶颈!
你肯定遇到过这种情况:写了个程序,感觉运行慢,就盯着循环改了半天,结果整个程序只快了 0.01 秒。为啥?因为这个循环一天就跑一次,根本不是瓶颈 —— 真正耗时的是后面调用 API 的 10 秒等待。这就是 “过早优化”,白忙活一场。
那怎么找瓶颈?咱们不用瞎猜,用 Python 自带的性能分析工具就行,最常用的是cProfile(函数级分析)和line_profiler(行级分析)。
举个例子,比如你有个处理数据的脚本:
# test.py
import time
def load_data():
# 模拟加载数据(比如读文件)
time.sleep(2) # 这里是瓶颈
return [i for i in range(100000)]
def process_data(data):
# 模拟处理数据
result = []
for i in data:
result.append(i ** 2)
return result
def main():
data = load_data()
process_data(data)
if __name__ == "__main__":
main()用cProfile分析,只需要在命令行跑:
python -m cProfile -s cumulative test.py-s cumulative:按 “累计耗时” 排序,能直接看到哪个函数最耗时。运行结果里会有这样的关键信息:
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.000 0.000 2.051 2.051 test.py:1(<module>)
1 0.000 0.000 2.051 2.051 test.py:13(main)
1 0.000 0.000 2.001 2.001 test.py:4(load_data)
1 0.050 0.050 0.050 0.050 test.py:9(process_data)
1 2.001 2.001 2.001 2.001 {built-in method time.sleep}很明显,load_data里的sleep(模拟 IO)占了 97% 的时间,process_data只占 2%。这时候优化process_data意义不大,不如先优化load_data(比如用异步读文件)。
总结:优化前先做 “性能 profiling”,只优化占比超过 10% 的 “热点函数”,别在冷门代码上浪费时间。
提到 Python 优化,很多人第一反应是 “把 for 循环改成列表推导式”。但它俩到底差在哪?什么时候该用,什么时候不该用?咱们用实战数据说话。
咱们直接跑代码测试,对比 “for 循环 + append” 和 “列表推导式” 生成 100 万个数的平方:
import timeit
# 1. for循环+append
def for_loop_square(n):
lst = []
for i in range(n):
lst.append(i ** 2) # 每次循环都要调用append方法
return lst
# 2. 列表推导式
def list_comp_square(n):
return [i ** 2 for i in range(n)] # 底层直接构建列表,无append调用
# 测试配置:生成100万个数,每个函数跑5次取平均
n = 1_000_000
test_times = 5
# 计算耗时
for_time = timeit.timeit(lambda: for_loop_square(n), number=test_times)
comp_time = timeit.timeit(lambda: list_comp_square(n), number=test_times)
# 输出结果
print(f"测试次数:{test_times}次,每次生成{ n/10000 }万个数的平方")
print(f"for循环(append)总耗时:{for_time:.2f}秒")
print(f"列表推导式总耗时:{comp_time:.2f}秒")
print(f"列表推导式比for循环快:{(for_time - comp_time)/for_time * 100:.1f}%")我在 Python 3.11 上跑的结果是:
测试次数:5次,每次生成100万个数的平方
for循环(append)总耗时:0.32秒
列表推导式总耗时:0.21秒
列表推导式比for循环快:34.4%确实快!那为啥?咱们用dis模块看 “字节码”(Python 代码执行的中间步骤)就懂了。
字节码越短、调用的方法越少,执行越快。咱们用dis.dis()反编译两个函数:
import dis
print("=== for_loop_square 字节码 ===")
dis.dis(for_loop_square)
print("n=== list_comp_square 字节码 ===")
dis.dis(list_comp_square)关键差异看这里:
5 8 SETUP_LOOP 30 (to 40)
10 LOAD_GLOBAL 1 (range)
12 LOAD_FAST 0 (n)
14 CALL_FUNCTION 1
16 GET_ITER
>> 18 FOR_ITER 18 (to 38)
20 STORE_FAST 2 (i)
6 22 LOAD_FAST 1 (lst)
24 LOAD_METHOD 2 (append) # 每次循环都要加载append方法
26 LOAD_FAST 2 (i)
28 LOAD_CONST 2 (2)
30 BINARY_POWER
32 CALL_METHOD 1 # 每次循环都要调用append
34 POP_TOP
36 JUMP_ABSOLUTE 18每次循环都要做LOAD_METHOD(加载 append)和CALL_METHOD(调用 append),这两步很耗时。
9 0 LOAD_CONST 1 (<code object <listcomp> at 0x000001>)
2 LOAD_CONST 2 ('list_comp_square.<locals>.<listcomp>')
4 MAKE_FUNCTION 0
6 LOAD_GLOBAL 0 (range)
8 LOAD_FAST 0 (n)
10 CALL_FUNCTION 1
12 GET_ITER
14 CALL_FUNCTION 1 # 直接调用列表推导式的底层逻辑
16 RETURN_VALUE列表推导式是通过BUILD_LIST指令直接在底层构建列表,没有多次append调用,字节码步骤少很多 —— 这就是它快的核心原因。
列表推导式虽快,但不是万能的。咱们用表格总结一下适用场景:
对比维度 | for 循环 + append | 列表推导式 |
|---|---|---|
性能 | 较慢(多次 append 调用) | 较快(底层直接构建) |
可读性 | 逻辑复杂时更清晰(比如多分支、调试) | 简单逻辑清晰,复杂逻辑变 “天书” |
调试难度 | 方便(可在循环中加 print、断点) | 难(推导式是一行,无法中间打断) |
内存占用 | 和列表推导式一致(都生成完整列表) | 和 for 循环一致 |
适用场景 |
|
|
比如你要筛选 “能被 3 整除且平方大于 1000” 的数,还要打印中间结果 —— 用列表推导式会很丑,调试也难:
# 反面例子:列表推导式里写复杂逻辑,可读性差
result = [
i**2 for i in range(1000)
if i % 3 == 0
and (print(f"符合条件的i: {i}") or True) # 为了加print,不得不写or True(print返回None)
]换成 for 循环 + append,可读性直接拉满,还能方便调试:
# 正面例子:复杂逻辑用for循环
result = []
for i in range(1000):
if i % 3 == 0:
square = i ** 2
if square > 1000:
print(f"符合条件的i: {i}, 平方: {square}") # 调试方便
result.append(square)如果你的数据量特别大(比如 1 亿条),不管是 for 循环还是列表推导式,都会生成完整的列表,直接把内存撑爆。这时候该用生成器表达式(把[]换成())。
生成器不会一次性生成所有数据,而是 “用一个拿一个”(惰性计算),内存占用极低。咱们看对比:
import sys
# 1. 列表推导式:生成100万个数,占内存大
list_comp = [i**2 for i in range(1_000_000)]
print(f"列表推导式内存占用:{sys.getsizeof(list_comp) / 1024 / 1024:.2f} MB") # 约7.63 MB
# 2. 生成器表达式:同样100万个数,内存占用几乎不变
gen_exp = (i**2 for i in range(1_000_000))
print(f"生成器表达式内存占用:{sys.getsizeof(gen_exp)} 字节") # 约112字节(固定大小)注意:生成器只能遍历一次,遍历完就空了。如果需要多次使用,还是得用列表。
优化完代码,怎么验证 “真的变快了”?很多人用time.time()测两次,就说 “快了 50%”—— 但这样的结果很可能是错的!因为 Python 的运行环境有很多干扰因素,比如垃圾回收(GC)、冷启动、缓存等。
咱们来逐个解决这些坑,教你做 “靠谱的基准测试”。
比如你刚启动 Python,第一次跑代码会加载模块、初始化变量,耗时比后面几次长。如果只测一次,结果肯定不准。
解决方法:先 “热身”,再测试
先跑几次测试函数,让 Python 完成初始化,再正式计时:
import timeit
def test_func():
return [i**2 for i in range(100000)]
# 热身:先跑3次,排除冷启动影响
for _ in range(3):
test_func()
# 正式测试:跑10次取平均
total_time = timeit.timeit(test_func, number=10)
avg_time = total_time / 10
print(f"平均每次耗时:{avg_time:.4f}秒")Python 的垃圾回收器会在后台自动回收没用的内存,偶尔一次回收会占用几十毫秒 —— 如果测试时刚好遇到 GC,结果就会突然变大,波动很大。
解决方法:测试期间禁用 GC,测试完恢复
用gc模块禁用 GC,避免干扰;测试结束后一定要恢复,不然会导致内存泄漏。
import timeit
import gc
def test_func():
return [i**2 for i in range(100000)]
# 禁用GC
gc.disable()
try:
# 热身+测试
for _ in range(3):
test_func()
total_time = timeit.timeit(test_func, number=10)
print(f"总耗时:{total_time:.2f}秒")
finally:
# 无论如何都要恢复GC
gc.enable()timeit是 Python 官方推荐的基准测试工具,但很多人用的时候,把 “准备数据” 的时间也算进去了,导致结果不准。
比如你要测试 “处理数据” 的耗时,却把 “加载数据” 的时间也加进去了:
# 错误用法:stmt里包含了准备数据(range(100000))
wrong_time = timeit.timeit(stmt="[i**2 for i in range(100000)]", number=10)
print(f"错误耗时:{wrong_time:.2f}秒") # 包含了range生成数据的时间解决方法:用setup参数放准备代码
setup里的代码只会执行一次,专门用来做准备(比如生成数据、导入模块),不参与计时;stmt里只放要测试的核心代码。
# 正确用法:setup放准备代码,stmt放核心代码
setup = "data = range(100000)" # 准备数据,只执行一次
stmt = "[i**2 for i in data]" # 核心代码,多次执行
right_time = timeit.timeit(stmt=stmt, setup=setup, number=10)
print(f"正确耗时:{right_time:.2f}秒") # 只算处理数据的时间把上面的避坑点整合起来,给大家一个通用模板:
import timeit
import gc
def benchmark(
stmt, # 要测试的核心代码(字符串或函数)
setup="pass",# 准备代码(字符串)
number=100, # 测试次数
warmup=3 # 热身次数
):
# 1. 禁用GC
gc.disable()
try:
# 2. 热身
if callable(stmt):
# 如果stmt是函数,直接调用热身
for _ in range(warmup):
stmt()
else:
# 如果stmt是字符串,用exec执行热身
exec(setup)
for _ in range(warmup):
exec(stmt)
# 3. 正式测试
total_time = timeit.timeit(stmt=stmt, setup=setup, number=number)
avg_time = total_time / number
print(f"测试次数:{number}次,平均耗时:{avg_time:.6f}秒")
return avg_time
finally:
# 4. 恢复GC
gc.enable()
# 用法示例:测试列表推导式vsfor循环
if __name__ == "__main__":
print("=== 测试列表推导式 ===")
benchmark(
stmt="[i**2 for i in data]",
setup="data = range(100000)",
number=20
)
print("n=== 测试for循环 ===")
benchmark(
stmt="""
lst = []
for i in data:
lst.append(i**2)
""",
setup="data = range(100000)",
number=20
)你可能遇到过:同样的代码,在同事电脑上跑很快,在你电脑上却很慢 —— 很可能是 Python 版本不一样!从 3.7 到 3.11,Python 的性能提升非常大,尤其是 3.11,官方说 “整体性能提升 60%”。
咱们用实际数据对比不同版本的性能(测试代码:生成 100 万个数的平方,跑 10 次):
Python 版本 | 总耗时(秒) | 平均每次耗时(秒) | 相对 3.8 提升比例 |
|---|---|---|---|
3.8 | 2.85 | 0.285 | 0%(基准) |
3.10 | 2.12 | 0.212 | 25.6% |
3.11 | 1.78 | 0.178 | 37.5% |
可以看到,3.11 比 3.8 快了近 40%!这意味着:有些代码不用改,升级 Python 版本就能直接变快。
str.split()、str.join()等常用方法性能提升 20%-50%。如果你追求极致性能,可以试试PyPy(一个 Python 解释器,兼容大部分 CPython 代码)。它用了 JIT(即时编译)技术,对循环密集型代码提升极大 —— 比如同样的循环代码,PyPy 可能比 CPython 快 10 倍。
但 PyPy 有两个坑要注意:
numpy的部分功能、requests的某些特性可能用不了。答:因为列表推导式和 for 循环一样,都会生成完整的列表。如果数据量太大(比如 1 亿条),列表会占用大量内存。解决方法:用生成器表达式(把[]换成()),或者分批次处理数据。
答:因为time.time()只能测 “墙钟时间”,会受到其他程序的干扰(比如电脑同时在下载文件、杀毒)。正确的做法是用timeit,并且禁用 GC、做好热身,多次运行取平均。
答:可能是 GC 的问题 —— 优化后的代码生成垃圾对象更快(比如频繁创建小列表),导致 GC 更频繁地触发,造成卡顿。解决方法:1. 减少临时对象的创建(比如复用列表);2. 用gc.collect()在合适的时机手动触发 GC(比如在两次请求之间)。
答:因为不同版本的 Python 优化点不一样。比如 3.11 对循环的优化,在 3.8 里没有;有些代码在 3.11 里快,在 3.8 里可能和普通代码没区别。解决方法:优化时要考虑 “目标环境的 Python 版本”,最好在目标版本上做测试。
答:主要有两个原因:
append方法并调用(LOAD_METHOD和CALL_METHOD),而列表推导式是通过底层的BUILD_LIST指令直接构建列表,减少了方法调用的开销。lst),解释器能做更多优化。可以补充:但列表推导式不是万能的,逻辑复杂或需要调试时,用 for 循环更合适;数据量大时,用生成器表达式更省内存。
答:要避开 3 个坑,做好 3 步:
gc.disable()禁用 GC,测试完恢复。timeit的setup参数放准备代码,stmt放核心代码,确保只计时核心逻辑。答:官方说 3.11 比 3.10 快 60%,主要提升点:
str.split()、str.join()等常用方法用更高效的算法实现,性能提升 20%-50%。dict的查找速度提升、异常处理开销降低等。答:我的思路是 “先定位瓶颈,再针对性优化,最后验证效果”,分 3 步:
cProfile(函数级)或line_profiler(行级)分析,找出耗时占比高的热点函数,不优化冷门代码。timeit+ 禁用 GC + 热身)验证优化效果,同时兼顾代码可读性(别为了快写 “天书”)。原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。