Python functools.lru_cache 实用指南:用法、原理与 5 个常见陷阱
functools.lru_cache 是 Python 标准库里性价比最高的性能优化工具之一:加一行装饰器,就能把重复计算变成字典查找。但它用错了也会带来内存泄漏、结果错乱等隐蔽问题。本文用可运行的例子讲清楚它的用法和常见坑。
一、基本用法
from functools import lru_cache
import time
@lru_cache(maxsize=None)
def fib(n: int) -> int:
return n if n < 2 else fib(n - 1) + fib(n - 2)
start = time.perf_counter()
print(fib(100)) # 354224848179261915075
print(f"{time.perf_counter() - start:.4f}s")
print(fib.cache_info()) # CacheInfo(hits=98, misses=101, maxsize=None, currsize=101)
不加缓存时,fib(100) 的递归调用次数是天文数字;加上之后每个 n 只算一次。
maxsize=None:不限制容量,等价于 Python 3.9+ 的functools.cache。maxsize=128(默认):超过容量时淘汰"最近最少使用"(LRU)的条目。cache_info()查看命中率,cache_clear()清空缓存。
二、原理一句话
装饰器把函数的位置参数和关键字参数拼成一个键,存进内部字典;maxsize 有限时再用双向链表维护使用顺序。所以参数必须可哈希,并且函数应当是纯函数——相同输入永远得到相同输出。
三、5 个常见陷阱
陷阱 1:参数不可哈希
@lru_cache
def total(items):
return sum(items)
try:
total([1, 2, 3])
except TypeError as e:
print(e) # unhashable type: 'list'
print(total((1, 2, 3))) # 6,改用元组即可
调用方传 list、dict 时会直接报错。解决办法是在外层把参数转换成 tuple 或 frozenset 再调用缓存函数。
陷阱 2:返回可变对象被调用方修改
@lru_cache
def default_config():
return {"timeout": 30}
cfg = default_config()
cfg["timeout"] = 1 # 改的是缓存里的同一个对象!
print(default_config()) # {'timeout': 1}
缓存返回的是同一个对象。返回 dict/list 时,要么返回不可变类型(如 types.MappingProxyType、tuple),要么让调用方自己 copy()。
陷阱 3:装饰实例方法导致内存泄漏
class Report:
@lru_cache(maxsize=None)
def render(self, page):
return f"page {page}"
self 会成为缓存键的一部分,缓存是类级别共享的,于是每个 Report 实例都被缓存强引用,永远不会被回收。推荐做法:
from functools import cached_property
class Report:
def __init__(self, pages):
self.pages = pages
@cached_property
def summary(self): # 结果存在实例自己的 __dict__ 里
print("computing...")
return f"{len(self.pages)} pages"
r = Report([1, 2, 3])
print(r.summary) # computing... 3 pages
print(r.summary) # 3 pages(不再计算)
无参数的属性用 cached_property;需要参数时,可以在 __init__ 里为每个实例单独包装:self.render = lru_cache(maxsize=256)(self._render),缓存随实例一起释放。
陷阱 4:缓存依赖外部状态的函数
RATE = 7.0
@lru_cache
def to_cny(usd):
return usd * RATE
print(to_cny(10)) # 70.0
RATE = 7.2
print(to_cny(10)) # 仍然是 70.0
函数读了全局变量、文件、数据库或当前时间,缓存就会返回过期结果。要么把这些依赖变成参数,要么在依赖变化时调用 to_cny.cache_clear()。
陷阱 5:1 和 1.0 可能被当成同一个键
@lru_cache
def add(a, b):
return a + b
print(add(1, 2)) # 3
print(add(1.0, 2)) # 3 ——命中了上一次的缓存,而不是 3.0
因为 (1, 2) == (1.0, 2) 且哈希相同。(单个 int/str 参数时 CPython 走了快速路径,未必复现,但不要依赖这个实现细节。)如果类型会影响结果,使用 @lru_cache(typed=True),它会把参数类型也纳入键中,此时 add(1.0, 2) 返回 3.0。
四、什么时候用、怎么选 maxsize
适合:纯计算(递归、动态规划、解析配置)、重复查询的慢接口(配合定期 cache_clear())。
不适合:有副作用的函数、依赖实时数据的函数、参数几乎不重复的函数(只会白白占内存)。
maxsize 建议:参数空间有限、可控时用 None;参数空间很大时设一个上限(如 1024),然后观察 cache_info() 的命中率再调整。
五、小结
- 一行
@lru_cache就能消除重复计算,用cache_info()验证效果; - 参数要可哈希,函数要是纯函数;
- 返回可变对象要小心被修改;
- 实例方法优先用
cached_property或按实例包装,避免内存泄漏; - 类型敏感时加
typed=True。
以上代码均可在 Python 3.9+ 直接运行,欢迎复制试一试。
- 点赞
- 收藏
- 关注作者
评论(0)