Python 基础体系 · 第 13/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 作用域与闭包:LEGB、nonlocal、global 和迟绑定

Python 中的“变量”更准确地说是名称绑定:名称指向对象,而赋值、函数定义、参数接收、importfor 循环目标等操作,都会在某个命名空间中建立或改变名称与对象之间的绑定关系。作用域解决的问题是:当解释器看到一个名称时,应当到哪里寻找它。(docs.python.org)

闭包则是在此基础上产生的机制:一个函数可以携带它定义位置附近的自由变量,即使外层函数已经返回,这些变量仍然可以被内部函数访问。迟绑定进一步说明了一个容易误解的事实:闭包捕获的通常是变量所在的绑定位置,而不是创建闭包那一刻的对象快照。


1. 从代码块和命名空间开始

1.1 作用域属于代码块

Python 的执行单位称为代码块。模块、函数体、类定义体、脚本文件,以及传给 eval()exec() 的代码,都可以形成代码块。代码块执行时对应一个执行帧,执行帧保存了继续执行所需的运行时信息。(docs.python.org)

例如:

x = "module"

def outer():
    x = "outer"

    def inner():
        x = "inner"
        print(x)

    inner()

outer()

这里至少有三个相关的代码块:

模块代码块
└── outer 函数代码块
    └── inner 函数代码块

x 在三个代码块中分别绑定了三个名称。它们的拼写相同,但不代表同一个绑定。

1.2 赋值不是“修改变量”,而是重新绑定名称

a = []
b = a

a = [1]

执行过程是:

  1. a = []:创建列表对象 O1,让 a 指向 O1
  2. b = a:让 b 也指向 O1
  3. a = [1]:创建列表对象 O2,让 a 改为指向 O2
  4. b 仍然指向 O1

因此:

print(a)  # [1]
print(b)  # []

而下面的代码不是重新绑定 items,而是修改原列表对象:

items = []
alias = items

items.append(1)

print(items)  # [1]
print(alias)  # [1]

这个区别对于闭包非常重要:

  • x = new_value 是改变闭包中的名称绑定;
  • x.append(value) 是通过闭包中的名称访问对象,再修改对象本身。

2. LEGB:名称解析的四个层次

LEGB 是 Python 名称解析顺序的记忆方式:

L - Local       当前局部作用域
E - Enclosing   外层函数作用域
G - Global      当前模块的全局作用域
B - Builtins    内置名称作用域

严格地说,名称会从当前作用域向最近的外层作用域查找;如果仍未找到,则查找模块全局命名空间,最后查找内置命名空间。(docs.python.org)

2.1 一个完整例子

value = "global"

def outer():
    value = "enclosing"

    def inner():
        value = "local"
        print(value)

    inner()

outer()

输出:

local

如果去掉 inner() 中的局部绑定:

value = "global"

def outer():
    value = "enclosing"

    def inner():
        print(value)

    inner()

outer()

输出:

enclosing

如果再去掉 outer() 中的绑定:

value = "global"

def outer():
    def inner():
        print(value)

    inner()

outer()

输出:

global

如果代码中没有自定义的 value

def show():
    print(len([1, 2, 3]))

show()

len 会在内置命名空间中找到。

2.2 LEGB 描述的是读取,不是所有赋值行为

下面的代码不会因为模块中存在 count 就修改模块变量:

count = 0

def increment():
    count = count + 1
    return count

increment()

运行结果是:

UnboundLocalError: cannot access local variable 'count' where it is not associated with a value

原因不是“局部作用域中找不到 count,然后应该继续查全局作用域”。原因是:

  1. 函数体中出现了 count = ...
  2. 因此整个函数代码块中的 count 都被判定为局部名称;
  3. 执行 count + 1 时,局部 count 还没有绑定值;
  4. 于是抛出 UnboundLocalError

Python 不要求变量声明,解释器可以根据代码块中的绑定操作判定局部名称;这也意味着赋值语句即使位于函数后半部分,也会影响前面的同名引用。(docs.python.org)

count = 0

def show():
    print(count)  # 这里也被视为读取局部 count
    count = 1

show()

这段代码同样会抛出 UnboundLocalError

如果名称根本没有任何可用绑定,则是 NameError

def show():
    print(not_defined)

show()
NameError: name 'not_defined' is not defined

UnboundLocalErrorNameError 的子类,但它专门表示“解释器已经把名称判定为局部名称,却在当前执行位置尚未得到绑定”。(docs.python.org)


3. 局部变量、自由变量与闭包

3.1 自由变量是什么

如果名称在当前函数中被使用,却没有在当前函数中定义,它可能是一个自由变量

def outer():
    message = "hello"

    def inner():
        print(message)

    inner()

inner() 来说:

  • message 没有在 inner() 内部绑定;
  • message 存在于外层函数 outer()
  • 所以 messageinner() 的自由变量。

当一个函数对象保存了对外层自由变量的引用,这个函数就形成了闭包。闭包不是“函数复制了一份外层变量”,而是函数对象关联了外层变量对应的闭包单元。

def make_greeter(prefix):
    def greet(name):
        return f"{prefix}, {name}"

    return greet

hello = make_greeter("Hello")
hi = make_greeter("Hi")

print(hello("Ada"))  # Hello, Ada
print(hi("Ada"))     # Hi, Ada

make_greeter() 返回后,其执行过程已经结束,但 hello 仍然能访问 "Hello"。如果没有闭包保存这个绑定,prefix 的作用域就会随着 make_greeter() 返回而结束。

3.2 闭包保存的是绑定单元

可以通过函数对象的 __closure__ 和代码对象的 co_freevars 观察闭包:

def make_counter():
    count = 0

    def next_count():
        return count + 1

    return next_count

counter = make_counter()

print(counter.__code__.co_freevars)
print(counter.__closure__[0].cell_contents)

典型输出:

('count',)
0

这里:

  • co_freevars 表示内部代码使用的自由变量名称;
  • __closure__ 保存对应的闭包单元;
  • cell_contents 是单元当前引用的对象。

__closure__cell_contents 是很有用的诊断工具,但不应把它们当作日常修改闭包状态的主要接口。理解闭包的关键是:内部函数访问的是一个可持续存在的绑定单元,而非普通局部变量的临时副本。


4. nonlocal:重新绑定最近的外层函数变量

读取外层变量不需要 nonlocal

def make_counter():
    count = 0

    def get():
        return count

    return get

但如果内部函数要对外层变量重新赋值,就必须声明 nonlocal

def make_counter():
    count = 0

    def increment():
        nonlocal count
        count += 1
        return count

    return increment

counter = make_counter()

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

count += 1 可以展开理解为:

count = count + 1

因此它包含对 count 的重新绑定。没有 nonlocal 时,解释器会把 count 判定为 increment() 的局部变量:

def make_counter():
    count = 0

    def increment():
        count += 1
        return count

    return increment

counter = make_counter()
counter()

结果是:

UnboundLocalError

有了 nonlocal 后,名称绑定目标变为最近的外层函数作用域中的 countnonlocal 只能指向外层函数作用域中已经存在的绑定;如果不存在,代码在编译阶段就会产生 SyntaxError。(docs.python.org)

def invalid():
    def inner():
        nonlocal missing

    return inner

这段代码无法正常定义,因为 missing 不存在于任何外层函数作用域。

4.1 多层嵌套时选择最近绑定

def outer():
    value = "outer"

    def middle():
        value = "middle"

        def inner():
            nonlocal value
            value = "changed"

        inner()
        return value

    middle_result = middle()
    return value, middle_result

print(outer())

输出:

('outer', 'changed')

inner() 中的 nonlocal value 修改的是 middle()value,而不是 outer()value。如果一个名称在多个外层函数中绑定,nonlocal 选择最近的一层。(docs.python.org)

4.2 修改对象不一定需要 nonlocal

def make_log():
    records = []

    def add(item):
        records.append(item)

    return add

add = make_log()
add("a")
add("b")

这里没有给 records 重新赋值,而是调用列表对象的 append() 方法,因此不需要 nonlocal

但下面的版本需要:

def make_log():
    records = []

    def add(item):
        nonlocal records
        records = records + [item]

    return add

records + [item] 创建了新列表,随后 records = ... 重新绑定了名称。

这也是“可变对象”和“名称绑定”经常混淆的地方:

操作 是否重新绑定外层名称 是否需要 nonlocal
items.append(x)
items[x] = value
items += [x] 对列表通常是原地修改,但语义仍属于赋值操作 在闭包内应声明 nonlocal
items = items + [x]
items = new_items

对于 items += [x],即使某些对象类型会执行原地更新,语法上仍是增强赋值;在函数作用域中它会触发局部绑定规则,因此处理闭包变量时应明确写出 nonlocal


5. global:把当前代码块中的名称指向模块全局绑定

global 用于声明:当前代码块中对指定名称的读取和绑定,都针对包含该代码块的模块全局命名空间。

count = 0

def increment():
    global count
    count += 1
    return count

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

没有 global 时,count += 1 会被视为对函数局部名称的重新绑定,从而产生 UnboundLocalError

globalnonlocal 的区别可以概括为:

声明 绑定目标
不声明 当前函数局部作用域
nonlocal x 最近的外层函数作用域
global x 当前模块的全局作用域
value = "module"

def outer():
    value = "outer"

    def inner():
        global value
        value = "changed"

    inner()
    return value

print(outer())  # outer
print(value)    # changed

这里 inner() 修改的是模块级 value,所以 outer() 中同名的局部 value 不受影响。

globalnonlocal 都是解析器指令,作用范围是整个当前代码块,而不是从语句所在行开始。声明必须位于当前代码块中对该名称的使用或赋值之前;否则会产生 SyntaxError。此外,写在传给 exec() 的字符串中的 global 不会改变外层函数代码块的解析规则。(docs.python.org)

def invalid():
    print(value)
    global value

这段代码会在编译阶段失败,而不是运行到 print() 时才决定作用域。

5.1 global 不是跨模块全局变量

“全局”是相对于当前模块而言的,不是整个 Python 进程中的唯一变量。

假设有两个模块:

# settings.py
value = 1
# worker.py
value = 2

def show():
    global value
    return value

worker.py 中的 global value 指向 worker 模块的 value,不会指向 settings.value。如果需要修改另一个模块中的值,必须明确访问模块对象:

import settings

settings.value = 3

这仍然不是推荐用来共享复杂状态的方式,因为模块级可变状态会增加调用关系和测试隔离的难度。


6. 迟绑定:闭包读取的是调用时的当前绑定

迟绑定指的是:闭包中的自由变量通常不会在函数创建时立即复制成一个固定值;函数执行时,它会读取闭包单元当前保存的对象。

def make_reader():
    value = "first"

    def read():
        return value

    def replace():
        nonlocal value
        value = "second"

    return read, replace

read, replace = make_reader()

print(read())  # first
replace()
print(read())  # second

执行过程如下:

  1. make_reader() 创建外层绑定 value = "first"
  2. read() 捕获 value 对应的闭包单元。
  3. replace() 通过 nonlocal 修改同一个闭包单元。
  4. 再次调用 read() 时,它读取到的是 "second"

这里的“迟”不是指函数代码延迟编译,而是指自由变量的对象值在函数调用时解析和读取。Python 文档也明确指出,自由变量的名称解析发生在运行时。(docs.python.org)


7. 循环变量导致的经典迟绑定问题

最常见的错误出现在循环中创建函数:

functions = []

for i in range(3):
    functions.append(lambda: i)

print([func() for func in functions])

很多人预期输出:

[0, 1, 2]

实际输出是:

[2, 2, 2]

推导过程是:

  1. 第一次循环创建函数 lambda: i,它引用循环变量 i
  2. 第二次循环创建另一个函数,但仍然引用同一个循环变量 i
  3. 第三次循环同样如此。
  4. 循环结束后,i 的当前值为 2
  5. 函数真正被调用时,三个函数都读取 i 的当前绑定,因此都得到 2

这不是三个闭包各自保存了三个值,而是三个函数共享对同一个绑定的访问。

7.1 用默认参数在创建时保存值

函数参数的默认值是在函数定义执行时计算的,因此可以用默认参数把当前循环值固定下来:

functions = []

for i in range(3):
    functions.append(lambda i=i: i)

print([func() for func in functions])

输出:

[0, 1, 2]

这里的 i=i 有两个不同角色:

  • 左边的 i 是参数名称;
  • 右边的 i 是创建函数时从当前作用域读取的循环变量。

创建第一个函数时,等价于保存默认值 0;创建第二个函数时保存 1;创建第三个函数时保存 2。调用时,参数已经有默认值,不再依赖外层循环变量。

也可以使用工厂函数:

def make_reader(value):
    def read():
        return value

    return read

functions = [make_reader(i) for i in range(3)]

print([func() for func in functions])

输出同样是:

[0, 1, 2]

工厂函数的优点是把“每次循环创建一个独立外层绑定”的意图表达得更清楚。

7.2 迟绑定不只发生在 lambda

下面的普通函数同样存在问题:

functions = []

for i in range(3):
    def read():
        return i

    functions.append(read)

print([func() for func in functions])  # [2, 2, 2]

lambda 只是创建小函数的简写,并没有改变作用域规则。


8. 闭包与装饰器的关系

装饰器本质上经常同时使用:

  1. 函数对象;
  2. 外层函数参数;
  3. 内部包装函数;
  4. 闭包保存配置或被包装函数。
from functools import wraps

def repeat(times):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            result = None
            for _ in range(times):
                result = func(*args, **kwargs)
            return result

        return wrapper

    return decorator

@repeat(3)
def greet(name):
    print(f"Hello, {name}")

greet("Ada")

输出:

Hello, Ada
Hello, Ada
Hello, Ada

这里的变量关系是:

repeat(3)
└── times = 3
    └── decorator(func)
        └── func = greet
            └── wrapper(*args, **kwargs)

wrapper() 使用了外层的 timesfunc,因此它们是自由变量。@repeat(3) 先调用 repeat(3) 得到 decorator,再把 greet 传给 decorator,最后用返回的 wrapper 替换原来的 greet 名称。

可以观察闭包:

print(greet.__name__)                 # greet
print(greet.__wrapped__.__name__)     # greet
print(greet.__code__.co_freevars)     # 可能包含 func、times

@wraps(func) 不负责实现闭包;它的作用是复制被包装函数的部分元数据,并设置 __wrapped__,避免包装后函数显示为普通的 wrapper。闭包机制本身来自 wrappertimesfunc 的自由变量引用。

8.1 参数化装饰器中的状态

如果装饰器需要维护调用次数,可以使用 nonlocal

from functools import wraps

def count_calls(func):
    calls = 0

    @wraps(func)
    def wrapper(*args, **kwargs):
        nonlocal calls
        calls += 1
        print(f"call #{calls}")
        return func(*args, **kwargs)

    return wrapper

@count_calls
def add(a, b):
    return a + b

print(add(1, 2))
print(add(3, 4))

输出:

call #1
3
call #2
7

这里 calls 属于装饰器调用创建的外层作用域。每个被装饰函数都会得到自己的 calls 绑定,因此两个不同函数之间不会天然共享计数器。


9. 推导式、循环作用域与函数作用域

Python 3 中,列表推导式的循环变量不会泄漏到外部作用域:

values = [i for i in range(3)]

try:
    print(i)
except NameError:
    print("i is not defined")

输出:

i is not defined

但“推导式有自己的作用域”不意味着它会自动解决所有迟绑定问题:

functions = [lambda: i for i in range(3)]

print([func() for func in functions])

结果仍然是:

[2, 2, 2]

推导式内部的多个 lambda 仍然引用推导式作用域中的同一个 i 绑定。要固定值,仍然使用默认参数:

functions = [lambda i=i: i for i in range(3)]

print([func() for func in functions])  # [0, 1, 2]

生成器表达式还会延迟迭代和计算,因此更容易把“创建时间”和“执行时间”混在一起:

functions = (lambda: i for i in range(3))

for func in functions:
    print(func())

这里每次取出一个函数后立即调用,可能依次输出:

0
1
2

但如果先把函数全部收集起来:

functions = list(lambda: i for i in range(3))
print([func() for func in functions])

则会得到:

[2, 2, 2]

关键不是 lambda 是否位于列表或生成器中,而是函数调用发生时,所引用的绑定当前是什么值。


10. 类作用域不是普通的外层函数作用域

类定义体是一个代码块,但类作用域不会像函数作用域那样自动成为方法的闭包外层作用域。(docs.python.org)

class Config:
    prefix = "app"

    def show(self):
        return prefix

调用:

Config().show()

通常会得到:

NameError: name 'prefix' is not defined

方法中的 prefix 不会自动查找 Config 类命名空间。应当显式通过类或实例访问:

class Config:
    prefix = "app"

    def show(self):
        return self.prefix

print(Config().show())  # app

或者:

class Config:
    prefix = "app"

    @classmethod
    def show(cls):
        return cls.prefix

类体中的名称可以直接被后续类体语句使用:

class Config:
    prefix = "app"
    name = prefix + "-worker"

但方法代码块在之后执行时,遵循自己的名称解析规则,而不是把类字典作为 LEGB 中的普通 E 层。

Python 3.14 还包含标注作用域这一版本相关变化:标注、类型形参列表和 type 语句等场景使用特殊的 annotation scope;它们大体类似函数作用域,但如果紧邻类作用域,则可以访问外层类命名空间。这不能推广到普通方法闭包。(docs.python.org)


11. 诊断作用域问题的方法

11.1 先检查名称在哪个代码块被绑定

看到 NameErrorUnboundLocalError 时,先搜索当前代码块中所有可能的绑定操作:

  • 普通赋值;
  • 增强赋值;
  • 参数;
  • for 循环目标;
  • with ... as
  • except ... as
  • 函数或类定义;
  • import
  • globalnonlocal

例如:

total = 10

def calculate():
    if False:
        total = 20

    return total

即使 if False 永远不会执行,total = 20 仍然是当前函数代码块中的绑定操作,因此 return total 会读取尚未绑定的局部 total,最终抛出 UnboundLocalError

11.2 用 inspect 查看闭包

import inspect

def make_multiplier(factor):
    def multiply(value):
        return value * factor

    return multiply

double = make_multiplier(2)

print(inspect.getclosurevars(double))

典型结果类似:

ClosureVars(
    nonlocals={'factor': 2},
    globals={},
    builtins={},
    unbound=set()
)

inspect.getclosurevars() 可以把函数使用到的非局部变量、全局变量、内置变量和未绑定名称分类展示,适合诊断装饰器、回调和工厂函数。

11.3 用 dis 观察局部变量和闭包变量的区别

import dis

def make_reader():
    value = 10

    def read():
        return value

    return read

reader = make_reader()
dis.dis(reader)

在 CPython 中,闭包变量通常会通过 LOAD_DEREF 一类的字节码访问,而局部变量通常使用 LOAD_FAST。这属于 CPython 的实现观察,不应把具体字节码名称当作所有 Python 实现都必须保持的公共语言规范。

更可靠的判断仍然是:

print(reader.__code__.co_freevars)

它直接说明代码对象需要哪些自由变量名称。


12. 闭包、生命周期与资源保留

闭包会延长其捕获对象的生命周期:

def make_handler():
    large_data = bytearray(10_000_000)

    def handler():
        return len(large_data)

    return handler

handler = make_handler()

只要 handler 仍然存活,large_data 就可能继续被闭包引用。这里没有内存泄漏这一必然结论,但有一个明确的生命周期关系:闭包引用会阻止相关对象因“没有引用”而被回收。

如果只需要少量配置,不应无意中捕获巨大的对象:

def make_handler(config):
    timeout = config["timeout"]

    def handler():
        return timeout

    return handler

这个版本只捕获 timeout,而不是整个 config。当然,若 timeout 本身是复杂对象,仍然需要根据实际引用关系判断生命周期。

闭包捕获的也可能是资源对象:

def make_reader(file):
    def read_one():
        return file.readline()

    return read_one

返回的函数依赖外部文件对象是否仍然打开。调用者如果提前关闭文件,闭包并不会自动恢复资源:

with open("data.txt", encoding="utf-8") as file:
    reader = make_reader(file)

# 文件已经关闭
reader()  # ValueError: I/O operation on closed file

闭包保存的是引用和绑定,不是资源生命周期管理器。资源边界仍应通过 with、上下文管理器或明确的打开与关闭协议处理。


13. 闭包状态与并发

nonlocal 让多个调用共享同一个闭包状态,但它不会自动提供并发安全性:

def make_counter():
    count = 0

    def increment():
        nonlocal count
        count += 1
        return count

    return increment

在单线程顺序调用时,它表现为简单计数器。若多个线程共享同一个 increment,则多个调用会访问同一个闭包单元。线程之间共享进程资源和对象,访问共享可变状态需要显式协调,否则可能出现竞态或非预期结果。(docs.python.org)

可以用锁保护状态:

from threading import Lock

def make_counter():
    count = 0
    lock = Lock()

    def increment():
        nonlocal count
        with lock:
            count += 1
            return count

    return increment

这里锁和计数值都被闭包捕获:

  • count 是需要重新绑定的非局部变量;
  • lock 是被读取并调用的非局部对象;
  • with lock 保证同一时刻只有一个线程执行受保护区域。

这并不意味着所有闭包都需要锁。只有当多个执行流共享同一个闭包实例,并且至少一个执行流会修改共享状态时,才需要考虑同步。


14. 常见误解对照

14.1 “函数定义在哪里,就一定能访问那里的所有名称”

不准确。函数可以访问其定义位置的外层函数变量,但类作用域对普通方法不是这样的闭包外层:

class A:
    x = 1

    def method(self):
        return x  # 不会自动读取 A.x

应写成:

class A:
    x = 1

    def method(self):
        return self.x

14.2 “global 表示全局唯一变量”

不准确。global 指向当前模块的全局命名空间,不是整个进程、所有模块共享的命名空间。

14.3 “只要读取外层变量就需要 nonlocal

不准确:

def outer():
    value = 1

    def inner():
        return value  # 只读,不需要 nonlocal

    return inner

只有内部代码需要对外层名称重新绑定时,才需要 nonlocal

14.4 “列表推导式解决了循环闭包问题”

不准确。推导式避免了循环变量泄漏,但其中创建的函数仍可能迟绑定同一个推导式变量:

funcs = [lambda: i for i in range(3)]
print([f() for f in funcs])  # [2, 2, 2]

应使用默认参数或工厂函数固定值。

14.5 “闭包保存了变量创建时的值”

不准确。默认参数通常保存创建时的值;普通闭包通常保存对绑定单元的访问。

def make_functions():
    value = 1

    def read():
        return value

    def change():
        nonlocal value
        value = 2

    return read, change

read() 的结果会随 change() 改变,正是因为它读取的是同一个外层绑定。


15. 一套可执行的判断流程

遇到作用域问题时,可以按以下顺序推导,而不是先凭经验添加 globalnonlocal

  1. 确定当前代码块:名称出现在模块、函数、类体、推导式还是动态执行代码中?
  2. 扫描当前代码块的绑定操作:当前块是否对该名称赋值、接收为参数、导入或用作循环目标?
  3. 判断当前引用类型
    • 当前块有绑定:局部名称;
    • 当前块无绑定,但外层函数有:自由变量;
    • 外层函数也没有:继续到模块全局;
    • 模块全局没有:查内置名称。
  4. 如果要重新绑定外层函数变量:使用 nonlocal
  5. 如果要重新绑定模块变量:使用 global
  6. 如果函数在循环中创建:检查调用发生时循环变量是否已经改变;必要时用默认参数或工厂函数固定值。
  7. 如果是类方法:不要把类命名空间当作普通闭包外层,使用 selfcls 或类名显式访问。
  8. 如果仍然不确定:检查 __code__.co_freevars__closure__inspect.getclosurevars()

作用域的核心不是“变量属于哪一行”,而是“名称在什么代码块中绑定,以及引用发生时沿什么环境链查找”。nonlocalglobal 改变的是绑定目标;闭包保存的是外层绑定的可访问性;迟绑定说明读取发生在调用时,而不是函数创建时。掌握这三层关系,装饰器、回调、工厂函数和异步任务中的大多数名称问题都可以按规则逐步推导,而不必依赖记忆某个特殊写法。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。