第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 的顺序找,现在你知道为什么了吧?

配图1 - 配图1

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 更精准。

配图2 - 配图2

闭包是什么?先来一个生活类比

闭包 = 带行李箱的函数。

普通函数就像空手出门,回来还是空手。闭包函数出门的时候,把外面的「行李箱」(自由变量)打包带走了,不管走到哪儿都能用。

用代码说话:

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_acounter_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 点

  1. LEGB 规则:Python 找变量按 Local → Enclosing → Global → Built-in 的顺序
  2. global/nonlocal:想改外层变量,必须先声明,否则 Python 会创建新局部变量
  3. 闭包:函数带着外层变量的「行李箱」到处跑,实现数据封装和状态管理

延伸资源

  • Python 官方文档:LEGB 规则详解(英文)
  • 《Python 编程:从入门到实践》第 9 章「类」——理解闭包和类的关系
  • 视频:B 站「程序员子絮」闭包专题(搜索关键词)

互动钩子

你在写代码时遇到过「变量找不到」或者「闭包不 work」的 bug 吗?怎么解决的?评论区聊聊,老粉优先回复!


下章预告:学会了作用域和闭包,你的代码已经能「记住」东西了。但如果你想把自己写的工具分享给同事/开源给社区,下一章「模块与包」会告诉你怎么做——让你的代码从一个文件变成一个可以 pip install 的包。敬请期待!

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