第8章 8.3 functools 函数工具

「上章回顾」:上一章我们学会了用 itertools 把复杂的循环拆成一个个小零件,像搭乐高一样组合出各种迭代器玩法。但你有没有遇到过这种情况——同一个函数传同样的参数,被重复调用了 N 次,每次都要重新算一遍,浪费了大把时间?

今天我们要学的 functools,就是来解决这个痛点的。它专门给函数提供「加速包」——缓存计算结果、固定部分参数、简化装饰器写法。学会了它,你的代码能跑得更快、更优雅。


🎯 开场 3 分钟:为什么要学这个?

场景还原:你写了一个计算斐波那契数列的函数:

def fib(n):
if n < 2:
    return n
return fib(n-1) + fib(n-2)

当你调用 fib(30) 的时候,这个函数被调用了 268 万次!其中大量计算是完全重复的。

再比如:你写了一个查询天气的函数,每次用户查询都去请求远程 API,既慢又浪费钱。

痛点总结
1. 重复计算——同样的参数,结果算了一遍又一遍
2. 重复请求——同样的查询,每次都发网络请求
3. 代码重复——写一个带额外功能的装饰器,原函数的信息全丢了

学完本文你能
- 用 lru_cache 给函数加缓存,让重复调用秒返回
- 用 partial 固定部分参数,少写重复代码
- 用 wraps 写装饰器不丢元数据
- 用 reduce 把序列浓缩成单个值


🧱 基础 25 分钟:核心概念

8.3.1 lru_cache —— 函数的「记忆面包」

生活类比:你有没有过这种经历——数学老师问「12×13等于多少」,你刚算完等于 156,下次问「12×13」你脱口就说答案,因为记住了嘛。lru_cache 就是给函数装一个「记忆面包」,算过的结果直接存起来,下次同样输入秒出结果。

为什么要用:避免重复计算,提升性能。

怎么用

from functools import lru_cache

@lru_cache(maxsize=128)
def fib(n):
if n < 2:
    return n
return fib(n-1) + fib(n-2)

print(fib(30))  # 输出: 832040
print(fib.cache_info())  # 输出: CacheInfo(hits=28, misses=31, ...)

解释:@lru_cache(maxsize=128) 装饰器给函数加了一层缓存,maxsize 是缓存多少个不同的参数。调用 cache_info() 可以看到缓存命中情况——hits=28 说明有 28 次直接用了缓存结果,不用重新算。

配图1 - 配图1

8.3.2 cache —— 无限缓存的 lru_cache

生活类比lru_cache 像一个有容量限制的冰箱,塞满了就得扔掉旧食材。而 cache 就是无限大的仓库,随便你存多少。

适用场景:缓存的数据量可控、不会爆炸的情况(Python 3.9+)。

from functools import cache

@cache
def factorial(n):
if n == 0:
    return 1
return n * factorial(n-1)

print(factorial(100))  # 输出: 933262154439441526816992388562667004907159682643816214685929...
print(factorial(50))   # 输出: 30414093201713378043612608166064768844377641568960512000000000000
print(factorial(100))  # 这次秒出,因为用了缓存

注意cache 默认 maxsize=None(无限),用的时候要确保你的函数参数不会太多,否则内存可能爆炸。

8.3.3 partial —— 预设参数的「快捷方式」

生活类比:你去奶茶店,每次都点「三分糖、加椰果」,久了店员就说「给你记上了,以后直接说『老规矩』就行」。partial 就是这个「老规矩」——固定一个函数的某些参数,生成一个新的简化版函数。

为什么要用:减少重复传参,代码更简洁。

怎么用

from functools import partial

# 原始函数:两个参数
def power(base, exponent):
return base ** exponent

# 固定 exponent=2,生成新函数
square = partial(power, exponent=2)
cube = partial(power, exponent=3)

print(square(5))  # 输出: 25
print(cube(2))    # 输出: 8
print(power(5, 2))  # 原函数也能用,输出: 25

解释:square = partial(power, exponent=2) 创建了一个新函数,调用时只需传 base 参数,exponent 自动用 2。

8.3.4 reduce —— 把列表「压缩」成单个值

生活类比:你去超市买了 5 样东西,结账的时候收银机一件件累加,最后给你一个总价。reduce 就是这个「累加」的过程——把一个序列按某种规则「压缩」成一个值。

为什么要用:做聚合运算(如求和、求积、找最值)。

from functools import reduce

# 求乘积:1×2×3×4×5
result = reduce(lambda x, y: x * y, [1, 2, 3, 4, 5])
print(result)  # 输出: 120

# 求最大值
result = reduce(lambda a, b: a if a > b else b, [3, 7, 2, 9, 4])
print(result)  # 输出: 9

# 字符串拼接
words = ['Hello', ' ', 'World', '!']
result = reduce(lambda a, b: a + b, words)
print(result)  # 输出: Hello World!

解释:reduce(函数, 序列) 先把序列前两个元素传给函数,结果再跟第三个元素传进去,以此类推。lambda x, y: x * y 就是「把 x 和 y 相乘」。

配图2 - 配图2

8.3.5 wraps —— 装饰器的「身份证保护罩」

生活类比:你给朋友送礼物,让快递员包装了一层。朋友收到后只看到包装纸,不知道里面是啥。装饰器也是「包装」——包完之后原函数的信息(名字、文档)全变成了包装纸的。wraps 就是把原函数的信息「还原」出来。

为什么要用:写装饰器时不丢原函数的 __name____doc__ 等元数据。

from functools import wraps

def my_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
    print("调用前")
    result = func(*args, **kwargs)
    print("调用后")
    return result
return wrapper

@my_decorator
def say_hello(name):
"""这个函数用于打招呼"""
print(f"Hello, {name}!")

print(say_hello.__name__)  # 输出: say_hello(而不是wrapper)
print(say_hello.__doc__)   # 输出: 这个函数用于打招呼

解释:@wraps(func) 把原函数的名字和文档复制给了 wrapper,这样 say_hello.__name__ 仍然是 say_hello,而不是被装饰后变成 wrapper


🔥 实战 35 分钟:3 个递进的小项目

项目 1(5 分钟):斐波那契数列计算器——从 268 万次到 31 次

目标:用 lru_cache 优化斐波那契计算,对比优化前后性能。

代码

import time
from functools import lru_cache

# 不用缓存的版本
def fib_no_cache(n):
if n < 2:
    return n
return fib_no_cache(n-1) + fib_no_cache(n-2)

# 用缓存的版本
@lru_cache(maxsize=100)
def fib_with_cache(n):
if n < 2:
    return n
return fib_with_cache(n-1) + fib_with_cache(n-2)

# 测试
n = 30

start = time.time()
result1 = fib_no_cache(n)
time1 = time.time() - start

start = time.time()
result2 = fib_with_cache(n)
time2 = time.time() - start

print(f"不用缓存: 结果={result1}, 耗时={time1:.4f}秒")
print(f"用缓存:   结果={result2}, 耗时={time2:.4f}秒")
print(f"缓存信息: {fib_with_cache.cache_info()}")

预期输出

不用缓存: 结果=832040, 耗时=0.1823秒
用缓存:   结果=832040, 耗时=0.0000秒
缓存信息: CacheInfo(hits=28, misses=31, ...)

解释fib_with_cache 只调用了 31 次(真正的计算次数),却得到了同样的结果,因为 28 次直接用了缓存。不用缓存的版本调用了 268 万次!


项目 2(15 分钟):数据统计工具——用 reduce 分析学生成绩

目标:从 CSV 读取学生成绩数据,用 reducepartial 做统计分析。

数据文件 students.csv

name,score,grade
张三,85,B
李四,92,A
王五,78,C
赵六,95,A
钱七,88,B

代码

import csv
from functools import reduce, partial

# 读取CSV文件
def read_csv(filepath):
with open(filepath, 'r', encoding='utf-8') as f:
    reader = csv.DictReader(f)
    return list(reader)

# 用reduce统计平均分
def calc_average(scores):
return reduce(lambda a, b: a + b, scores) / len(scores)

# 用reduce找最高分学生
def find_top_student(students):
return reduce(
    lambda best, current: current if int(current['score']) > int(best['score']) else best,
    students
)

# 用partial固定计算逻辑
calc_sum = partial(reduce, lambda a, b: a + b)
calc_count = partial(reduce, lambda a, b: a + 1)

# 主程序
students = read_csv('students.csv')
scores = [int(s['score']) for s in students]

average = calc_average(scores)
top_student = find_top_student(students)
total = calc_sum(scores)
count = calc_count(scores, 0)

print(f"学生总数: {count}")
print(f"总分: {total}")
print(f"平均分: {average:.2f}")
print(f"最高分学生: {top_student['name']} ({top_student['score']}分)")

预期输出

学生总数: 5
总分: 438
平均分: 87.60
最高分学生: 赵六 (95分)

解释:用 reduce 做聚合统计很强大——求和、计数、找最值都可以用它。partial 把常用的 reduce 模式封装成新函数,代码更简洁。


项目 3(15 分钟):爬虫 URL 去重工具——综合运用缓存和函数工具

目标:模拟一个 URL 爬取工具,用 cache 缓存请求结果,用 partial 固定通用逻辑。

from functools import cache, partial
import time

# 模拟的API请求(实际项目中换成真实的requests.get)
call_count = 0

@cache
def fetch_page(url):
global call_count
call_count += 1
time.sleep(0.1)  # 模拟网络延迟
return f"页面内容({url})"

# 用partial固定基础URL
fetch_from_site = partial(fetch_page, "https://example.com/")

# 模拟爬虫:同一URL不会重复请求
urls_to_fetch = [
"https://example.com/page1",
"https://example.com/page2",
"https://example.com/page1",  # 重复
"https://example.com/page3",
"https://example.com/page2",  # 重复
]

print("开始爬取...")
start = time.time()

results = []
for url in urls_to_fetch:
content = fetch_page(url)
results.append(content)

elapsed = time.time() - start

print(f"\n爬取完成!")
print(f"总耗时: {elapsed:.2f}秒(理论无缓存需要: {len(urls_to_fetch)*0.1:.2f}秒)")
print(f"实际请求次数: {call_count}(去重后只有{len(set(urls_to_fetch))}个独立URL)")
print(f"缓存命中: 请求相同URL时直接返回缓存结果")

预期输出

开始爬取...

爬取完成!
总耗时: 0.30秒(理论无缓存需要: 0.50秒)
实际请求次数: 3(去重后只有3个独立URL)
缓存命中: 请求相同URL时直接返回缓存结果

解释:用了 @cache 后,重复的 URL 不会真的发请求,直接从缓存拿结果,3 次真实请求节省了 0.2 秒。这个工具稍加改造就能接真实爬虫。


💪 进阶 20 分钟:常见坑 + 性能小贴士

坑 1:缓存未命中——参数要可哈希

# ❌ 错误示例:列表不可哈希,不能当缓存key
from functools import lru_cache

@lru_cache
def process_data(data):
return sum(data)

process_data([1, 2, 3])  # TypeError: unhashable type: 'list'
# ✅ 正确示例:用元组代替列表
@lru_cache
def process_data(data):
return sum(data)

process_data((1, 2, 3))  # 正常

原因lru_cache 用参数做字典的 key,列表可变所以不能哈希,元组可以。


坑 2:缓存不清——修改了原函数但还是用的缓存

# ❌ 错误示例:改了函数但忘记清缓存
@lru_cache
def get_config(key):
return f"config_{key}"

print(get_config("db"))  # 输出: config_db
# 假设你修改了配置逻辑...
get_config.__wrapped__ = None  # 试图清除缓存?没用!

print(get_config("db"))  # 还是返回 config_db,因为缓存还在
# ✅ 正确示例:调用 cache_clear() 清除缓存
@lru_cache
def get_config(key):
return f"config_{key}"

print(get_config("db"))    # 输出: config_db
get_config.cache_clear()   # 清除所有缓存
print(get_config("db"))    # 重新计算

坑 3:maxsize 太大——内存爆炸

# ❌ 错误示例:maxsize 设太大
@lru_cache(maxsize=100000)
def heavy_func(n):
return n * 2
# ✅ 正确示例:预估合理的缓存大小
# 大多数场景 128-512 就够用了
@lru_cache(maxsize=256)
def reasonable_func(n):
return n * 2

坑 4:partial 的参数覆盖——小心陷阱

# ❌ 错误示例:关键字参数被覆盖
from functools import partial

def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"

# 试图固定 greeting="Hi"
say_hi = partial(greet, greeting="Hi")
print(say_hi("张三"))  # 输出: Hi, 张三!  没问题
print(say_hi("李四", greeting="Hello"))  # 警告!greeting 被覆盖了
# ✅ 正确示例:不要在 partial 和调用时重复传参
say_hi = partial(greet, greeting="Hi")
print(say_hi("李四"))  # 输出: Hi, 李四!  用 partial 设定的值

坑 5:wraps 忘了传参数——白写了

# ❌ 错误示例:wraps 没传被装饰函数
from functools import wraps

def my_decorator(func):
@wraps  # ❌ 忘了传 func 参数!
def wrapper(*args, **kwargs):
    return func(*args, **kwargs)
return wrapper
# ✅ 正确示例:wraps(func)
def my_decorator(func):
@wraps(func)  # ✅ 传入原函数
def wrapper(*args, **kwargs):
    return func(*args, **kwargs)
return wrapper

性能小贴士:缓存大小设置有讲究

lru_cachemaxsize 不是越大越好:

  • 命中率低:设太大浪费内存
  • LRU 淘汰:缓存满了会淘汰最久未用的

经验公式:设成你预计同时使用的不同参数数量的 1.5-2 倍。如果不确定,用默认的 maxsize=128


调试技巧:用 cache_info() 观察缓存效果

from functools import lru_cache

@lru_cache(maxsize=10)
def expensive_computation(n):
return n ** 2

# 触发缓存
expensive_computation(5)
expensive_computation(5)  # 命中缓存
expensive_computation(6)
expensive_computation(7)
expensive_computation(5)  # 再次命中

print(expensive_computation.cache_info())
# 输出: CacheInfo(hits=2, misses=3, ...)

解读hits=2 表示 2 次命中缓存,misses=3 表示 3 次未命中。开发时用这个观察你的缓存策略是否有效。


✏️ 练习题

练习 1(2 分钟):用 cache 加速阶乘计算

  • 输入:factorial(10)factorial(5)
  • 预期输出:3628800120
  • 提示:直接用 @cache 装饰阶乘函数

练习 2(2 分钟):用 partial 固定 power 函数的指数

  • 输入:创建一个 cube = partial(power, exponent=3),然后调用 cube(2)
  • 预期输出:8
  • 提示:先定义 power(base, exponent) 函数

练习 3(3 分钟):在项目 1 基础上加判断

  • 题目:给 fib_with_cache 加一个判断,如果 n 是负数返回 0
  • 预期输出:fib_with_cache(-5) 返回 0fib_with_cache(10) 返回 55
  • 提示:在函数开头加 if n < 0: return 0

练习 4(5 分钟):用 reduce 处理新数据

  • 输入:[15, 20, 25, 30, 35]
  • 预期输出:最大值 35,最小值 15,平均值 25.0
  • 提示:用三个不同的 reduce 逻辑

练习 5(5 分钟):分析报错并修复

  • 题目:下面代码报错 TypeError: unhashable type: 'list',找出问题并修复
from functools import lru_cache

@lru_cache
def find_max(numbers):
return max(numbers)

print(find_max([3, 1, 4, 1, 5]))
  • 预期输出:5
  • 提示:列表不能做缓存的 key,改成元组

作业:做一个「网站配置加载器」

需求描述:写一个配置加载器,模拟从不同网站加载配置,用缓存避免重复请求。

功能点
1. 用 @cache 装饰加载函数,模拟 fetch_config(site)(用 time.sleep 模拟延迟)
2. 用 partial 创建一个默认站点的配置加载器
3. 用 reduce 统计多个配置加载的总耗时

加分项
1. 用 cache_info() 展示缓存命中情况
2. 支持清除缓存重新加载

验收标准
- 能跑起来
- 加载相同配置秒回(用了缓存)
- 加载新配置要等待(模拟请求)
- 代码有注释

提交方式:评论区贴代码或 GitHub 链接


📚 总结 + 资源

本文学了 3 件事
1. lru_cache / cache 给函数加缓存,避免重复计算
2. partial 固定部分参数,生成简化版函数
3. wraps 写装饰器时保留原函数元数据

延伸学习
- 官方文档:functools — Higher-order functions and operations on callable objects
- 视频:Corey Schafer 的 functools 教程
- 书籍:《Python 进阶》第三章「装饰器」

互动钩子:你在项目里有没有遇到过「同一个函数被调用 N 次但没加缓存」的情况?结果卡成什么样了?评论区聊聊,老粉优先回复!


📌 下章预告:学会了给函数加缓存、减负担,下一章我们要学习「路径操作」和「类型提示」——pathlib 让文件和文件夹操作像说话一样简单,typing 让代码自己会「说话」。敬请期待「第8章 8.4 pathlib 与 typing」!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。