作用域与闭包

一段代码里 x = 5 的这个 x 到底”在哪里”?跨函数写代码后,你会开始遇到 “为什么 x 在这里能读、那里改不了?” 的疑问。答案就在作用域(scope)——Python 定位变量名的规则。理解 LEGB 规则和闭包,你才能真正驾驭函数、装饰器、生成器这些高级特性。这一篇讲透作用域、globalnonlocal、闭包与”闭包变量陷阱”。

一、LEGB 规则

Python 找一个变量名,按四层依次查找:

  1. L (Local):当前函数的局部;
  2. E (Enclosing):外层函数(如果嵌套定义)的局部;
  3. G (Global):模块顶层;
  4. B (Built-in):Python 内置命名空间。

找不到就 NameError

1
2
3
4
5
6
7
8
9
10
11
12
x = "global"

def outer():
x = "enclosing"
def inner():
x = "local"
print(x) # local
inner()
print(x) # enclosing

outer()
print(x) # global

内置层次的例子:

1
print("hello")     # print 在 built-in 里

如果你 def print(...) 定义了自己的 print,就会遮蔽内置。所以别用 listdictsum 这些做变量名。

二、局部作用域

函数内部赋值的变量默认是局部

1
2
3
4
5
6
7
8
x = 10

def f():
x = 20 # 新建局部 x,不影响外部
print(x) # 20

f()
print(x) # 10

规则:只要在函数体里有 x = ... 这种赋值语句,x 在整个函数里都被视为局部。哪怕这行在很下面:

1
2
3
4
5
x = 10

def f():
print(x) # ❌ UnboundLocalError!
x = 20 # 因为这一行,x 在整个函数里都是局部

要么全部使用外部 x(不赋值),要么显式声明 global

三、global:修改全局变量

在函数里想改全局变量,用 global

1
2
3
4
5
6
7
8
9
count = 0

def increment():
global count
count += 1

increment()
increment()
print(count) # 2

别滥用 global——它会让代码难测、难维护。多数情况改成”返回值 + 调用方赋值”更好:

1
2
3
4
5
def next_count(current):
return current + 1

count = 0
count = next_count(count)

四、nonlocal:修改外层函数变量

闭包(下面详细讲)常常需要修改外层函数的变量,但不是全局的,这时用 nonlocal

1
2
3
4
5
6
7
8
9
10
11
12
def counter():
n = 0
def inc():
nonlocal n
n += 1
return n
return inc

c = counter()
print(c()) # 1
print(c()) # 2
print(c()) # 3

如果不写 nonlocaln += 1 会被当成”新建局部 n”,导致 UnboundLocalError。

nonlocalglobal 的区别

  • global 找到模块顶层的变量;
  • nonlocal 找到最近的外层函数的变量。

五、什么是闭包

闭包(closure)= “函数 + 它捕获的外层变量“。当外层函数返回内层函数时,内层函数记住了它的定义环境。

1
2
3
4
5
6
7
8
9
10
def make_multiplier(factor):
def multiplier(x):
return x * factor # 捕获了外层的 factor
return multiplier

double = make_multiplier(2)
triple = make_multiplier(3)

print(double(5)) # 10
print(triple(5)) # 15
  • double 是一个函数,它”记住”了 factor=2
  • triple 是同一份代码,但”记住”的 factor=3
  • 两者互不影响。

六、闭包能干什么

1. 参数化的函数

1
2
3
4
5
6
7
8
9
10
def make_greeter(greeting):
def greet(name):
return f"{greeting}{name}"
return greet

hello = make_greeter("你好")
morning = make_greeter("早上好")

print(hello("咖飞")) # 你好,咖飞
print(morning("小王")) # 早上好,小王

2. 装饰器的底层机制

装饰器本质就是”接受函数,返回函数”,天然就是闭包:

1
2
3
4
5
6
7
8
def timed(func):
def wrapper(*args, **kwargs):
import time
start = time.time()
result = func(*args, **kwargs)
print(f"{func.__name__} 用了 {time.time()-start:.3f}s")
return result
return wrapper

wrapper 捕获了 func,就是闭包。

3. 保存状态(比类轻量)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def make_counter():
count = 0
def inc():
nonlocal count
count += 1
return count
def reset():
nonlocal count
count = 0
return inc, reset

inc, reset = make_counter()
print(inc(), inc(), inc()) # 1 2 3
reset()
print(inc()) # 1

七、闭包变量的可见性

闭包捕获变量本身(更准确说是”cell”),不是那一刻的值。所以外层变量后来变,闭包看到的也变:

1
2
3
4
5
6
7
8
9
def outer():
x = 10
def inner():
print(x)
x = 20 # 修改外层 x
return inner

f = outer()
f() # 20,不是 10!

inner 捕获的是 x 这个引用,而不是数值 10。

八、”晚绑定”陷阱:循环里的闭包

这是 Python 最经典的坑之一:

1
2
3
4
5
6
7
8
funcs = []
for i in range(3):
funcs.append(lambda: i)

for f in funcs:
print(f())
# 期待 0 1 2
# 实际 2 2 2

原因:所有 lambda 捕获同一个变量 i,循环结束时 i 是 2,所以调用时全部返回 2。

修复 1:默认参数(快照当前值)

1
2
3
4
5
6
funcs = []
for i in range(3):
funcs.append(lambda i=i: i) # 默认值在 def 时求值

for f in funcs:
print(f()) # 0 1 2

修复 2:立即调用工厂函数

1
2
3
4
5
funcs = []
for i in range(3):
def make(x):
return lambda: x
funcs.append(make(i))

修复 3:偏函数 functools.partial

1
2
from functools import partial
funcs = [partial(lambda x: x, i) for i in range(3)]

这个坑几乎所有教程都会警告——记住它。

九、检查闭包变量

闭包的”记忆”存在 __closure__ 属性里:

1
2
3
4
5
6
def make(x):
return lambda: x

f = make(42)
print(f.__closure__) # (cell object,)
print(f.__closure__[0].cell_contents) # 42

一般写业务代码不需要用它,但了解原理有助于理解装饰器等高级机制。

十、模块作用域 vs 类作用域

模块顶层的变量属于模块作用域(G 层)。

类体里的变量是个特殊存在——它属于”类的命名空间”,但方法内部访问不到(不属于 LEGB 的 E 层):

1
2
3
4
5
class Foo:
x = 10 # 类属性
def method(self):
print(x) # ❌ NameError!方法看不到 x
print(self.x) # ✅ 得走 self

初学者遇到这个会很困惑。规则:类体不是外层作用域,方法内部只能通过 selfFoo.x 访问类属性。

十一、变量的生命周期

  • 局部变量:函数调用时创建,返回时销毁;
  • 闭包捕获的变量:只要闭包函数还存在,就活着;
  • 全局变量:程序运行期间一直活;
  • 循环变量:Python 里 for 变量会泄漏到外层
1
2
3
for x in range(3):
pass
print(x) # 2!

推导式的循环变量不泄漏(3.x),但普通 for 会。

十二、检查名字:locals / globals

1
2
3
4
5
6
def f():
a, b = 1, 2
print(locals()) # {'a': 1, 'b': 2}

f()
print(globals()) # 模块顶层的所有名字,是个字典

调试时偶尔用一下,业务代码不要动 locals() 的返回值(有些实现里改它没效果)。

十三、一个综合例子:简单缓存

用闭包实现一个”最多缓存 N 个结果”的装饰器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# cache.py
"""闭包实现的极简 LRU 风格缓存。"""

from collections import OrderedDict


def bounded_cache(maxsize: int = 128):
"""返回一个装饰器,为被装饰函数加缓存。"""
def decorator(func):
cache = OrderedDict()

def wrapper(*args):
if args in cache:
# 命中:把它移到末尾表示"最近用"
cache.move_to_end(args)
return cache[args]

result = func(*args)
cache[args] = result

# 超出容量,弹掉最久未用的
if len(cache) > maxsize:
cache.popitem(last=False)
return result

wrapper.cache = cache # 挂在函数上便于调试
return wrapper
return decorator


@bounded_cache(maxsize=3)
def slow_square(n):
print(f" 正在计算 {n} 的平方")
return n * n


for x in [1, 2, 3, 1, 4, 2]:
print(x, "->", slow_square(x))

print("缓存内容:", dict(slow_square.cache))

输出:

1
2
3
4
5
6
7
8
9
10
11
  正在计算 1 的平方
1 -> 1
正在计算 2 的平方
2 -> 4
正在计算 3 的平方
3 -> 9
1 -> 1 # 缓存命中
正在计算 4 的平方
4 -> 16
2 -> 4 # 缓存命中
缓存内容: {3: 9, 1: 1, 4: 16, 2: 4}

这个例子融合了闭包、可变默认对象、装饰器概念,本质就是一个 mini 版的 functools.lru_cache

十四、常见陷阱

  1. 函数里给全局变量赋值忘了 globalx = ... 会新建局部。
  2. 循环里的闭包晚绑定:改成默认参数或立即工厂。
  3. UnboundLocalError:函数里既读又赋值同一个名字,全成了局部。
  4. nonlocal 找不到目标:外层必须已有这个名字,不能凭空创建。
  5. 类作用域不参与 LEGB:方法内直接写类属性名会 NameError。
  6. for 变量泄漏for x in range(3): pass; print(x) 会输出 2。

十五、小结与延伸阅读

  • LEGB:Local → Enclosing → Global → Built-in;
  • global 修改全局,nonlocal 修改外层函数变量;
  • 闭包 = 函数 + 它记住的外层变量;
  • 闭包记住的是变量的引用,不是快照;
  • 循环里创建闭包要小心晚绑定;
  • 尽量少用 global,闭包比 global 更”局部化”;
  • 类作用域不参与 LEGB。

延伸阅读:

下一篇 匿名函数 lambda 与高阶函数 我们讲 Python 函数式编程的两个主角。