第3章 3.3 作用域与闭包
——小明的「变量找不到」灾难与神奇的封闭包子
🎯 开场 3 分钟:为什么要学这个?
你有没有遇到过这种糟心事?
count = 10
def add():
count = count + 1 # ❌ 报错!
print(count)
add()
你明明在外面定义了 count,为什么函数里面不能用?
或者你写过这种代码吗——
funcs = []
for i in range(3):
funcs.append(lambda: print(i))
for f in funcs:
f() # 输出的都是 2,不是 0, 1, 2!
明明想打印 0、1、2,结果全打印了 2。这是 Python 里让新手最懵的问题之一。
学完这章,你能彻底搞懂上面两个bug的原理,写出更健壮的代码。
🧱 基础 25 分钟:核心概念
什么是作用域?先听个故事
作用域就像行政区划。
你住在北京朝阳区某小区,你的身份证是北京签发的。但你去上海旅游,酒店前台要查你的身份证——他能查到吗?能,但需要调取北京的系统。
Python 里的变量也一样。每个变量有自己的「行政区域」,在这个区域里随便用,出了区域想用就得看规矩。
🏠 LEGB 规则——Python 找变量的「就近原则」
Python 找一个变量的时候,遵循 LEGB 顺序:
Local → Enclosing → Global → Built-in
翻译成人话:先看自己屋里有没有,没有就上一层楼,再没有就出小区,最后去市政系统查。
看个例子:
# Global 层级
name = "张三"
def outer():
# Enclosing 层级(外层函数)
name = "李四"
def inner():
# Local 层级(内层函数)
name = "王五"
print(name) # 先找自己 => 找到 "王五"
inner()
print(name) # 自己没有 => 找外层 => 找到 "李四"
print(name) # 自己没有 => 找外层也没有 => 找到 "张三"
outer()
输出:
王五
李四
张三
解释:每次 print 都按 LEGB 的顺序找,现在你知道为什么了吧?

global 和 nonlocal——我想改外面的变量!
有时候内层函数确实想改外层的变量,怎么办?
❌ 错误做法:直接改
count = 0
def add():
count = count + 1 # 报错!因为 Python 认为是创建新变量
print(count)
add()
为什么报错? Python 看到 count =,就默认你要在局部创建一个新变量,但右边 count + 1 又要读 count,这时候局部 count 还不存在,懵了。
✅ 正确做法 1:global 声明
count = 0
def add():
global count # 声明:我要用外面的 count
count = count + 1
print(count)
add() # 输出 1
add() # 输出 2
add() # 输出 3
解释:global count 相当于告诉 Python「别在这儿创建新的,去外面找那个 count」。
✅ 正确做法 2:nonlocal 声明
def outer():
count = 0
def add():
nonlocal count # 声明:我要改外层函数的 count
count = count + 1
print(count)
add() # 输出 1
add() # 输出 2
outer()
解释:nonlocal 专门用于内层函数修改外层函数的变量,比 global 更精准。

闭包是什么?先来一个生活类比
闭包 = 带行李箱的函数。
普通函数就像空手出门,回来还是空手。闭包函数出门的时候,把外面的「行李箱」(自由变量)打包带走了,不管走到哪儿都能用。
用代码说话:
def make_adder():
# 这个就是"行李箱",被内层函数记住了
bonus = 10
def adder(x):
return x + bonus # 用的是外层函数的 bonus
return adder # 把 adder 这个"带行李箱的函数"返回出去
# 创建一个闭包
add5 = make_adder()
print(add5(5)) # 输出 15(5 + 10)
print(add5(100)) # 输出 110(100 + 10)
解释:make_adder() 返回的 adder 函数,背着 bonus=10 这个行李箱满街跑。这就是闭包。
闭包的原理——Python 怎么记住外层变量?
当你创建一个闭包时,Python 会把外层函数的局部变量打包成一个「细胞块」(cell),然后闭包函数能访问到这块内存。
def make_counter():
count = 0 # 这个 count 被闭包"记住"了
def counter():
nonlocal count
count += 1
return count
return counter
# 创建两个独立的计数器
counter_a = make_counter()
counter_b = make_counter()
print(counter_a()) # 1
print(counter_a()) # 2(独立的 count)
print(counter_b()) # 1(另一个独立的 count)
print(counter_a()) # 3
关键点:每次调用 make_counter() 都会创建一个新的 count 变量,counter_a 和 counter_b 互不影响。
⚠️ 延迟绑定陷阱——最常见的坑
这是本文最重要的一个坑,你一定要记住:
funcs = []
for i in range(3):
funcs.append(lambda: print(i)) # ❌ 所有函数都记住同一个 i
for f in funcs:
f()
输出:
2
2
2
原因:循环结束后 i 的值是 2,所有闭包都引用同一个 i。当它们执行时,Python 按 LEGB 一找,找到的就是最后的 2。
✅ 解决方法:立即绑定
funcs = []
for i in range(3):
funcs.append(lambda i=i: print(i)) # ✅ 立即把当前的 i 传进去
for f in funcs:
f()
输出:
0
1
2
解释:lambda i=i 的意思是「把当前的 i 值,作为参数 i 的默认值传进去」,这样每个闭包记住的就是自己的值了。
🔥 实战 35 分钟:3 个递进的小项目
项目 1(5 分钟):用闭包做一个「点赞计数器」
"""项目1:社交媒体点赞计数器"""
def make_like_counter(post_id):
"""创建一个针对特定帖子的点赞计数器"""
likes = 0 # 外层变量,被闭包记住
def like():
nonlocal likes
likes += 1
return likes
def get_total():
return likes
# 返回一个字典,方便外面调用
return {"like": like, "get_total": get_total, "post_id": post_id}
# 为两条微博创建计数器
weibo_1 = make_like_counter("微博#001")
weibo_2 = make_like_counter("微博#002")
# 模拟点赞操作
print(f"{weibo_1['post_id']} 赞了!目前 {weibo_1['like']()} 个赞")
print(f"{weibo_1['post_id']} 又赞了!目前 {weibo_1['like']()} 个赞")
print(f"{weibo_1['post_id']} 总赞数: {weibo_1['get_total']()}")
print(f"{weibo_2['post_id']} 赞了!目前 {weibo_2['like']()} 个赞")
print(f"{weibo_1['post_id']} 不受影响,总赞数还是: {weibo_1['get_total']()}")
预期输出:
微博#001 赞了!目前 1 个赞
微博#001 又赞了!目前 2 个赞
微博#001 总赞数: 2
微博#002 赞了!目前 1 个赞
微博#001 不受影响,总赞数还是: 2
一句话解释:每个微博的计数器独立管理自己的 likes 变量,这就是闭包的威力。
项目 2(15 分钟):用闭包处理 CSV 数据——「成绩统计器」
这个项目我们从 CSV 读取学生成绩,用闭包来管理不同科目的统计状态。
先创建一个测试 CSV 文件 students.csv:
# 创建测试数据(实际项目不需要这步,只是为了你能运行)
import csv
import os
# 准备数据
header = ["name", "chinese", "math", "english"]
data = [
["小明", "85", "92", "78"],
["小红", "91", "88", "95"],
["小刚", "76", "85", "82"],
["小丽", "88", "90", "89"],
]
# 写入 CSV
os.makedirs("data", exist_ok=True)
with open("data/students.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(header)
writer.writerows(data)
print("测试数据已创建: data/students.csv")
然后是核心代码:
"""项目2:学生成绩统计器(带闭包的版本)"""
import csv
def make_subject_stats():
"""创建一个统计器工厂,返回带有闭包状态的统计函数"""
stats = {
"count": 0,
"total": 0,
"max_score": 0,
"max_name": "",
"min_score": float("inf"),
"min_name": "",
}
def add_score(name, score):
score = int(score)
stats["count"] += 1
stats["total"] += score
if score > stats["max_score"]:
stats["max_score"] = score
stats["max_name"] = name
if score < stats["min_score"]:
stats["min_score"] = score
stats["min_name"] = name
def get_average():
if stats["count"] == 0:
return 0
return stats["total"] / stats["count"]
def report():
return {
"人数": stats["count"],
"平均分": f"{get_average():.1f}",
"最高分": f"{stats['max_score']}({stats['max_name']})",
"最低分": f"{stats['min_score']}({stats['min_name']})",
}
return add_score, report
# 读取 CSV 并统计
chinese_stats = make_subject_stats()
math_stats = make_subject_stats()
english_stats = make_subject_stats()
add_chinese, report_chinese = chinese_stats
add_math, report_math = math_stats
add_english, report_english = english_stats
# 读取数据并喂给统计器
with open("data/students.csv", "r", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
add_chinese(row["name"], row["chinese"])
add_math(row["name"], row["math"])
add_english(row["name"], row["english"])
# 输出报告
print("=" * 30)
print("📊 各科目成绩统计")
print("=" * 30)
for subject, report in [("语文", report_chinese), ("数学", report_math), ("英语", report_english)]:
r = report()
print(f"\n【{subject}】")
for key, value in r.items():
print(f" {key}: {value}")
预期输出:
==============================================================
📊 各科目成绩统计
==============================================================
【语文】
人数: 4
平均分: 85.0
最高分: 91(小红)
最低分: 76(小刚)
【数学】
人数: 4
平均分: 88.8
最高分: 92(小明)
最低分: 85(小刚)
【英语】
人数: 4
平均分: 86.0
最高分: 95(小红)
最低分: 78(小明)
一句话解释:每个科目的统计器独立维护自己的状态,用闭包实现「数据隔离」,比全局变量优雅 100 倍。
项目 3(15 分钟):做一个「待办清单工具」
组合前两个项目的能力,做一个带持久化存储的待办清单,用闭包管理内存中的任务状态。
"""项目3:待办清单工具(闭包 + 文件存储)"""
import json
import os
TODO_FILE = "data/todo_list.json"
def make_todo_list():
"""创建一个待办清单管理器(闭包)"""
# 内存中的任务列表
tasks = []
def load():
"""从文件加载任务"""
nonlocal tasks
if os.path.exists(TODO_FILE):
with open(TODO_FILE, "r", encoding="utf-8") as f:
tasks = json.load(f)
return len(tasks)
def save():
"""保存任务到文件"""
with open(TODO_FILE, "w", encoding="utf-8") as f:
json.dump(tasks, f, ensure_ascii=False, indent=2)
def add(task_text):
"""添加任务"""
task = {
"id": len(tasks) + 1,
"text": task_text,
"done": False
}
tasks.append(task)
save()
return f"✅ 已添加: {task_text}"
def complete(task_id):
"""标记任务完成"""
for task in tasks:
if task["id"] == task_id:
task["done"] = True
save()
return f"✔️ 完成任务 #{task_id}"
return f"❌ 找不到任务 #{task_id}"
def list_tasks(show_done=True):
"""列出所有任务"""
if not tasks:
return "📝 待办清单是空的"
lines = ["📝 当前待办:"]
for task in tasks:
if not show_done and task["done"]:
continue
status = "✔️" if task["done"] else "⏳"
lines.append(f" {status} [{task['id']}] {task['text']}")
return "\n".join(lines)
def stats():
"""统计任务完成情况"""
total = len(tasks)
done = sum(1 for t in tasks if t["done"])
return f"📊 总计 {total} 个任务,已完成 {done} 个,完成率 {done/total*100:.0f}%" if total > 0 else "📊 暂无任务"
return {"load": load, "add": add, "complete": complete, "list": list_tasks, "stats": stats}
# 创建待办清单实例
todo = make_todo_list()
# 初始化:加载已有数据
print(f"加载了 {todo['load']()} 个历史任务\n")
# 演示添加任务
print(todo["add"]("写完 Python 闭包教程"))
print(todo["add"]("复习模块与包的知识"))
print(todo["add"]("给博客换个主题"))
print(todo["add"]("回复读者留言"))
# 演示完成任务
print(todo["complete"](1))
print(todo["complete"](3))
# 显示列表和统计
print()
print(todo["list"]())
print()
print(todo["stats"]())
# 只显示未完成的
print()
print("--- 只看未完成任务 ---")
print(todo["list"](show_done=False))
预期输出:
加载了 0 个历史任务
✅ 已添加: 写完 Python 闭包教程
✅ 已添加: 复习模块与包的知识
✅ 已添加: 给博客换个主题
✅ 已添加: 回复读者留言
✔️ 完成任务 #1
✔️ 完成任务 #3
📝 当前待办:
✔️ [1] 写完 Python 闭包教程
⏳ [2] 复习模块与包的知识
✔️ [3] 给博客换个主题
⏳ [4] 回复读者留言
📊 总计 4 个任务,已完成 2 个,完成率 50%
--- 只看未完成任务 ---
📝 当前待办:
⏳ [2] 复习模块与包的知识
⏳ [4] 回复读者留言
一句话解释:闭包让 tasks 变量「藏」在函数内部,外部无法直接访问,只能通过我们提供的接口操作,既安全又整洁。
💪 进阶 20 分钟:常见坑 + 性能小贴士
坑 1:闭包的延迟绑定
# ❌ 错误
funcs = [lambda: print(i) for i in range(3)]
funcs[0]()()() # 输出都是 2
# ✅ 正确
funcs = [lambda i=i: print(i) for i in range(3)]
funcs[0]()()() # 输出 0, 1, 2
坑 2:在循环里创建闭包忘记绑定
# ❌ 错误:所有按钮回调都引用同一个 i
buttons = []
for i in range(3):
btn = lambda: print(f"按钮 {i} 被点击")
buttons.append(btn)
# ✅ 正确:立即绑定
buttons = []
for i in range(3):
btn = (lambda i=i: lambda: print(f"按钮 {i} 被点击"))()
buttons.append(btn)
坑 3:global 和 local 混用导致困惑
# ❌ 错误:没声明 global 又想改全局变量
count = 0
def bad_func():
count = count + 1 # UnboundLocalError
# ✅ 正确:先声明
count = 0
def good_func():
global count
count += 1
坑 4:闭包修改外层 list/dict 不需要 nonlocal
# 这个不需要 nonlocal!因为没有重新赋值
def make_processor():
results = [] # list 对象本身没变,只是改了内容
def add(item):
results.append(item) # ✅ 直接用就行
return add
但是重新赋值就需要:
def bad_example():
results = []
def add(item):
results = [item] # ❌ 这行会创建新的局部变量!
坑 5:在闭包里用可变默认参数(经典老坑)
# ❌ 错误
def bad_func(items=[]):
items.append("x")
return items
# ✅ 正确
def good_func(items=None):
if items is None:
items = []
items.append("x")
return items
性能小贴士:闭包 vs 类——什么时候用哪个?
# 用闭包(轻量级,单个实例)
def make_counter():
count = 0
def counter():
nonlocal count
count += 1
return count
return counter
# 用类(复杂状态,多个方法)
class Counter:
def __init__(self):
self.count = 0
def increment(self):
self.count += 1
return self.count
def reset(self):
self.count = 0
经验之谈:如果你的「闭包」超过 3-4 个方法,或者需要继承/序列化,赶紧换类。闭包适合简单的一次性状态管理。
调试技巧:如何查看闭包内容
def outer():
x = 10
y = 20
def inner():
return x + y
return inner
f = outer()
print(f.__code__.co_freevars) # ('x', 'y') - 查看闭包变量名
print(f.__closure__) # 查看闭包细胞块
print(f.__closure__[0].cell_contents) # 10 - 查看具体值
✏️ 练习题 + 作业题
练习题(5 道,10 分钟内完成)
练习 1(1 分钟):LEGB 找变量
name = "全局"
def outer():
name = "外层"
def inner():
name = "内层"
print(name)
inner()
print(name)
print(name)
outer()
- 输入:无
- 预期输出:全局、内层、外层
- 提示:按 LEGB 顺序,每个 print 分别找哪个 name?
练习 2(2 分钟):修复 global 报错
total = 100
def add_price(price):
# 只改这一行,让它能正常运行
total = total + price
- 输入:add_price(30)
- 预期输出:无报错,total 变成 130
- 提示:加一句声明
练习 3(2 分钟):闭包计数器
def make_counter():
count = 0
def counter():
nonlocal count
count += 1
return count
return counter
# 创建两个计数器,分别调用 3 次,输出结果
c1 = make_counter()
c2 = make_counter()
# 你的代码
- 预期输出:1 2 3 1 2 3
- 提示:c1 和 c2 互不影响
练习 4(3 分钟):延迟绑定陷阱
funcs = []
for i in range(3):
funcs.append(lambda x, i=i: x + i)
# 输出 5, 6, 7
print(funcs[0](5), funcs[1](5), funcs[2](5))
- 预期输出:5 6 7
- 提示:关键在 lambda 的参数默认值
练习 5(2 分钟):分析报错
def outer():
x = 10
def inner():
print(x)
x = 20 # 这行会报错,为什么?
inner()
outer()
- 预期输出:UnboundLocalError
- 提示:Python 看到
x =就把 x 当局部变量,但 print 在赋值之前
作业题(30 分钟 - 2 小时)
做一个「个人消费记录工具」
需求描述:
做一个命令行消费记录工具,支持添加消费、查看统计、用闭包管理状态。
功能点:
1. 添加消费:输入金额和分类(餐饮/交通/购物/其他),保存到 JSON 文件
2. 查看统计:显示每类消费总额和占比
3. 月份筛选:只查看某个月份的消费记录
加分项:
1. 支持删除消费记录(按 ID 删除)
2. 显示消费趋势(哪类花得最多)
验收标准:
- 能运行 python todo_money.py 启动
- 添加 3 条消费后,统计结果正确
- 数据持久化到 expenses.json,重启后数据不丢失
📚 总结 + 资源
本章核心 3 点
- LEGB 规则:Python 找变量按 Local → Enclosing → Global → Built-in 的顺序
- global/nonlocal:想改外层变量,必须先声明,否则 Python 会创建新局部变量
- 闭包:函数带着外层变量的「行李箱」到处跑,实现数据封装和状态管理
延伸资源
- Python 官方文档:LEGB 规则详解(英文)
- 《Python 编程:从入门到实践》第 9 章「类」——理解闭包和类的关系
- 视频:B 站「程序员子絮」闭包专题(搜索关键词)
互动钩子
你在写代码时遇到过「变量找不到」或者「闭包不 work」的 bug 吗?怎么解决的?评论区聊聊,老粉优先回复!
下章预告:学会了作用域和闭包,你的代码已经能「记住」东西了。但如果你想把自己写的工具分享给同事/开源给社区,下一章「模块与包」会告诉你怎么做——让你的代码从一个文件变成一个可以 pip install 的包。敬请期待!

评论(0)