第7章 7.2 闭包与高阶函数
「上章我们学会了用纯函数和高阶函数把代码变得更干净,这一章我们要用它解决一个更复杂的问题——怎么让函数记住自己的"小秘密"?」
你有没有遇到过这种情况?写了一个函数,每次调用都要重复计算一些东西,但你又不想把这些中间结果暴露给外面——就像你的日记本,只允许你自己翻阅,别人想看?门都没有。
这就是闭包要解决的问题。
🎯 开场 3 分钟:为什么要学这个?
想象这样一个场景:
你去奶茶店点单,服务员问「你要几分糖?」你说「三分糖」。然后你拿到一个小票,上面有个号码。以后你取奶茶的时候,店员扫一下票,就能知道你点的是三分糖——这张票记住了你点单时的选择,即使你已经离开柜台。
这个「记住」的过程,就是闭包在代码里做的事。
痛点问题:
- 想让一个函数保留「私人数据」,但不知道怎么做
- 每次调用都要重复计算,性能很差
- 代码越写越乱,想把相关功能「打包」在一起
学完本文你能:
- 用闭包给函数创建一个「私有保险箱」
- 用记忆函数把昂贵的\n\n
\n\n
\n\n计算结果缓存起来 - 用高阶函数 + 闭包实现防抖节流这些高级功能
- 写出一个有点真实用途的数据处理小工具
🧱 基础 25 分钟:核心概念
什么是闭包?
类比:私人保险箱
闭包就像一个带锁的保险箱。保险箱里有你的私人物品(数据),只有你能打开(访问)。别人就算站在保险箱旁边,也不知道里面装了什么。
代码解释:
def 外面():
秘密 = "我的小金库" # 这是只有"外面"知道的数据
def 里面():
print(秘密) # 里面这个函数,能看到"外面"的数据
return 里面 # 把里面这个函数返回出去
获取保险箱 = 外面() # 调用外面,返回里面这个函数
获取保险箱() # 执行获取保险箱(),输出"我的小金库"
运行结果:
我的小金库
这行在干嘛:
- 外面() 返回的是 里面 这个函数本身,而不是执行它
- 获取保险箱 这个变量,现在指向 里面 函数
- 但调用 获取保险箱() 时,它居然能访问到 秘密 这个变量——这就是闭包的魔力
为什么需要闭包?
痛点:保护数据不被随便修改
# ❌ 错误示范:数据暴露在外面,谁都能改
count = 0
def 计数():
global count
count += 1
return count
计数()
count = 100 # 糟糕!别人可以直接修改你的数据
print(计数()) # 输出 2,数据被你玩坏了
# ✅ 正确做法:用闭包把数据藏起来
def 创建计数器():
count = 0 # 这个变量只有创建计数器内部知道
def 计数():
nonlocal count # nonlocal 关键字:我要修改外层的 count
count += 1
return count
return 计数
我的计数器 = 创建计数器()
print(我的计数器()) # 输出 1
print(我的计数器()) # 输出 2
# 外部根本访问不到 count 这个变量!
运行结果:
3
1
2
这行在干嘛:
- nonlocal count 告诉 Python「我要修改外层函数的 count,不是新建一个」
- 外部代码完全碰不到 count,它被「锁」在闭包里了
记忆函数:让函数变「记性」
类比:算过的题不用再算
记忆函数就像一个错题本。老师讲过一道题,你会了,下次遇到同样的题,你不用再算一遍,直接写答案。
def 记忆(func):
缓存 = {} # 这个字典就是"错题本"
def 包装(*args):
if args in 缓存:
print(f"【命中缓存】{args} 已经算过了,直接返回 {缓存[args]}")
return 缓存[args]
结果 = func(*args)
缓存[args] = 结果
print(f"【新计算】{args} -> {结果}")
return 结果
return 包装
@记忆
def 斐波那契(n):
if n < 2:
return n
return 斐波那契(n-1) + 斐波那契(n-2)
print(斐波那契(10))
运行结果:
【新计算】(0,) -> 0
【新计算】(1,) -> 1
【新计算】(2,) -> 1
【新计算】(3,) -> 2
【新计算】(4,) -> 3
【新计算】(5,) -> 5
【新计算】(6,) -> 8
【新计算】(7,) -> 13
【新计算】(8,) -> 21
【新计算】(9,) -> 34
【新计算】(10,) -> 55
55
这行在干嘛:
- @记忆 装饰器把 斐波那契 函数包装了一层
- 每次计算前先查缓存,命中了直接返回,没命中才真正计算
- 注意打印结果里没有重复计算同一个值——这就是「记忆」的威力
防抖与节流:高阶函数的实战
防抖(Debounce):等最后一次
就像电梯关门:有人按了按钮,你不会立即关门,而是等所有人按完再关。
import time
def 防抖(func, 延迟秒):
上次时间 = 0 # 记录上次执行时间
def 包装(*args):
nonlocal 上次时间
当前时间 = time.time()
if 当前时间 - 上次时间 >= 延迟秒:
print(f"执行 {func.__name__},距离上次 {当前时间 - 上次时间:.2f}秒")
func(*args)
上次时间 = 当前时间
else:
print(f"太频繁了,等 {延迟秒}秒 再执行")
return 包装
@防抖
def 发送请求(数据):
print(f"真正发送请求: {数据}")
# 模拟快速连续调用
发送请求("数据1")
time.sleep(0.1)
发送请求("数据2")
time.sleep(0.1)
发送请求("数据3")
time.sleep(0.5) # 超过1秒了
发送请求("数据4")
运行结果:
执行 发送请求,距离上次 0.00秒
真正发送请求: 数据1
太频繁了,等 1秒 再执行
太频繁了,等 1秒 再执行
执行 发送请求,距离上次 0.70秒
真正发送请求: 数据4
这行在干嘛:
- 每次调用先看「离上次执行是不是太近了」
- 太近就拒绝执行,等够久了才真正调用
- 适合搜索框输入、窗口调整等「不需要每次都触发」的场景
🔥 实战 35 分钟:3 个递进的小项目
项目 1(5 分钟):计算器记忆
目标: 写一个「会记忆」的计算器,同样的计算只算一次
def 创建记忆计算器():
记忆 = {}
def 计算(表达式):
if 表达式 in 记忆:
print(f"【命中记忆】{表达式} = {记忆[表达式]}")
return 记忆[表达式]
# 模拟计算过程(实际项目中这里可以是复杂计算)
print(f"【正在计算】{表达式}")
结果 = eval(表达式) # 注意:实际项目中慎用 eval
记忆[表达式] = 结果
print(f"【已记忆】{表达式} = {结果}")
return 结果
return 计算
# 测试
计算器 = 创建记忆计算器()
计算器("2 + 3")
计算器("2 + 3") # 同样的表达式
计算器("10 * 5")
计算器("10 * 5") # 同样的表达式
计算器("2 + 3 + 4") # 不同的表达式
预期输出:
【正在计算】2 + 3
【已记忆】2 + 3 = 5
【命中记忆】2 + 3 = 5
【正在计算】10 * 5
【已记忆】10 * 5 = 50
【命中记忆】10 * 5 = 50
【正在计算】2 + 3 + 4
【已记忆】2 + 3 + 4 = 9
一句话解释: 闭包里的 记忆 字典保存了所有计算过的表达式,第二次直接返回结果。
项目 2(15 分钟):学生成绩处理器
目标: 从 JSON 数据中筛选、分析学生成绩,用闭包实现「私人统计器」
import json
# 模拟从文件或 API 读取的数据
学生数据 = json.dumps([
{"姓名": "张三", "成绩": 85, "班级": "A"},
{"姓名": "李四", "成绩": 92, "班级": "A"},
{"姓名": "王五", "成绩": 78, "班级": "B"},
{"姓名": "赵六", "成绩": 88, "班级": "B"},
{"姓名": "钱七", "成绩": 95, "班级": "A"},
])
def 创建成绩统计器():
# 私有数据,只有统计器内部能访问
及格线 = 60
统计缓存 = {}
def 筛选及格生(学生列表):
return [s for s in 学生列表 if s["成绩"] >= 及格线]
def 计算平均分(学生列表):
if not 学生列表:
return 0
总分 = sum(s["成绩"] for s in 学生列表)
return 总分 / len(学生列表)
def 获取班级统计(学生列表, 班级名):
缓存键 = f"班级_{班级名}"
if 缓存键 in 统计缓存:
print(f"【命中缓存】{班级名}班数据")
return 统计缓存[缓存键]
班级学生 = [s for s in 学生列表 if s["班级"] == 班级名]
统计结果 = {
"班级": 班级名,
"人数": len(班级学生),
"平均分": 计算平均分(班级学生),
"及格生": 筛选及格生(班级学生)
}
统计缓存[缓存键] = 统计结果
print(f"【新计算】{班级名}班统计")
return 统计结果
def 获取整体统计(学生列表):
缓存键 = "整体"
if 缓存键 in 统计缓存:
print("【命中缓存】整体数据")
return 统计缓存[缓存键]
统计结果 = {
"总人数": len(学生列表),
"平均分": 计算平均分(学生列表),
"及格率": f"{len(筛选及格生(学生列表)) / len(学生列表) * 100:.1f}%"
}
统计缓存[缓存键] = 统计结果
print("【新计算】整体统计")
return 统计结果
return {
"筛选及格生": 筛选及格生,
"计算平均分": 计算平均分,
"班级统计": 获取班级统计,
"整体统计": 获取整体统计
}
# 测试
学生列表 = json.loads(学生数据)
统计器 = 创建成绩统计器()
print("=== 整体统计 ===")
整体 = 统计器["整体统计"](学生列表)
print(整体)
print("\n=== A班统计(第一次) ===")
A班 = 统计器["班级统计"](学生列表, "A")
print(A班)
print("\n=== A班统计(第二次,命中缓存) ===")
A班 = 统计器["班级统计"](学生列表, "A")
print(A班)
print("\n=== B班统计 ===")
B班 = 统计器["班级统计"](学生列表, "B")
print(B班)
预期输出:
=== 整体统计 ===
【新计算】整体统计
{'总人数': 5, '平均分': 87.6, '及格率': '100.0%'}
=== A班统计(第一次) ===
【新计算】A班统计
{'班级': 'A', '人数': 3, '平均分': 90.66666666666667, '及格生': [...]}
=== A班统计(第二次,命中缓存) ===
【命中缓存】A班数据
{'班级': 'A', '人数': 3, '平均分': 90.66666666666667, '及格生': [...]}
=== B班统计 ===
【新计算】B班统计
{'班级': 'B', '人数': 2, '平均分': 83.0, '及格生': [...]}
一句话解释: 用闭包把 及格线 和 统计缓存 藏起来,外部只能通过返回的函数访问,数据安全又有缓存加速。
项目 3(15 分钟):个人待办清单工具
目标: 用闭包实现一个「任务管理器」,支持添加、完成、筛选、统计
def 创建待办清单():
# 私有数据
任务列表 = []
唯一ID = 0
统计缓存 = {}
def 刷新缓存():
nonlocal 统计缓存
统计缓存 = {
"总数": len(任务列表),
"已完成": len([t for t in 任务列表 if t["完成"]]),
"未完成": len([t for t in 任务列表 if not t["完成"]])
}
class 任务:
def __init__(self, 标题, 优先级="中"):
nonlocal 唯一ID
唯一ID += 1
self.id = 唯一ID
self.标题 = 标题
self.优先级 = 优先级
self.完成 = False
def __repr__(self):
状态 = "✅" if self.完成 else "⬜"
return f"{状态} [{self.id}] {self.标题} (优先级:{self.优先级})"
def 添加任务(标题, 优先级="中"):
新任务 = 任务(标题, 优先级)
任务列表.append(新任务)
刷新缓存()
return 新任务.id
def 完成任务(任务id):
for t in 任务列表:
if t.id == 任务id:
t.完成 = True
刷新缓存()
return True
return False
def 删除任务(任务id):
global 任务列表
原来的长度 = len(任务列表)
任务列表 = [t for t in 任务列表 if t.id != 任务id]
if len(任务列表) < 原来的长度:
刷新缓存()
return True
return False
def 筛选任务(条件=None):
if 条件 is None:
return 任务列表[:]
return [t for t in 任务列表 if 条件(t)]
def 获取统计():
if not 统计缓存:
刷新缓存()
return 统计缓存.copy()
def 显示所有():
print("\n📋 当前待办清单:")
for t in 任务列表:
print(t)
stats = 获取统计()
print(f"\n📊 统计: 总数={stats['总数']}, 已完成={stats['已完成']}, 未完成={stats['未完成']}")
return {
"添加": 添加任务,
"完成": 完成任务,
"删除": 删除任务,
"筛选": 筛选任务,
"统计": 获取统计,
"显示": 显示所有
}
# 测试
清单 = 创建待办清单()
清单["添加"]("买菜", "高")
清单["添加"]("写周报", "中")
清单["添加"]("打电话给妈妈", "高")
清单["添加"]("刷剧", "低")
清单["显示"]()
print("\n--- 完成「买菜」---")
清单["完成"](1)
清单["显示"]()
print("\n--- 筛选高优先级任务 ---")
高优先任务 = 清单["筛选"](lambda t: t.优先级 == "高" and not t.完成)
print("高优先级未完成:", 高优先任务)
print("\n--- 删除「刷剧」---")
清单["删除"](4)
清单["显示"]()
预期输出:
📋 当前待办清单:
⬜ [1] 买菜 (优先级:高)
⬜ [2] 写周报 (优先级:中)
⬜ [3] 打电话给妈妈 (优先级:高)
⬜ [4] 刷剧 (优先级:低)
📊 统计: 总数=4, 已完成=0, 未完成=4
--- 完成「买菜」---
📋 当前待办清单:
✅ [1] 买菜 (优先级:高)
⬜ [2] 写周报 (优先级:中)
⬜ [3] 打电话给妈妈 (优先级:高)
⬜ [4] 刷剧 (优先级:低)
📊 统计: 总数=4, 已完成=1, 未完成=3
--- 筛选高优先级任务 ---
高优先级未完成: [[2] 写周报 (优先级:中), [3] 打电话给妈妈 (优先级:高)]
--- 删除「刷剧」---
📋 当前待办清单:
✅ [1] 买菜 (优先级:高)
⬜ [2] 写周报 (优先级:中)
⬜ [3] 打电话给妈妈 (优先级:高)
📊 统计: 总数=3, 已完成=1, 未完成=2
一句话解释: 用闭包把「任务列表」和「ID计数器」藏起来,外部只能通过返回的字典调用方法,数据完全私有。
💪 进阶 20 分钟:常见坑 + 性能小贴士
坑 1:闭包引用外部变量,而非拷贝值
# ❌ 错误示范
def 创建计数器列表():
计数器列表 = []
for i in range(3):
def 计数():
return i # 这里的 i 是引用,不是拷贝
计数器列表.append(计数)
return 计数器列表
# 测试
计数器们 = 创建计数器列表()
for c in 计数器们:
print(c()) # 全都输出 3!
# ✅ 正确做法:用默认参数「捕获」当前值
def 创建计数器列表正确版():
计数器列表 = []
for i in range(3):
def 计数(index=i): # 用默认参数捕获当前 i 的值
return index
计数器列表.append(计数)
return 计数器列表
计数器们 = 创建计数器列表正确版()
for c in 计数器们:
print(c()) # 输出 0, 1, 2
运行结果:
3
3
3
0
1
2
坑 2:循环中绑定闭包函数的经典陷阱
# ❌ 错误示范
def 创建函数们():
函数列表 = []
for i in range(3):
函数列表.append(lambda: i * 2)
return 函数列表
# 调用时
for f in 创建函数们():
print(f()) # 全部输出 4(因为循环早就结束了,i=2)
# ✅ 正确做法:用参数默认值捕获
def 创建函数们正确版():
函数列表 = []
for i in range(3):
函数列表.append(lambda x=i: x * 2) # x=i 捕获当前值
return 函数列表
for f in 创建函数们正确版():
print(f()) # 输出 0, 2, 4
坑 3:装饰器忘记 nonlocal 导致数据不更新
# ❌ 错误示范
def 计数装饰器(func):
count = 0
def 包装(*args):
# ❌ 这里 count += 1 其实是创建了一个新的局部变量
count = count + 1 # UnboundLocalError!
print(f"函数被调用了 {count} 次")
return func(*args)
return 包装
# ✅ 正确做法
def 计数装饰器正确版(func):
count = 0
def 包装(*args):
nonlocal count # 声明要修改外层变量
count += 1
print(f"函数被调用了 {count} 次")
return func(*args)
return 包装
坑 4:记忆函数内存泄漏
# ⚠️ 警告:记忆函数会一直保留历史记录,可能导致内存占用过大
# 如果缓存无限增长,可以用 maxsize 限制缓存大小
from functools import lru_cache
@lru_cache(maxsize=128) # 最多缓存 128 个结果
def 昂贵的计算(n):
# ... 计算逻辑
return n * n
性能小贴士:避免在循环中创建闭包
# ❌ 性能差:每次循环都创建新函数
def 性能差():
结果 = []
for i in range(1000):
结果.append(lambda: i) # 1000 个闭包对象
return 结果
# ✅ 性能好:减少闭包创建
def 性能好():
结果 = [lambda x=i: x for i in range(1000)] # 用列表推导式
return 结果
调试技巧:用 closure 属性查看闭包内容
def 外层():
a = 1
b = 2
def 内层():
return a + b
return 内层
f = 外层()
print("闭包包含的变量:")
for i, cell in enumerate(f.__closure__):
print(f" 第{i}个变量: {cell.cell_contents}")
# 运行结果:
# 闭包包含的变量:
# 第0个变量: 1
# 第1个变量: 2
✏️ 练习题
练习 1(2 分钟):添加一个「只读」计数器
- 输入: 复用项目 1 的代码
- 预期输出: 计数器只能 +1,不能重置
- 提示: 在返回的字典里加一个
获取当前值()方法,只返回 count 不修改它
练习 2(2 分钟):给计算器加「清空记忆」功能
- 输入: 在项目 1 代码基础上
- 预期输出: 调用
清空记忆()后,同样的计算会重新执行 - 提示: 在
创建记忆计算器里加一个清空函数,清空记忆字典
练习 3(3 分钟):筛选高分数学生
- 输入: 用项目 2 的方法,处理一份新数据
新学生 = [{"姓名": "小红", "成绩": 55, "班级": "C"},
{"姓名": "小强", "成绩": 98, "班级": "C"}]
- 预期输出: 筛选出及格生并计算平均分
- 提示: 直接调用
统计器["筛选及格生"](新学生)和统计器["计算平均分"](...)
练习 4(3 分钟):合并两个待办清单
- 输入: 用项目 3 的方法,创建两个清单 A 和 B
- 预期输出: 把 B 的任务合并到 A 里
- 提示: 需要在
创建待办清单里添加一个合并方法,直接 extend 任务列表
练习 5(5 分钟):分析这个报错
- 输入: 以下代码运行后报什么错?为什么?
def 错误示例():
x = 10
def 内层():
print(x) # 这里能访问
x = 20 # 这里想修改
print(x)
内层()
错误示例()
- 预期输出: 报错信息 + 解释原因
- 提示: Python 看到
x =就认为x是局部变量,但这个变量还没赋值就被引用了
作业:做一个「个人财务小管家」
需求描述:
用闭包实现一个简单的个人收支记录工具,数据完全私有,只暴露必要的方法。
功能点:
1. 记账:记录一笔收支(金额、类别、备注)
2. 统计:查看总收支、结余、各类别总额
3. 筛选:按类别筛选、按金额范围筛选
4. 缓存优化:同样的统计只计算一次,第二次直接返回缓存
加分项:
1. 支持按日期范围筛选
2. 数据持久化到文件(JSON 格式)
验收标准:
- 能跑起来
- 多次调用统计方法,第二次应该命中缓存
- 代码有注释,说明闭包「藏」了什么数据
提交方式: 评论区贴代码或 GitHub 链接
📚 总结
本文学了 3 个核心点:
- 闭包 = 函数的「私人保险箱」:用
nonlocal修改外层变量,用作用域规则保护私有数据 - 记忆函数 = 函数的「错题本」:把计算结果缓存起来,避免重复计算
- 高阶函数 + 闭包 = 强大组合:装饰器、防抖节流、模块模式都靠这个组合
延伸学习资源:
- Python 官方文档:函数作用域规则 —— 搞清楚 LEGB 法则
- 《流畅的 Python》第 8 章 —— 闭包的进阶用法和原理
- Fluent Python 配套代码 —— 大量闭包和装饰器的实战例子
互动钩子:
「你在工作中有没有遇到过『明明同一个函数,每次都要重新算』的烦恼?后来是怎么解决的?评论区聊聊,老粉优先回复!」
下章预告:
「学会了让函数记住自己的小秘密,下一章我们要学习如何『监视』函数的每一次调用——Proxy 和 Reflect,让你的代码变得更可控!」

评论(0)