前言
先把话说在前面:不存在"几行代码让所有 Python 函数都稳定快 30 倍"这回事。"30 倍"是一个具体数字,而实际提速取决于函数在做什么、瓶颈在哪、跑在哪个 解释器上,绝不可能有一个放之四海而皆准的倍数。本文不会给出任何"提速 N 倍"的 承诺,而是讲清楚哪些做法通常更快、为什么更快,并给出一段你可以自己运行、 亲眼测出结果的基准代码。
另一个要破除的误解是"优化就是到处加小技巧"。真正的顺序应该是:先测量、找到 瓶颈、再优化。如果函数的时间花在网络请求或磁盘 I/O 上,把循环里的属性访问 抠得再细也几乎没用;反过来,一个纯计算的递归函数如果重复算同一批子问题,一个 缓存装饰器就能改变数量级。
最后提醒:本文提到的很多"更快"都属于CPython 的实现细节(比如局部变量比 全局变量查找快),换解释器、换版本甚至换成 3.13 起实验性的自由线程构建,结论 都可能不同。任何优化,都要在你自己的环境里量一遍。
一、为什么不能承诺"30 倍"
优化的收益来自"消除了多少无效工作",而不是来自"加了几行魔法"。可以分成几类:
- 减少重复计算:把已经算过的结果缓存下来,重复部分越多,收益越大。
- 降低查找开销:变量、属性、方法的查找有成本,把高频查找提到循环外。
- 选对算法 / 数据结构:这一步往往才是数量级差别,但和"几行代码"无关。
- 减少内存拷贝:避免不必要地物化大列表。
其中"几行代码"能碰到的,主要是前两类。后面两类更多是设计问题。
二、缓存重复计算:functools.lru_cache 与 cache
对纯函数(相同输入必得相同输出、无副作用)来说,把结果缓存起来是最直接的 加速手段。标准库提供了两个装饰器:
functools.lru_cache(maxsize=128, typed=False):带容量上限的最近最少使用
缓存,超出上限时淘汰最久未用的条目。
functools.cache(Python 3.9 起):等价于lru_cache(maxsize=None),
即不设上限。
# 适用于 Python 3.9+
from functools import cache
@cache
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
print(fib(30)) # 832040,且大大减少了重复递归lru_cache在 Python 3.8 及更早版本也能用,只是没有functools.cache这个 简写,写成@lru_cache(maxsize=None)即可。
三、把全局查找变成局部查找
在 CPython 里,函数内访问局部变量用的是快速路径(字节码LOAD_FAST), 访问全局变量要走字典查找(LOAD_GLOBAL)。所以把高频使用的全局名字在 函数开头绑定成局部名字,通常能省下一点查找开销。
# 适用于 Python 3.8+
from math import sqrt
def hypot_many(pairs):
local_sqrt = sqrt # 绑定成局部名字
return [local_sqrt(x * x + y * y) for x, y in pairs]同理,把"每次都算一遍"的表达式提到循环外:
# 适用于 Python 3.8+
def scale(values, factor, offset):
# 把不变的表达式放到循环外
shift = factor * offset
return [v * factor + shift for v in values]再次强调:这类改动是"通常略快",不是"一定快 30 倍",实际差别取决于循环 体有多重——循环体越轻,查找开销占比越大。
四、避免在热循环里重复做属性查找
每次obj.attr都要查一次属性。在很热的循环里,把方法或属性提到循环外能少做 很多次查找:
# 适用于 Python 3.8+
def double_all(items):
result = []
append = result.append # 绑定一次,循环里反复用
for x in items:
append(x * 2)
return result但要清醒:如果循环体里有真正的重活(比如字符串处理、网络 I/O),这点查找 开销可以忽略。先测量,再决定值不值得牺牲可读性。
五、选对写法:能少算就少算
有些"提速"其实是沟通"哪些工作可以不做":
- 用生成器表达式代替大列表,避免一次性物化,降低内存峰值(但注意生成器
只能遍历一次)。
- 用
str.join(...)拼接大量字符串,而不是在循环里反复s += piece。 - 用字典 / 集合做成员判断,而不是在列表里线性扫描。
- 用内置函数(
sum、min、max、any、all)让它走 C 实现,而不是手写
Python 循环。
# 适用于 Python 3.8+
words = ["alpha", "beta", "gamma"]
# 推荐:join 一次拼好
line = ",".join(words)
# 推荐:用集合做成员判断
allowed = {"yes", "no", "maybe"}
print("yes" in allowed) # True,通常比在列表里扫更快六、自己动手量:timeit
timeit.timeit(stmt='pass', setup='pass', timer=..., number=1000000, globals=None)和timeit.repeat(stmt='pass', setup='pass', timer=..., repeat=5, number=1000000, globals=None)是做微基准的标准工具(timer参数不传就用平台默认的最佳计时器)。下面这段 可以直接复制运行:
# 适用于 Python 3.8+
import timeit
setup_plain = """
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
"""
setup_cached = """
from functools import lru_cache
@lru_cache(maxsize=None)
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
"""
plain = min(timeit.repeat("fib(25)", setup=setup_plain, repeat=5, number=1))
cached = min(timeit.repeat("fib(25)", setup=setup_cached, repeat=5, number=1))
print(f"普通递归: {plain:.6f} 秒")
print(f"加缓存 : {cached:.6f} 秒")说明:repeat返回每轮的耗时列表,取min是为了避开偶尔被系统调度干扰的 那几次。注意缓存版本在第一次跑完之后缓存就热了,所以后续几轮量的是"命中 缓存"的速度,这正是我们想展示的差异。
命令行里也能量:
python -m timeit -s "from math import sqrt" "sqrt(2)"七、常见优化手段对照
| 手段 | 收益来源 | 适用前提 |
|---|
functools.cache/lru_cache | 去掉重复计算 | 函数是纯函数、参数可哈希 |
| 局部绑定全局名 | 少走一次字典查找 | 名字在热循环里高频使用 |
| 循环外提表达式 | 不重复计算不变量 | 表达式确实与循环变量无关 |
| 局部绑定方法 | 少做属性查找 | 循环很热、循环体很轻 |
| 用内置函数 | 走 C 实现 | 有对应的内置函数 |
| 选对数据结构 | 改变复杂度 | 常出现在成员判断、查找环节 |
常见坑点
- 给不可哈希的参数加缓存
``python from functools import lru_cache # ❌ 列表不可哈希,调用时抛 TypeError @lru_cache(maxsize=None) def f(xs): return sum(xs) # ✅ 传不可变类型 @lru_cache(maxsize=None) def g(xs): return sum(xs) g((1, 2, 3))``
- 给有副作用的函数加缓存
``python from functools import lru_cache # ❌ 第二次调用不会真的写文件,副作用被跳过 @lru_cache(maxsize=None) def save(path, data): ... # ✅ 缓存只用在纯函数上``
- 指望微优化解决 I/O 瓶颈
``python # ❌ 时间花在网络等待上,改循环写法没用 for url in urls: data = fetch(url) # 真正的瓶颈在这里 # ✅ 先定位瓶颈,再决定优化手段``
- 过早优化
``python # ❌ 还没测就先把代码改得又长又难读 # ✅ 先测量,只优化热点``
- timeit 用法错误导致结果不可信
``python # ❌ 把 setup 里的准备工作算进了计时 # ✅ 准备工作放 setup,被计时的只放 stmt`另外,单次运行波动大,要用repeat多跑几轮再取min`。
- 误用 lru_cache 的默认容量
``python from functools import lru_cache # ❌ 参数取值很多时,默认 maxsize=128 会频繁淘汰 @lru_cache def f(n): ... # ✅ 要么显式设容量,要么明确要无上限 @lru_cache(maxsize=None) def g(n): ...``
- 把"通常更快"当成"必然更快"
``python # ❌ 不测就断言某写法一定快 # ✅ 在自己的数据、自己的版本上量一遍,再下结论``
总结
| 思路 | 代表做法 |
|---|
| 少算 | functools.cache/lru_cache |
| 少查 | 局部绑定、循环外提表达式 |
| 少拷 | 生成器、join、内置函数 |
| 选对 | 字典 / 集合代替线性扫描 |
| 先量 | timeit测,再决定改不改 |
"几行代码快 30 倍"更像是一句吸引眼球的宣传语,而不是可以复现的承诺。真正 可靠的做法是:用timeit找到瓶颈,针对瓶颈选对手段,然后重新测一遍确认 有效。缓存和局部绑定能帮上忙,但它们降的是常数与重复开销,改变不了算法本身 的复杂度——那才是数量级差别真正来源的地方。