第7章 7.2 闭包与高阶函数

「上章我们学会了用纯函数和高阶函数把代码变得更干净,这一章我们要用它解决一个更复杂的问题——怎么让函数记住自己的"小秘密"?」

你有没有遇到过这种情况?写了一个函数,每次调用都要重复计算一些东西,但你又不想把这些中间结果暴露给外面——就像你的日记本,只允许你自己翻阅,别人想看?门都没有。

这就是闭包要解决的问题。


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

想象这样一个场景:

你去奶茶店点单,服务员问「你要几分糖?」你说「三分糖」。然后你拿到一个小票,上面有个号码。以后你取奶茶的时候,店员扫一下票,就能知道你点的是三分糖——这张票记住了你点单时的选择,即使你已经离开柜台

这个「记住」的过程,就是闭包在代码里做的事。

痛点问题:

  • 想让一个函数保留「私人数据」,但不知道怎么做
  • 每次调用都要重复计算,性能很差
  • 代码越写越乱,想把相关功能「打包」在一起

学完本文你能:

  1. 用闭包给函数创建一个「私有保险箱」
  2. 用记忆函数把昂贵的\n\nSimple tech illustration expla\n\nAI comic creation scene, creat\n\n计算结果缓存起来
  3. 用高阶函数 + 闭包实现防抖节流这些高级功能
  4. 写出一个有点真实用途的数据处理小工具

🧱 基础 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 个核心点:

  1. 闭包 = 函数的「私人保险箱」:用 nonlocal 修改外层变量,用作用域规则保护私有数据
  2. 记忆函数 = 函数的「错题本」:把计算结果缓存起来,避免重复计算
  3. 高阶函数 + 闭包 = 强大组合:装饰器、防抖节流、模块模式都靠这个组合

延伸学习资源:

  1. Python 官方文档:函数作用域规则 —— 搞清楚 LEGB 法则
  2. 《流畅的 Python》第 8 章 —— 闭包的进阶用法和原理
  3. Fluent Python 配套代码 —— 大量闭包和装饰器的实战例子

互动钩子:

「你在工作中有没有遇到过『明明同一个函数,每次都要重新算』的烦恼?后来是怎么解决的?评论区聊聊,老粉优先回复!」


下章预告:

「学会了让函数记住自己的小秘密,下一章我们要学习如何『监视』函数的每一次调用——Proxy 和 Reflect,让你的代码变得更可控!」

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