杰瑞科技汇

Python缓存机制如何提升运行效率?

Python 的缓存机制主要可以分为两大类:

Python缓存机制如何提升运行效率?-图1
(图片来源网络,侵删)
  1. 内置缓存:由 Python 解释器自动管理,主要针对小整数和字符串等不可变对象,以提高性能和内存效率。
  2. 手动/显式缓存:由开发者通过编程技术实现,用于缓存函数的计算结果,避免重复计算,提升程序运行速度。

下面我们分别深入探讨这两类机制。


内置缓存机制

这部分机制是 Python 解释器在底层自动完成的,开发者通常无需关心,但了解它有助于我们更好地理解 Python 的行为和性能特性。

整数缓存

Python 会对小整数(通常在 -5256 的范围内)进行缓存。

  • 原理:像 -5, -4, ..., 0, 1, ..., 256 这些整数在程序中非常常用,为了避免在内存中为每一个相同的小整数都创建一个新对象,Python 解释器在启动时会预先创建这些整数对象,并将它们存入一个对象池中,当代码中需要使用这些整数时,直接从这个池中引用,而不是重新创建。
  • 验证:我们可以使用 is 操作符(它检查的是两个对象的身份,即内存地址是否相同)来验证这一点。
# a 和 b 都指向了同一个缓存中的 100 对象
a = 100
b = 100
print(a is b)  # 输出: True
# c 和 d 指向了不同的对象,因为 1000 超出了缓存范围
c = 1000
d = 1000
print(c is d)  # 输出: False (CPython 中通常为 False,但这是实现细节,不应依赖)
# 如果手动创建一个 1000 的对象,并让另一个变量指向它,它们会是同一个对象
e = 1000
f = e
print(e is f)  # 输出: True
  • 为什么是 -5 到 256? 这个范围是一个在 CPython 实现中约定俗成的优化选择,它足够大,可以覆盖绝大多数日常使用的整数,同时又足够小,不会占用过多内存。注意: 这个范围是 CPython 的实现细节,对于其他 Python 解释器(如 Jython, IronPython)或未来版本可能会有所不同,因此不应该在你的代码逻辑中依赖这个特性

字符串缓存

字符串的缓存机制比整数更复杂一些,主要遵循以下规则:

Python缓存机制如何提升运行效率?-图2
(图片来源网络,侵删)
  • 字符串字面量:对于在代码中直接定义的字符串(字面量),如果两个字符串的值相同,Python 可能会复用同一个对象,以节省内存。
  • 字符串驻留:这是一种更广泛的优化策略,Python 会尝试将相同的字符串对象只保留一份,但对于非字面量字符串(如通过拼接、循环生成的),这种行为不保证会发生。
# 字面量字符串的缓存
s1 = "hello world"
s2 = "hello world"
print(s1 is s2)  # 输出: True (在大多数解释器中为 True)
# 通过拼接创建的字符串,不一定被缓存
s3 = "hello " + "world"
print(s3 is s2)  # 输出: 可能是 True,也可能是 False,不确定!不要依赖。
# 使用 intern() 函数强制驻留
s4 = "hello world"
s5 = "hello world"
s6 = "".join(["hello", " ", "world"])
# 手动进行字符串驻留
s6_interned = sys.intern(s6)
print(s5 is s6)        # 输出: False (很可能)
print(s5 is s6_interned) # 输出: True
  • sys.intern():这是一个手动干预字符串缓存的方法,它会将字符串放入一个全局的字符串表中,如果表中已经存在相同的字符串,它会返回表中那个字符串的引用;否则,它会将当前字符串加入表中并返回其引用,这在处理大量重复字符串(如在解析大量文本数据时)时可以显著减少内存占用,但会增加创建字符串时的开销。

手动/显式缓存

手动缓存的核心思想是“空间换时间”,即用额外的内存空间来存储计算结果,当再次遇到相同的输入时,直接从内存中读取结果,而不是重新执行一遍耗时的计算。

使用字典或缓存类

最基础的手动缓存方式就是使用字典。

# 一个计算斐波那契数列的函数,非常耗时
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)
# 使用字典进行手动缓存
cache = {}
def fibonacci_cached(n):
    if n in cache:
        return cache[n]
    if n < 2:
        result = n
    else:
        result = fibonacci_cached(n - 1) + fibonacci_cached(n - 2)
    cache[n] = result
    return result
# --- 测试 ---
import time
start_time = time.time()
print(fibonacci(35)) # 计算耗时较长
end_time = time.time()
print(f"普通版本耗时: {end_time - start_time:.4f} 秒")
start_time = time.time()
print(fibonacci_cached(35)) # 首次计算后,后续调用会非常快
end_time = time.time()
print(f"缓存版本耗时: {end_time - start_time:.4f} 秒")
# 再次调用,速度极快
start_time = time.time()
print(fibonacci_cached(35))
end_time = time.time()
print(f"缓存版本(第二次调用)耗时: {end_time - start_time:.4f} 秒")

functools.lru_cache 装饰器

Python 标准库 functools 提供了一个非常强大且易于使用的装饰器 lru_cache,它为我们实现了上述缓存逻辑,并且功能更完善。

  • LRU:全称是 Least Recently Used(最近最少使用),当缓存空间满时,它会淘汰掉最久没有被使用过的缓存项。
  • 使用方法:只需在函数定义前加上 @lru_cache() 装饰器即可。
from functools import lru_cache
import time
# 原始的、耗时的函数
@lru_cache(maxsize=None) # maxsize=None 表示缓存大小无限制
def fibonacci_lru(n):
    if n < 2:
        return n
    return fibonacci_lru(n - 1) + fibonacci_lru(n - 2)
# --- 测试 ---
start_time = time.time()
print(fibonacci_lru(35))
end_time = time.time()
print(f"lru_cache 版本耗时: {end_time - start_time:.4f} 秒")
# 再次调用,速度极快
start_time = time.time()
print(fibonacci_lru(35))
end_time = time.time()
print(f"lru_cache 版本(第二次调用)耗时: {end_time - start_time:.4f} 秒")
# 查看缓存信息
print(f"缓存信息: {fibonacci_lru.cache_info()}")
# 输出类似: CacheInfo(hits=35, misses=36, maxsize=None, currsize=36)
# hits: 命中次数, misses: 未命中(重新计算)次数

lru_cache 的优点:

Python缓存机制如何提升运行效率?-图3
(图片来源网络,侵删)
  1. 简单易用:只需一行代码,无需手动管理字典。
  2. 线程安全:它是线程安全的。
  3. 功能强大
    • maxsize:可以设置缓存的最大条目数,防止内存无限增长。
    • typed:如果设置为 True,不同类型的参数会被视为不同的缓存键。f(3)f(3.0) 会被缓存两次。
    • cache_info()cache_clear():提供了查看缓存状态和清空缓存的方法。

适用场景

  • 纯函数:函数的输出仅依赖于输入参数,且没有副作用(如修改全局变量、打印日志等)。
  • 计算密集型任务:如复杂的数学计算、文件解析、网络请求等。
  • 重复调用:函数会被多次使用相同的参数调用。

__slots__ 与缓存

__slots__ 是一个特殊属性,它通常不被直接归类为“缓存机制”,但它通过一种非常高效的方式“缓存”了实例的属性信息,从而极大地优化了内存和性能。

  • 问题:在 Python 中,每个实例默认都有一个 __dict__ 字典来存储其属性,这非常灵活,但也带来了内存和性能开销。
  • __slots__ 的作用:当你在一个类中定义 __slots__ 时,Python 就不会再为该类的实例创建 __dict__,相反,它会为你在 __slots__ 中列出的属性创建固定的、预分配的“槽位”。
class WithoutSlots:
    def __init__(self, a, b):
        self.a = a
        self.b = b
class WithSlots:
    __slots__ = ['a', 'b'] # 定义实例可以拥有的属性
    def __init__(self, a, b):
        self.a = a
        self.b = b
# --- 测试 ---
# 1. 内存占用
import sys
wos = WithoutSlots(1, 2)
ws = WithSlots(1, 2)
print(f"WithoutSlots 内存占用: {sys.getsizeof(wos)} bytes") # 包含 __dict__ 的开销
print(f"WithSlots 内存占用: {sys.getsizeof(ws)} bytes")    # 更小
# 2. 属性访问速度
import timeit
def access_without_slots():
    obj = WithoutSlots(1, 2)
    return obj.a
def access_with_slots():
    obj = WithSlots(1, 2)
    return obj.a
print(f"\nWithoutSlots 访问时间: {timeit.timeit(access_without_slots, number=1000000):.4f} 秒")
print(f"WithSlots 访问时间: {timeit.timeit(access_with_slots, number=1000000):.4f} 秒")

__slots__ 的优缺点:

  • 优点
    • 节省内存:没有了 __dict__,内存占用显著减少。
    • 访问速度更快:属性的访问是通过一个固定的偏移量完成的,比在字典中查找键要快。
  • 缺点
    • 失去灵活性:无法在运行时动态添加 __slots__ 中未定义的属性。
    • 没有 __dict__:如果你需要存储额外的、未在 __slots__ 中声明的属性,会直接报错。
    • 子类复杂性:如果子类没有定义 __slots__,它会继承父类的 __slots__,但同时仍然会拥有自己的 __dict__,可能达不到预期的内存节省效果。

缓存类型 核心机制 优点 缺点/注意事项 适用场景
内置缓存 解释器自动对小整数和字符串字面量进行对象复用。 提升性能,节省内存。 实现细节,不应在代码逻辑中依赖。 Python 解释器内部优化。
手动缓存 (字典) 用字典存储函数参数和返回值。 灵活,易于理解。 需要手动管理,代码冗余,可能有线程安全问题。 简单场景,学习缓存原理。
lru_cache functools 提供的装饰器,实现 LRU 策略的自动缓存。 极其简单,线程安全,功能丰富(maxsize, typed, cache_info)。 仅适用于纯函数。 强烈推荐用于优化纯函数的重复计算。
__slots__ 通过固定属性槽位来“缓存”实例属性信息。 显著节省内存提升属性访问速度 失去动态添加属性的灵活性,没有 __dict__ 用于创建大量实例且属性固定的类,如数据模型。

Python 提供了从底层自动优化到开发者手动控制的多种缓存机制,在日常开发中,对于纯函数的性能优化,@lru_cache 是你的首选工具,而对于内存敏感的类设计,__slots__ 是一个强大的利器,理解这些机制,能帮助你写出更高效、更优雅的 Python 代码。

分享:
扫描分享到社交APP
上一篇
下一篇