斐波那契数列加个装饰器,速度快了近五千倍: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__ 这几个高频出现的魔法方法各自的作用和常见坑。

相关推荐
newerp1 小时前
Golang 接口的两副面孔:eface、iface 与动态派发之谜
后端·程序员·go
万物智能1 小时前
开源鸿蒙内核配置与驱动三条路径—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
前端·后端
Zane19941 小时前
自己写一个 java.lang.String,为什么永远替换不掉 JDK 那个
java·后端
明月_清风1 小时前
递归算法:从原理到实战,一次讲透
后端·算法·go
掘金者阿豪1 小时前
ChatGPT Plus、Pro 5x、Pro 20x 到底有多少额度?聊聊 Codex 那个让人看不懂的“周限额”
后端
今天AI了吗1 小时前
【AI】一文讲清 RAG:从大模型局限到企业级知识库落地流程
前端·javascript·人工智能·python·深度学习·机器学习·easyui
“AI国潮设计-小江”1 小时前
《Python实战 | 用SDXL大模型生成“潮汕英歌舞”国潮IP头像,已申请外观专利,附Prompt思路!》
人工智能·python·prompt·aigc·scikit-learn
用户608186527901 小时前
Avalonia 控件模板实战:从 WPF 迁移自定义 Button 样式的完整指南
后端
霸道流氓气质2 小时前
Spring AI提示词模板与动态变量替换
java·python·spring