← 回到手记
代码

lru_cache:别再手写记忆化字典了

用标准库 @lru_cache / @cache 给纯函数做记忆化;参数必须可哈希,别缓存副作用,也别原地改返回值。

要解决的问题

写 Python 后端时,经常有一类函数:同样的入参,结果一定一样,但算一次很贵——解析大配置、按键查只读映射、递归算组合数、按 PEP 号拉文档文本等。同一进程里同一参数被问很多次,若每次都重算,CPU 和 I/O 都白费。

标准库 functools 里的 @lru_cache(自 Python 3.2)就是干这件事的:用字典记住「参数 → 返回值」,下次命中直接返回。需要无上限缓存时,Python 3.9+ 还有更轻的 @cache(等价于 lru_cache(maxsize=None))。本文示例已在本机 Python 3.13.5 跑通;核心 API 在 3.9+ 一致。

核心概念

先记住这几条(以 Python 3.12 functools 文档 为准):

  • @lru_cache(maxsize=128, typed=False):装饰器,把函数包成带记忆的可调用对象,最多保留 maxsize 条最近调用。maxsize=None 时关闭 LRU 淘汰,缓存可无限增长。
  • 参数必须可哈希:内部用字典存结果,位置参数和关键字参数都要 hashable。传 list / dict 会 TypeError: unhashable type: ...。
  • @cache(3.9+):无界缓存,比带上限的 lru_cache 更小更快;适合参数空间小、结果稳定的纯函数。
  • cache_info() / cache_clear():前者返回 CacheInfo(hits, misses, maxsize, currsize),方便调 maxsize;后者清空缓存。3.9+ 还有只读的 cache_parameters()。
  • 线程安全:文档写明缓存结构在并发更新时保持一致;但另一线程在首次调用尚未写完缓存前再调一次,仍可能执行多次底层函数。
  • 适用边界:只适合「想复用已算过的值」的场景。有副作用、每次要新对象(如生成器)、或不纯的函数(如 time() / random())不该缓存。

示例代码

「贵」的纯函数 + 命中复用(本地已跑通):

from functools import lru_cache, cache

calls = {"n": 0}

@lru_cache(maxsize=32)
def load_config(env: str) -> dict:
    calls["n"] += 1
    # 这里假装一次很贵的只读加载
    return {"env": env, "debug": env == "dev"}

a = load_config("prod")
b = load_config("prod")   # 命中缓存,不再进函数体
c = load_config("dev")
assert calls["n"] == 2
assert a is b             # 返回的是同一份缓存对象
print(load_config.cache_info())
# CacheInfo(hits=1, misses=2, maxsize=32, currsize=2)
load_config.cache_clear()

@lru_cache
def fib(n: int) -> int:
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

assert fib(10) == 55
assert fib.cache_info().hits > 0

@cache
def square(x: int) -> int:
    return x * x

assert square(3) == 9
assert square.cache_info().maxsize is None

@lru_cache 也可不写括号(3.8+),默认 maxsize=128。需要按类型分开缓存时用 typed=True:例如 f(3) 与 f(3.0) 会各占一条。关键字参数顺序不同也可能记成两条,如 f(a=1, b=2) 与 f(b=2, a=1)(见官方文档说明)。

参数不可哈希时会立刻失败:

from functools import lru_cache

@lru_cache
def bad(xs: list) -> int:
    return len(xs)

# bad([1, 2])  → TypeError: unhashable type: 'list'
# 可改成 tuple:bad((1, 2))

常见误区

  1. 缓存有副作用的函数
    文档明确:不要缓存会改库表、打日志当业务、读「当前时间」的函数。命中时副作用不会再发生,行为会 silently 错。

  2. 改返回值却忘了它是缓存对象
    lru_cache 返回的是缓存里那一份引用。上面 a is b 为真;若 a["debug"] = True,下次命中也会看到被改过的值。只读结果,或返回不可变结构 / 深拷贝后再改。

  3. maxsize=None / @cache 用在参数空间很大的服务上
    无界缓存会一直涨。长跑的 Web 进程更稳妥的是设合理 maxsize,并用 cache_info() 看命中率再调。

  4. 给方法加 @lru_cache 却不理解 self 也进缓存键
    实例本身会参与键;实例多、生命周期长时,等于按实例囤一份结果,还可能拖住实例不被回收。实例级昂贵属性更常见的做法是 functools.cached_property(3.8+),细节见官方「How do I cache method calls?」。

  5. 把 list/dict 当参数
    不可哈希就进不了缓存。能稳定下来的键请改成 tuple、frozenset 或规范化后的字符串。

小结

同一入参会反复问、结果又稳定时,优先标准库 @lru_cache(要上限)或 @cache(3.9+、无上限):少写手账字典,还自带 cache_info / cache_clear。参数必须可哈希;别缓存副作用;别原地改返回值;长跑服务慎用无界缓存。正式项目以你当前解释器版本的 functools 文档 为准。

参考资料

  1. functools — Higher-order functions — Python 3.12 文档
  2. functools — 最新稳定版文档
✳

谢谢你读到这里。