斐波那契数列加个装饰器,速度快了近五千倍:functools 三件套详解

「Python 进阶之路」系列 Day29

写在前面

Day05 讲装饰器时提到过写自定义装饰器要加 @functools.wraps,但没细讲为什么。今天把 functools 模块里最常用的三个工具------lru_cachepartialwraps------一次讲透,各自解决的是完全不同类型的问题,别把它们混为一谈。


一、是什么:三个各管一段的工具

flowchart LR A[重复计算浪费时间] --> B[lru_cache<br/>缓存结果避免重算] C[需要预先固定部分参数] --> D[partial<br/>生成参数更少的新函数] E[装饰器让原函数元信息丢失] --> F[wraps<br/>把原函数信息复制回来]
  • 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__ 这几个高频出现的魔法方法各自的作用和常见坑。

相关推荐
子兮曰2 天前
jev-ultrafast 深度解析:7 秒订机票的浏览器 Agent 是如何炼成的
前端·后端·agent
子兮曰2 天前
Jev 爆发一周:7 秒 Agent 背后的 System One 生态与三场争议
前端·后端·ai编程
默_笙2 天前
🍙 给每个请求过安检:FastAPI 是怎么把校验写进类型注解的
python
爱勇宝2 天前
ZCode 开源 24 小时:一份没有历史的账本,回答不了"有没有偷代码"
前端·后端·chatglm (智谱)
qq_426003962 天前
启动playwright录制codegen生成自动化测试脚本
python·自动化
虎头金猫2 天前
4K 视频总卡在公网带宽?用 N1 + OpenList 把网盘播放链路重新理顺
运维·服务器·网络·python·容器·beautifulsoup·pandas
胡写代码2 天前
别再前后端各写一套表单校验了
java·后端
长沙三为智能科技2 天前
家政小程序开发从0到上线:五阶段交付流程与验收清单
python
伞伞悦读2 天前
【第38期】Python 模块与包详解:import、from、模块搜索路径、包结构和 __init__
开发语言·python
大勇前进2 天前
原生 PHP 还是 Laravel?小项目到底要不要上框架
后端