跳至主要内容

Python 缓存:两种简单方法

学习使用 @functools.lru_cache 或 @functools.cache 等装饰器在 Python 中为函数添加缓存。
· 12分钟 阅读

使用 AI 探索

在本文中,我们将学习 Python 中的缓存。我们会了解它是什么,以及如何有效使用它。

缓存是一种通过临时存储程序获得的结果以便在之后需要时重复使用,从而提升应用性能的技术。

在本教程中,我们将学习在 Python 中实现缓存的不同技术,包括 @lru_cache 和 @cache 这两个来自 functools 模块的装饰器。

如果您时间紧张,我们先从一个非常短的缓存实现开始,然后再展开更多细节。

简答:Python 缓存实现

要在 Python 中创建缓存,我们可以使用 functools 模块中的 @cache 装饰器。在下面的代码中,注意 print() 函数只会执行一次:

import functools

@functools.cache
def square(n):
    print(f"Calculating square of {n}")
    return n * n

# Testing the cached function
print(square(4))
print(square(4))

# Calculating square of 4
# 16
# 16

什么是 Python 中的缓存?

假设我们需要解决一个数学问题,并花了一个小时得到正确答案。如果第二天还要解同样的问题,能复用前一天的工作就比从头再来要更省事。

Python 中的缓存遵循相同的原则——当函数调用计算出某个值时,将其存储起来,以便在需要时再次使用。这种类型的缓存也被称为记忆化(memoization)。

我们来看看一个简短示例,它对一个很大的数值范围求和两次:

output = sum(range(100_000_001))
print(output)
output = sum(range(100_000_001))
print(output)

# 5000000050000000
# 5000000050000000

程序每次都需要重新计算总和。我们可以通过计时两次调用来验证:

import timeit

print(
    timeit.timeit(
        "sum(range(100_000_001))",
        globals=globals(),
        number=1,
    )
)

print(
    timeit.timeit(
        "sum(range(100_000_001))",
        globals=globals(),
        number=1,
    )
)

输出显示两次调用耗时大致相同(取决于我们的环境,执行时间可能更快或更慢)。

不过,我们可以使用缓存来避免对同一数值进行重复计算。我们可以用内置的 functools 模块中的 cache() 函数来重新定义名称 sum:

import functools
import timeit

sum = functools.cache(sum)

print(
    timeit.timeit(
        "sum(range(100_000_001))",
        globals=globals(),
        number=1,
    )
)

print(
    timeit.timeit(
        "sum(range(100_000_001))",
        globals=globals(),
        number=1,
    )
)

现在第二次调用只需几微秒,而不是一秒多,因为从 0 到 100,000,000 的求和结果已经被计算并缓存——第二次调用直接使用之前计算并存储的值。

上面我们使用装饰器 functools.cache() 为内置函数 sum() 加入了缓存。顺带一提,Python 中的装饰器是修改另一个函数行为的函数,而无需永久更改其代码。您可以在这篇 Python 装饰器教程 中了解更多装饰器相关内容。

functools.cache() 装饰器是在 Python 3.9 中引入的,但在更早的版本中我们可以使用 functools.lru_cache()。接下来一节我们将探讨这两种创建缓存的方法,包括更常用的装饰器记法,例如 @cache。

Python 缓存:不同方法

Python 的 functools 模块提供了两个用于为函数应用缓存的装饰器。我们用一个示例来探索 functools.lru_cache() 和 functools.cache()。

我们来写一个函数 sum_digits(),它接收一个数字序列并返回这些数字的各位数字之和。比如输入元组 (23, 43, 8) 时:

  • 23 的各位数字之和为 5。
  • 43 的各位数字之和为 7。
  • 8 的各位数字之和为 8。
  • 因此,总和为 20。

下面是我们可以编写的 sum_digits() 函数的一种方式:

def sum_digits(numbers):
    return sum(
        int(digit) for number in numbers for digit in str(number)
    )

numbers = 23, 43, 8

print(sum_digits(numbers))

# 20

让我们使用这个函数来探索不同的缓存创建方式。

Python 手动缓存

我们先手动创建缓存。虽然我们也可以很容易地自动化,但手动创建缓存有助于我们理解这一过程。

我们创建一个字典,每次用新值调用函数时,将键值对加入其中以存储结果。如果用已在字典中存储过的值来调用函数,函数将直接返回已存储的值,而不再重新计算:

import random
import timeit

def sum_digits(numbers):
    if numbers not in sum_digits.my_cache:
        sum_digits.my_cache[numbers] = sum(
            int(digit) for number in numbers for digit in str(number)
        )
    return sum_digits.my_cache[numbers]
sum_digits.my_cache = {}

numbers = tuple(random.randint(1, 1000) for _ in range(1_000_000))

print(
    timeit.timeit(
        "sum_digits(numbers)",
        globals=globals(),
        number=1
    )
)

print(
    timeit.timeit(
        "sum_digits(numbers)",
        globals=globals(),
        number=1
    )
)

第二次调用 sum_digits(numbers) 比第一次快得多,因为它使用了缓存的值。

我们来更详细地解释上述代码。首先注意,尽管在函数定义中使用了字典 sum_digits.my_cache,但我们是在定义函数之后才创建它。

sum_digits() 函数会检查传入的参数是否已经是 sum_digits.my_cache 字典的键之一。只有当参数不在缓存中时,才会计算所有数字的各位和。

由于我们在调用函数时传入的参数会作为字典的键,因此它必须是可哈希的数据类型。列表是不可哈希的,所以不能作为字典键使用。比如,将 numbers 替换成列表而不是元组——这会引发 TypeError:

# ...

numbers = [random.randint(1, 1000) for _ in range(1_000_000)]

# ...
Traceback (most recent call last):
...
TypeError: unhashable type: 'list'

手动创建缓存非常适合学习,但接下来我们来探索更快捷的方式。

使用 functools.lru_cache() 的 Python 缓存

从 Python 3.2 开始就有 lru_cache() 装饰器。函数名前的 “lru” 代表 "least recently used"(最近最少使用)。我们可以把缓存想象成一个存放常用物品的盒子——装满时,LRU 策略会丢弃最长时间未被使用的条目,为新条目腾出空间。

让我们用 @functools.lru_cache 来装饰 sum_digits() 函数:

import functools
import random
import timeit

@functools.lru_cache
def sum_digits(numbers):
    return sum(
        int(digit) for number in numbers for digit in str(number)
    )

numbers = tuple(random.randint(1, 1000) for _ in range(1_000_000))

print(
    timeit.timeit(
        "sum_digits(numbers)",
        globals=globals(),
        number=1
    )
)

print(
    timeit.timeit(
        "sum_digits(numbers)",
        globals=globals(),
        number=1
    )
)

多亏了缓存,第二次调用的运行时间显著缩短。

默认情况下,缓存会存储最先计算出的 128 个值。当 128 个位置被占满时,算法会删除最近最少使用(LRU)的值,为新值腾出空间。

我们可以在装饰函数时通过 maxsize 参数设置不同的最大缓存大小:

import functools
import random
import timeit

@functools.lru_cache(maxsize=5)
def sum_digits(numbers):
    return sum(
        int(digit) for number in numbers for digit in str(number)
    )

# ...

这种情况下,缓存只存储五个值。如果不想限制缓存大小,我们也可以将 maxsize 参数设置为 None。

使用 functools.cache() 的 Python 缓存

Python 3.9 引入了一个更简单、更快速的缓存装饰器——functools.cache()。这个装饰器有两个主要特点:

  • 它没有最大大小限制——类似于调用 functools.lru_cache(maxsize=None)。
  • 它会存储所有函数调用及其结果(不使用 LRU 策略)。这适用于输出相对较小的函数,或在不需要考虑缓存大小限制的场景。

让我们在 sum_digits() 函数上使用 @functools.cache 装饰器:

import functools
import random
import timeit

@functools.cache
def sum_digits(numbers):
    return sum(
        int(digit) for number in numbers for digit in str(number)
    )

numbers = tuple(random.randint(1, 1000) for _ in range(1_000_000))

print(
    timeit.timeit(
        "sum_digits(numbers)",
        globals=globals(),
        number=1
    )
)

print(
    timeit.timeit(
        "sum_digits(numbers)",
        globals=globals(),
        number=1
    )
)

用 @functools.cache 装饰 sum_digits() 等价于将 sum_digits 赋值为 functools.cache() 的返回值:

# ...

def sum_digits(numbers):
    return sum(
        int(digit) for number in numbers for digit in str(number)
    )

sum_digits = functools.cache(sum_digits)

注意我们也可以使用不同的导入方式:

from functools import cache

这样我们就可以直接使用 @cache 来装饰函数。

其他缓存策略

Python 自带的工具实现了 LRU 缓存策略,即当缓存满时,删除最近最少使用的条目来为新值腾出空间。

我们再看看其他一些缓存策略:

  • 先进先出(FIFO):当缓存满时,最先加入的项目被移除,为新值腾出空间。LRU 与 FIFO 的区别在于,LRU 会将最近使用的项目保留在缓存中,而 FIFO 则不管使用情况,直接丢弃最早加入的项目。
  • 后进先出(LIFO):当缓存满时,最近加入的项目被移除。可以想象自助餐厅的一摞盘子。我们最近放上去的盘子(后进)会最先被拿走(先出)。
  • 最近最常用(MRU):当需要空间时,丢弃最近刚被使用的值。
  • 随机替换(RR):该策略会随机丢弃一个项目,为新项目腾出空间。

这些策略也可以与“有效期”度量结合——这指缓存中某条数据被认为有效或相关的时长。想象缓存中的一篇新闻文章。它可能被频繁访问(LRU 会保留它),但一周后,这条新闻可能就过时了。

Python 缓存:常见用例

到目前为止,我们使用了简化的示例来帮助学习。然而,缓存在真实世界中有许多应用。

在数据科学中,我们经常对大型数据集执行重复操作。使用缓存结果可以减少在相同数据集上反复执行相同计算所需的时间和成本。

我们也可以用缓存来保存外部资源,如网页或数据库。我们来看一个示例,缓存一篇 DataCamp 的文章。但首先需要通过在终端执行以下命令来安装第三方模块 requests:

$ python -m pip install requests

安装好 requests 后,我们可以尝试下面的代码,它会在使用 @lru_cache 装饰器的同时,尝试两次获取同一篇 DataCamp 文章:

import requests
from functools import lru_cache

@lru_cache(maxsize=10)
def get_article(url):
    print(f"Fetching article from {url}")
    response = requests.get(url)
    return response.text

print(get_article("https://www.datacamp.com/tutorial/decorators-python"))
print(get_article("https://www.datacamp.com/tutorial/decorators-python"))

顺带一提,我们截断了输出,因为它非常长。不过请注意,只有第一次调用 get_article() 会打印 Fetching article from {url} 这句话。

这是因为网页只会在第一次调用时被访问。结果会存储在函数的缓存中。当我们第二次请求同一网页时,会直接返回缓存中的数据。

缓存可以确保在反复获取相同数据时不会产生不必要的延迟。外部 API 往往还有速率限制和调用成本。缓存可以降低 API 成本,并减少触发速率限制的可能性。

另一个常见用例是在机器学习应用中,需要重复进行多次昂贵的计算。例如,如果我们需要在将文本用于机器学习模型之前对其进行分词和向量化,那么可以把输出存入缓存。这样就无需重复这些计算代价高昂的操作。

在 Python 中实现缓存的常见挑战

我们已经了解了在 Python 中使用缓存的优势。但在实现缓存时也有一些挑战和缺点需要注意:

  • 缓存失效与一致性:数据可能会随时间变化。因此,缓存中存储的值也可能需要更新或移除。
  • 内存管理:在缓存中存储大量数据需要占用内存,如果缓存无限增长,可能会导致性能问题。
  • 复杂性:添加缓存会在创建和维护缓存时引入系统复杂性。通常收益大于成本,但复杂性增加可能导致难以发现和修复的错误。

结论

当对同一数据重复执行计算密集型操作时,我们可以使用缓存来优化性能。

Python 提供了两个用于在函数调用时创建缓存的装饰器:@lru_cache 和 @cache(位于 functools 模块)。

不过,我们需要确保及时更新缓存,并妥善进行内存管理。

如果您想进一步学习缓存和 Python,欢迎查看这个由六门课程组成的 Python Programming 技能路径。

Stephen Gruppetta

作者

Stephen Gruppetta

我在马耳他大学完成了本科阶段的物理与数学学习。随后前往伦敦,并在帝国理工学院取得物理学博士学位,研究用于成像人类视网膜的新型光学技术。现在,我专注于撰写与传播 Python 相关内容,并教授 Python。

主题