作用域规则与闭包
你一定遇到过这类"灵异事件":变量明明定义过,换到另一个函数里用,解释器却说"不认识这个名字";或者在函数里给外面的计数加一,加完一看,外面的值纹丝不动。这些现象背后的机制就是作用域
你一定遇到过这类"灵异事件":变量明明定义过,换到另一个函数里用,解释器却说"不认识这个名字";或者在函数里给外面的计数加一,加完一看,外面的值纹丝不动。这些现象背后的机制就是作用域。
作用域(Scope)划定了每个名字(变量、函数等)在代码中的可见范围。把这一章吃透,你就能准确预判任何一行代码到底取的是哪个值,也能明白 Python 里一个很优雅的技巧——闭包——是怎么运转的。
一、名字查找的固定路线:LEGB
Python 拿到一个名字时,不是东张西望地乱找,而是沿着一条由内向外的固定路线逐层搜索:
| 查找顺序 | 层级 | 名字的所在位置 |
|---|---|---|
| 1 | Local | 当前函数体内 |
| 2 | Enclosing | 外层嵌套函数 |
| 3 | Global | 模块(文件)最外层 |
| 4 | Built-in | 解释器内置名字(len、print 等) |
哪一层先命中就用哪一层的值;四层全部落空,才会抛 NameError。
city = "模块层" # G:全局作用域
def outer():
city = "外层函数" # E:嵌套作用域
def inner():
city = "内层函数" # L:局部作用域
print(city) # 按 L → E → G → B 的顺序搜,L 层直接命中
inner()
outer() # 输出:内层函数
下面我们把这四层逐个拆开细看。
二、最里面的一层:局部作用域(Local)
写在函数体里的名字,只在函数执行期间"活着",出了函数体就谁也不认识它:
def show():
inner_val = 88 # 局部名字,只在 show 内部可见
print(inner_val)
show() # 88
# print(inner_val) # NameError!函数外面根本找不到它
容易忽略的一点:函数参数同样是局部名字。
def welcome(user): # user 是 show 的局部名字
text = f"你好,{user}" # text 也是局部名字
print(text)
welcome("小周")
# print(user) # NameError!
三、最外面的一层:全局作用域(Global)
直接写在模块顶层(不缩进)的名字属于全局作用域,整个文件都能读到:
total = 0 # 全局名字
def report():
print(total) # 只是"读",没问题
report() # 0
想在函数里改全局变量?
注意:在函数体里对某个名字做赋值,Python 会认定你要在函数内部新建一个局部名字,而不是修改外面的那个:
total = 0
def broken_add():
total += 1 # UnboundLocalError!
# 赋值让 total 变成局部名字,可它还没被赋初值就要先读
# broken_add()
total += 1 等价于 total = total + 1,右边先要读 total,而解释器已把函数内的 total 判定为局部名字,于是"先读后写"卡在读这一步。
正确的做法是用 global 提前声明:
total = 0
def add():
global total # 声明:这里的 total 指向全局那个
total += 1
print(total)
add() # 1
add() # 2
print(total) # 2
四、夹在中间的一层:嵌套作用域(Enclosing)
函数可以套函数。内层函数虽然有自己的局部作用域,但外层函数的名字对它是"透明"的,可以直接读:
def outer():
msg = "外层的问候"
def inner():
print(msg) # 自己这层没有 msg,往外一层找到了
inner()
outer() # 外层的问候
想改外层函数的变量?
和全局的情形一模一样:直接赋值只会新建局部名字,想真正修改,必须用 nonlocal 声明:
def make_ticker():
ticks = 0
def tick():
nonlocal ticks # 声明:ticks 来自外层函数
ticks += 1
return ticks
return tick
ticker = make_ticker()
print(ticker()) # 1
print(ticker()) # 2
print(ticker()) # 3
上面这个结构就是闭包:tick 函数被返回到外面单独使用,但它仍然牢牢"记得"外层的 ticks。外层函数早已执行结束,这个变量却因为被内层函数引用而一直存活——闭包让函数带上了记忆。
global 和 nonlocal 一句话区分:
| 关键字 | 指向哪里 |
|---|---|
global |
模块顶层的全局名字 |
nonlocal |
最近一层外层函数里的名字 |
五、两个高频翻车现场
现场 1:同一个函数里又读又写全局变量
n = 5
def update():
print(n) # 先读
n = 6 # 后写 → 函数刚编译完,n 就被判定为局部名字了!
# update() # UnboundLocalError
为什么会这样: Python 编译函数时(还没运行)就确定好了每个名字的作用域。函数体内只要存在对 n 的赋值语句,n 在整个函数里就都是局部名字——包括那行看起来无害的 print(n)。
修正方法:
n = 5
def update():
global n
print(n)
n = 6
update() # 5
print(n) # 6
现场 2:可变对象当默认参数
def collect_tag(tag, tags=[]):
tags.append(tag)
return tags
print(collect_tag("python")) # ['python']
print(collect_tag("ai")) # ['python', 'ai'] —— 第二次怎么带着第一次的?
原因:默认值在 def 语句执行的那一刻就求值了,而且只创建一次。两次调用共享的是同一个列表对象。
修正方法: 默认值给 None,函数体内再现场创建:
def collect_tag(tag, tags=None):
if tags is None:
tags = []
tags.append(tag)
return tags
六、速查卡:三种典型结构
# 结构 1:普通函数,局部赋值不碰全局
who = "全局的"
def func():
who = "局部的" # 新建局部名字,全局的 who 毫发无损
print(who) # 局部的
func()
print(who) # 全局的
# 结构 2:嵌套函数,各自的名字互不干扰
def outer():
who = "外层的"
def inner():
who = "内层的" # inner 自己的局部名字
print(who) # 内层的
inner()
print(who) # 外层的
outer()
# 结构 3:nonlocal 真正改到外层
def outer():
who = "外层的"
def inner():
nonlocal who
who = "被改写了" # 修改的是外层那个 who
inner()
print(who) # 被改写了
outer()
作业
作业一:作用域判断
先别运行,逐行推演下面代码的三个输出,把答案写进注释里:
n = "模块层"
def first():
n = "函数层"
def second():
print(n) # ?
second()
print(n) # ?
first()
print(n) # ?
作业二:global 的走向
先别运行,推演下面代码的三个输出(注意 inner 里声明的是 global 而不是 nonlocal):
score = 0
def outer():
score = 100
def inner():
global score
score += 1
print(score) # ?
inner()
print(score) # ?
outer()
print(score) # ?
作业三:修复代码
下面的代码用于统计"处理了多少个数据集":每处理完一批数字,全局计数器加一。但现在运行必报错,请修好它:
datasets_done = 0
def analyze(numbers):
result = sum(numbers) / len(numbers)
# 处理完成,计数 +1
datasets_done += 1
return result
print(analyze([1, 2, 3]))
print(analyze([4, 5, 6]))
print(f"共处理了 {datasets_done} 个数据集")
作业四:批量定价器(闭包工厂)
实现 make_pricer(unit_price):接收单价,返回一个新函数;返回的函数接收数量 qty,返回 unit_price * qty(总价)。
必须用闭包实现,不许用 global。
apple = make_pricer(3)
print(apple(5)) # 15
print(apple(10)) # 30
pear = make_pricer(2)
print(pear(7)) # 14
作业五:闭包管库存
实现 create_inventory(initial_stock),返回两个函数:
restock(n):进货 n 件,返回最新库存ship(n):发货 n 件;库存不够就返回"库存不足",否则返回最新库存
要求用闭包保存库存状态,不允许把库存变量暴露到外面。
restock, ship = create_inventory(100)
print(restock(50)) # 150
print(ship(30)) # 120
print(ship(200)) # 库存不足
下一章:06 · 类的定义与实例化