「Python 进阶之路」系列 Day29
写在前面
Day05 讲装饰器时提到过写自定义装饰器要加 @functools.wraps,但没细讲为什么。今天把 functools 模块里最常用的三个工具------lru_cache、partial、wraps------一次讲透,各自解决的是完全不同类型的问题,别把它们混为一谈。
一、是什么:三个各管一段的工具
lru_cache:缓存函数调用结果的装饰器,基于 LRU(最近最少使用)策略淘汰缓存partial:固定一个函数的部分参数,返回一个参数更少的新可调用对象("偏函数")wraps:修复自定义装饰器导致原函数元信息(__name__、__doc__等)丢失的问题
二、为什么
lru_cache解决的是"纯函数被重复调用、每次都要重新算一遍"的浪费------用内存换时间partial解决的是"某些场景需要一个参数更少的简化版函数,自己手写包装函数太啰嗦"wraps解决的是"装饰器悄悄改变了函数的身份信息,导致调试和依赖函数名的逻辑出问题"
三、怎么用
1. lru_cache:用空间换时间,避免重复计算
递归/重复计算量大的函数会反复计算相同输入,lru_cache 把每次调用的参数和结果缓存起来,下次同样的参数直接返回缓存结果:
python
import functools, time
def fib_no_cache(n):
if n < 2:
return n
return fib_no_cache(n - 1) + fib_no_cache(n - 2)
@functools.lru_cache(maxsize=None)
def fib_cached(n):
if n < 2:
return n
return fib_cached(n - 1) + fib_cached(n - 2)
# 实测 fib(30):
# 不加缓存: 0.0662s
# 加缓存: 0.000014s
# 快了 4876 倍!
不加缓存的朴素递归会重复计算大量相同的子问题(fib(28) 会在计算 fib(29) 和 fib(30) 的过程中被各算一遍),时间复杂度是指数级;加上缓存后每个子问题只会真正计算一次,退化成线性复杂度。
cache_info() 可以查看缓存命中情况:
python
fib_cached.cache_clear()
fib_cached(20)
fib_cached(20) # 重复调用
print(fib_cached.cache_info())
# CacheInfo(hits=19, misses=21, maxsize=None, currsize=21)
maxsize 参数限制缓存容量,超过容量后按 LRU 策略淘汰最久没被访问的结果:
python
@functools.lru_cache(maxsize=2)
def square(x):
return x * x
square(1)
square(2)
square(3) # 缓存已满(maxsize=2),1被淘汰(最久没被访问)
info_before = square.cache_info()
square(1) # 重新计算(因为1已被淘汰),不是缓存命中
info_after = square.cache_info()
print(info_before.misses, info_after.misses)
# 3 4 ------ misses增加了,说明square(1)确实被淘汰后重新计算了
2. partial:预先固定部分参数
某些场景需要"预先固定几个参数",生成一个更简单的调用接口,partial 比自己写一个包装函数更简洁:
python
to_binary = functools.partial(int, base=2)
print(to_binary("1010")) # 10,相当于 int("1010", base=2)
def power(base, exponent):
return base ** exponent
square_fn = functools.partial(power, exponent=2)
cube_fn = functools.partial(power, exponent=3)
print(square_fn(5)) # 25
print(cube_fn(5)) # 125
常见应用场景:给多线程/多进程的 target 函数预先绑定部分参数、给回调函数固定一部分上下文参数,避免为每种参数组合单独写一个包装函数。
3. wraps:修复装饰器导致的元信息丢失
Day05 讲装饰器时提过要用 @functools.wraps,今天把它到底解决了什么问题讲清楚------自定义装饰器如果不用 wraps,被装饰函数的 __name__、__doc__ 等元信息会变成内部 wrapper 函数的:
python
def my_decorator_no_wraps(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
def my_decorator_with_wraps(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator_no_wraps
def greet(name):
"""打个招呼"""
return f"hello {name}"
@my_decorator_with_wraps
def greet2(name):
"""打个招呼"""
return f"hello {name}"
print(greet.__name__, greet.__doc__) # wrapper None ------ 元信息全丢了!
print(greet2.__name__, greet2.__doc__) # greet2 打个招呼 ------ 正确保留
不用 wraps 时,greet.__name__ 变成了 "wrapper"、__doc__ 变成 None------这会影响调试(打印函数名时显示的是内部实现细节 wrapper,不是有意义的原函数名)、影响依赖函数名做路由/反射的框架、也会让 help() 查不到原本写好的文档字符串。
wraps 还会额外保留一个 __wrapped__ 属性,指向被装饰的原始函数,方便需要绕过装饰器逻辑直接访问原函数的场景:
python
print(greet2.__wrapped__) # <function greet2 at 0x...> ------ 指向最原始的greet2函数
四、面试追问
Q1:lru_cache 是怎么实现缓存的,用的什么淘汰策略?
用调用参数作为 key,把函数调用结果缓存起来,下次遇到同样的参数直接返回缓存值,不再重复计算;maxsize 限制缓存容量,超过容量后按 LRU(最近最少使用)策略淘汰最久没被访问的缓存项,把空间让给新的调用结果。
Q2:partial 解决了什么问题?
预先固定一个函数的部分参数,生成一个参数更少、调用更简单的新可调用对象,避免为每种固定参数组合单独手写一个包装函数,常用于多线程/多进程的目标函数预绑定参数、回调函数固定上下文等场景。
Q3:为什么自定义装饰器要加 @wraps?不加会有什么问题?
不加 wraps,被装饰函数的 __name__、__doc__ 等元信息会变成内部 wrapper 函数的,实测验证过 __name__ 会变成 "wrapper"、__doc__ 会丢失变成 None,这会影响调试可读性、影响依赖函数名做路由或反射的框架逻辑,也会让 help() 查不到原本写好的文档。加了 wraps 会把原函数的这些元信息正确复制过来,并额外保留 __wrapped__ 属性指向原函数。
Q4:lru_cache 适合什么场景,不适合什么场景?
适合纯函数(相同输入永远得到相同输出、没有副作用)且被重复调用、单次计算量较大的场景,比如递归计算、复杂查询;不适合参数不可哈希(比如传入 list)、结果会随时间或外部状态变化、或者函数本身有副作用(比如写文件、发请求)的场景,缓存这类函数的结果可能导致数据过时或行为错误。
Q5:functools.wraps 底层做了什么?
本质是调用 functools.update_wrapper,把原函数的 __name__、__doc__、__module__、__dict__ 等属性复制到 wrapper 函数上,覆盖 wrapper 自己原本的这些属性,并设置 wrapper 的 __wrapped__ 属性指向原函数,让外部代码看起来 wrapper 就是原函数本身。
下一篇预告
Day30 是模块六(常用数据结构与标准库进阶)的收官篇------常用魔法方法大盘点:__repr__、__eq__、__hash__、__len__ 这几个高频出现的魔法方法各自的作用和常见坑。