AI 技术博客
返回首页
Python 基础 21 分钟阅读

作用域与命名空间:LEGB 规则与闭包雏形

Python作用域命名空间LEGB闭包

引言

这是第四章「函数」的第二篇。第一篇你学会了如何定义函数、传递参数和返回值——那是「函数怎么写」的问题。但从这篇开始,我们要解决一个更根本的问题:函数里的变量到底从哪来,又到哪里去?

你一定遇到过这样的困惑:明明在函数外面定义了变量 x,函数内部却报 NameError;或者函数内部修改了变量,外部值却没有变化;又或者闭包的结果与预期完全不符,调试半天才发现是变量绑定延迟导致的。这些问题的根源都指向同一个概念——作用域(Scope)。

作用域是 Python 变量查找的路径规则,它决定了「一个名字在当前代码位置能看到哪些绑定」。理解作用域,是写出正确、可维护 Python 代码的必经之路。本篇将带你完整梳理 LEGB 规则,通过 globals() 和 locals() 看清变量的实际布局,用 nonlocal 解决嵌套函数修改变量的难题,并揭示闭包中最为隐蔽的一个陷阱——变量延迟绑定。

阅读前提:已掌握第一篇的函数定义与参数传递。本篇不涉及装饰器(第 5 篇)、lambda 进阶用法(第 4 篇)。

概念与原理

命名空间:名字的地图

在深入作用域之前,必须先理解**命名空间(Namespace)**这个底层概念。

命名空间是一个「名字到对象」的映射表,本质上是一个字典。每次你执行 x = 5,Python 就在某个命名空间里存入一条记录:{'x': <int对象5>}。Python 中主要有四种命名空间:

  • 内置命名空间(Built-in):Python 启动时自动创建,包含 print、len、int、str 等内置函数和类型。所有代码都能访问它们,无需导入。
  • 全局命名空间(Global):当前模块(文件)的顶层命名空间。模块中所有在函数外部定义的变量都存放于此。
  • 局部命名空间(Local):函数被调用时临时创建的命名空间,存放该函数的局部变量。函数返回后销毁。
  • 嵌套函数的局部命名空间:当函数嵌套时,内层函数的局部命名空间独立于外层。

命名空间的生命周期各不相同:内置命名空间随 Python 解释器启动而产生,随退出而销毁;全局命名空间随模块加载而产生,随模块卸载而销毁;局部命名空间则随函数调用而产生,随函数返回而销毁。

你可以用 globals() 和 locals() 函数直接查看当前作用域的命名空间内容:

x = 10          # 写入全局命名空间

def sample():
    y = 20      # 写入局部命名空间
    print("globals keys:", list(globals().keys())[:5])
    print("locals:", locals())

sample()
print("global x:", x)     # 10,在全局命名空间中
# print("local y:", y)   # NameError!y 只在 sample 的局部命名空间中存在

注意:locals() 返回的是字典的副本,对其修改不会真正影响局部变量;而 globals() 返回的是真实的全局命名空间字典,对其修改会立即生效:

x = 1

def modify_global():
    globals()['x'] = 99

modify_global()
print(x)  # 99

虽然 globals() 可以修改全局变量,但这是一种极不推荐的 hack 手法。正确的方式是使用 global 关键字声明意图。

LEGB 规则:名字查找的完整路径

当一个名字在代码中被引用时,Python 按照 LEGB 规则依次查找:

  1. L(Local):当前函数(或 lambda)的局部命名空间。
  2. E(Enclosing):外层函数的局部命名空间(仅当存在嵌套函数时)。
  3. G(Global):当前模块的全局命名空间。
  4. B(Built-in):Python 内置命名空间。

查找过程是从小到大逐层向外的,一旦在某一层找到匹配的名字,就停止查找并使用该绑定;如果四层都找不到,抛出 NameError。

x = "global"        # G

def outer():
    x = "enclosing" # E

    def inner():
        x = "local" # L
        print(x)    # 输出 local,L 层找到

    inner()
    print(x)        # 输出 enclosing,L 层(outer 自身)找到

outer()
print(x)            # 输出 global,L 层没有,G 层找到

当内层函数需要引用外层函数的变量时,E 层的查找变得至关重要。这也是闭包能够工作的基础。

修改全局变量:global 关键字

默认情况下,函数内部对变量的赋值操作会创建一个同名局部变量,而不是修改全局变量。这是 LEGB 规则的自然结果:赋值总是写入当前最内层的命名空间。

count = 0

def increment():
    count = count + 1   # 读取 L/E/G/B,但赋值时创建局部 count
    # 实际执行时:先读取 count(G 层找到,值为 0),再赋值给新局部变量
    # 但是!count + 1 中的 count 在赋值前尚未在局部定义,会报 UnboundLocalError

increment()

上面的代码会报 UnboundLocalError: local variable 'count' referenced before assignment。原因是:Python 在编译阶段看到函数体内有 count = count + 1 这种赋值语句,就把 count 标记为局部变量;但执行时右侧的 count 还没被赋值,所以报错。

要用 global 关键字声明「我要修改全局变量」:

count = 0

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

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

global 的含义是告诉 Python:在这个函数体内,所有对这个名字的赋值都作用于全局命名空间,查找时也优先在局部作用域之外寻找。

nonlocal:修改变量,但不突破到全局

nonlocal 是 Python 3 引入的关键字,用于在嵌套函数中修改外层函数(而非全局)的变量。它的查找规则是:从当前函数的外层开始,沿 Enclosing 链向上查找第一个匹配的变量绑定,然后修改它。

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

nonlocal 与 global 的关键区别:nonlocal 只能引用 Enclosing 链上的变量,不能引用全局变量;如果 Enclosing 链上找不到匹配变量,会报 SyntaxError。

def demo():
    x = 1
    def inner():
        nonlocal x  # 正确:x 在 outer 中存在
        x = 2
    inner()
    print(x)        # 2

demo()

如果 nonlocal 引用的变量在 Enclosing 链上不存在,Python 会在编译阶段直接报 SyntaxError,即使函数从未被调用也会出错:

# 这段代码无法通过编译(取消注释会报错)
# def bad_demo():
#     x = 1
#     def inner():
#         nonlocal y  # SyntaxError!y 在 Enclosing 链上不存在
#         y = 2
#     inner()

闭包:函数携带环境的记忆

闭包(Closure)是指一个函数对象记住了它定义时所处环境中的变量。即使外层函数已经返回,内层函数仍然可以通过 Enclosing 作用域访问那些变量。

闭包的形成有三个必要条件:

  1. 存在嵌套函数(一个函数定义在另一个函数内部)。
  2. 内层函数引用了外层函数的变量(Enclosing 作用域中的变量)。
  3. 外层函数返回了内层函数。
def make_multiplier(factor):
    """返回一个乘法器函数"""
    def multiplier(x):
        return x * factor  # factor 来自 Enclosing 作用域
    return multiplier

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

print(double(10))  # 20
print(triple(10))  # 30

double 和 triple 是两个独立的函数对象,但它们共享同一段代码(multiplier),只是 factor 这个变量的绑定不同。这就是闭包的强大之处——每个闭包都携带了自己的「环境快照」。

可以用 __closure__ 属性查看函数是否形成了闭包,以及它捕获了哪些变量:

print(double.__closure__)       # (<cell at 0x...: int object at 0x...>,)
print(double.__closure__[0].cell_contents)  # 2

__closure__ 是元组,每个元素是一个 cell 对象,保存着被捕获的变量的引用。如果函数没有形成闭包,__closure__ 为 None。

实现

下面通过三个递进的示例,展示作用域和闭包在实际编程中的应用。

示例一:日志计时器(闭包的实际用途)

一个常见的工程需求是测量函数执行时间。我们用一个闭包来实现一个通用的计时装饰器的雏形:

import time

def timer(label="函数"):
    """工厂函数:返回一个带计时功能的包装器"""
    def wrapper(func):
        def inner(*args, **kwargs):
            start = time.perf_counter()
            result = func(*args, **kwargs)
            elapsed = time.perf_counter() - start
            print(f"[{label}] {func.__name__} 耗时 {elapsed:.6f} 秒")
            return result
        return inner
    return wrapper

# 使用示例
@timer("计算")
def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

result = fibonacci(20)
print(f"结果: {result}")

这里 timer 返回 wrapper,wrapper 返回 inner,形成了一个两层闭包。label 和 func 变量被 inner 捕获,即使在 timer 和 wrapper 执行完毕后仍然可用。

示例二:数据隔离的私有状态

闭包可以用来创建具有私有状态的对象,而不必使用类:

def create_bank_account(initial_balance=0):
    """创建一个银行账户,余额对外不可见"""
    balance = initial_balance  # 私有变量,在闭包内维护

    def deposit(amount):
        nonlocal balance
        if amount <= 0:
            raise ValueError("存款金额必须为正数")
        balance += amount
        return balance

    def withdraw(amount):
        nonlocal balance
        if amount <= 0:
            raise ValueError("取款金额必须为正数")
        if amount > balance:
            raise ValueError("余额不足")
        balance -= amount
        return balance

    def get_balance():
        return balance

    return deposit, withdraw, get_balance

deposit, withdraw, get_balance = create_bank_account(1000)
print(get_balance())       # 1000
deposit(500)
print(get_balance())       # 1500
withdraw(200)
print(get_balance())       # 1300
# 无法直接访问 balance,只能通过接口操作

balance 变量在外部无法直接访问——这就是闭包实现的「信息隐藏」。虽然不如类的 __private 属性那么规范,但在轻量级场景中非常实用。

示例三:全局配置读取器

# 模拟一个配置文件
CONFIG = {
    "api_host": "https://api.example.com",
    "timeout": 30,
    "retries": 3
}

def make_config_getter(key):
    """工厂:返回一个读取指定配置项的函数"""
    def get():
        if key not in CONFIG:
            raise KeyError(f"配置项 '{key}' 不存在")
        return CONFIG[key]
    return get

get_host = make_config_getter("api_host")
get_timeout = make_config_getter("timeout")

print(get_host())       # https://api.example.com
print(get_timeout())    # 30

易错点与陷阱

陷阱一:闭包变量绑定的延迟问题(经典陷阱)

这是 Python 闭包中最经典、也最容易让人困惑的陷阱。闭包捕获的是变量的引用,而不是变量在捕获时刻的值。当闭包被调用时,才会去查找那个变量的当前值。

def make_functions():
    funcs = []
    for i in range(3):
        funcs.append(lambda: i)
    return funcs

fs = make_functions()
print([f() for f in fs])  # [2, 2, 2],不是预期的 [0, 1, 2]

原因分析:循环结束时 i 的值为 2。三个 lambda 都引用同一个变量 i(在 Enclosing 作用域中),而不是各自捕获 i 在当时的值。当 fs[0]() 被调用时,查找 i 得到的是最终值 2。同理 fs[1]() 和 fs[2]() 也查到 2。

修复方法一:用默认参数捕获当前值(默认参数在定义时求值):

def make_functions_fixed():
    funcs = []
    for i in range(3):
        funcs.append(lambda x=i: x)  # x=i 在定义时就求值
    return funcs

fs = make_functions_fixed()
print([f() for f in fs])  # [0, 1, 2] 正确

修复方法二:使用 functools.partial:

from functools import partial

def make_functions_partial():
    funcs = []
    for i in range(3):
        funcs.append(partial(lambda x: x, i))
    return funcs

fs = make_functions_partial()
print([f() for f in fs])  # [0, 1, 2] 正确

修复方法三:使用生成器表达式(隐式创建新的作用域):

fs = (lambda i=i: i for i in range(3))
print([f() for f in fs])  # [0, 1, 2] 正确

第三种方法之所以有效,是因为生成器表达式(以及列表推导式在 Python 3 中)会创建新的局部作用域,每次迭代都有独立的 i 绑定。

陷阱二:在循环中定义闭包并立即调用

# 看起来应该打印 0,1,2,3,4,实际打印 5,5,5,5,5
for i in range(5):
    def inner():
        return i
    print(inner())

每个 inner 都引用同一个 i,而循环执行完后 i 的值是 4(range(5) 的最后一个值)。修复方式同样是默认参数捕获:

for i in range(5):
    def inner(x=i):
        return x
    print(inner())

陷阱三:global 与 nonlocal 混用的误解

x = 1

def outer():
    x = 2
    def middle():
        x = 3
        def inner():
            nonlocal x  # 修改 middle 中的 x,不是 outer 中的
            x = 4
        inner()
        print("middle:", x)  # 4

    outer_local = x
    middle()
    print("outer:", x)      # 2,outer 的 x 不受影响

outer()
print("global:", x)        # 1

nonlocal 只影响 Enclosing 链上最近的那个绑定,不会影响更外层的同名变量,更不会触及全局变量。这种精确的作用域控制使得闭包中的状态管理变得可靠。

陷阱四:在类定义中使用闭包变量

def make_class():
    secret = "hidden"

    class MyClass:
        def get_secret(self):
            return secret  # 引用 Enclosing 作用域

    return MyClass

C = make_class()
print(C().get_secret())  # hidden

类体在定义时会创建一个局部的命名空间,但类体内部不能直接使用 nonlocal 来修改变量——类体不是函数体。如果需要修改 Enclosing 变量,必须在类的方法中使用 nonlocal 或 global。

小结

本篇深入讲解了 Python 的作用域机制和命名空间概念。我们从命名空间的四种类型出发,系统梳理了 LEGB 规则——名字查找的路径:先从当前函数局部(L)找,找不到再到外层函数(E),再找不到到全局(G),最后到内置(B)。通过 globals() 和 locals(),我们直接看到了变量的物理布局;通过 global 和 nonlocal,我们掌握了修改变量绑定的正确手段。最后,我们通过闭包的三个实际应用(计时器、私有状态、配置读取器)理解了闭包的工程价值,并用经典的「循环中定义闭包」陷阱揭示了变量延迟绑定的本质。

作用域是 Python 最核心的机制之一。理解它,你就能写出行为可预测的代码,避免那些「明明定义了却找不到」或「改了却没生效」的神秘 bug。下一篇我们将学习可变参数(*args、**kwargs)和解包技术,这是函数接口设计的最后一块拼图。

练习与思考题

  1. 阅读以下代码,预测输出,然后用 Python 实际运行验证:

    x = 10
    def f():
        print(x)
        x = 20
    f()

    为什么会报错?如何在 f 内部正确访问外部的 x?

  2. 编写一个函数 create_accumulator(),返回一个函数,每次调用该函数时累加传入的值并返回当前总和。例如:acc = create_accumulator(),acc(10) 返回 10,acc(5) 返回 15,acc(-3) 返回 12。

  3. 分析下面代码的输出,并解释原因:

    def make_factories():
        factories = []
        for color in ["red", "green", "blue"]:
            factories.append(lambda: color)
        return factories
    f1, f2, f3 = make_factories()
    print(f1(), f2(), f3())

    用三种不同的方法修复它,使输出为 red green blue。

  4. 思考题:Python 为什么采用 LEGB 查找顺序而不是更直观的「从外到内」?从性能和语言设计角度分析这种选择的意义。