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

作用域与命名空间: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()` 函数直接查看当前作用域的命名空间内容: ```python 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()` 返回的是真实的全局命名空间字典,对其修改会立即生效: ```python 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`。 ```python 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 规则的自然结果:赋值总是写入当前最内层的命名空间。 ```python 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` 关键字声明「我要修改全局变量」: ```python count = 0 def increment(): global count count = count + 1 increment() increment() print(count) # 2 ``` `global` 的含义是告诉 Python:在这个函数体内,所有对这个名字的赋值都作用于全局命名空间,查找时也优先在局部作用域之外寻找。 ### nonlocal:修改变量,但不突破到全局 `nonlocal` 是 Python 3 引入的关键字,用于在嵌套函数中修改外层函数(而非全局)的变量。它的查找规则是:从当前函数的外层开始,沿 Enclosing 链向上查找第一个匹配的变量绑定,然后修改它。 ```python 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`。 ```python def demo(): x = 1 def inner(): nonlocal x # 正确:x 在 outer 中存在 x = 2 inner() print(x) # 2 demo() ``` 如果 `nonlocal` 引用的变量在 Enclosing 链上不存在,Python 会在**编译阶段**直接报 `SyntaxError`,即使函数从未被调用也会出错: ```python # 这段代码无法通过编译(取消注释会报错) # def bad_demo(): # x = 1 # def inner(): # nonlocal y # SyntaxError!y 在 Enclosing 链上不存在 # y = 2 # inner() ``` ### 闭包:函数携带环境的记忆 闭包(Closure)是指一个函数对象**记住了它定义时所处环境中的变量**。即使外层函数已经返回,内层函数仍然可以通过 Enclosing 作用域访问那些变量。 闭包的形成有三个必要条件: 1. 存在嵌套函数(一个函数定义在另一个函数内部)。 2. 内层函数引用了外层函数的变量(Enclosing 作用域中的变量)。 3. 外层函数返回了内层函数。 ```python 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__` 属性查看函数是否形成了闭包,以及它捕获了哪些变量: ```python print(double.__closure__) # (<cell at 0x...: int object at 0x...>,) print(double.__closure__[0].cell_contents) # 2 ``` `__closure__` 是元组,每个元素是一个 cell 对象,保存着被捕获的变量的引用。如果函数没有形成闭包,`__closure__` 为 `None`。 ## 实现 下面通过三个递进的示例,展示作用域和闭包在实际编程中的应用。 ### 示例一:日志计时器(闭包的实际用途) 一个常见的工程需求是测量函数执行时间。我们用一个闭包来实现一个通用的计时装饰器的雏形: ```python 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` 执行完毕后仍然可用。 ### 示例二:数据隔离的私有状态 闭包可以用来创建具有私有状态的对象,而不必使用类: ```python 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` 属性那么规范,但在轻量级场景中非常实用。 ### 示例三:全局配置读取器 ```python # 模拟一个配置文件 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 闭包中最经典、也最容易让人困惑的陷阱。闭包捕获的是**变量的引用**,而不是变量在捕获时刻的值。当闭包被调用时,才会去查找那个变量的当前值。 ```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。 修复方法一:用默认参数捕获当前值(默认参数在定义时求值): ```python 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`: ```python 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] 正确 ``` 修复方法三:使用生成器表达式(隐式创建新的作用域): ```python fs = (lambda i=i: i for i in range(3)) print([f() for f in fs]) # [0, 1, 2] 正确 ``` 第三种方法之所以有效,是因为生成器表达式(以及列表推导式在 Python 3 中)会创建新的局部作用域,每次迭代都有独立的 `i` 绑定。 ### 陷阱二:在循环中定义闭包并立即调用 ```python # 看起来应该打印 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)` 的最后一个值)。修复方式同样是默认参数捕获: ```python for i in range(5): def inner(x=i): return x print(inner()) ``` ### 陷阱三:global 与 nonlocal 混用的误解 ```python 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 链上**最近的**那个绑定,不会影响更外层的同名变量,更不会触及全局变量。这种精确的作用域控制使得闭包中的状态管理变得可靠。 ### 陷阱四:在类定义中使用闭包变量 ```python 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 实际运行验证: ```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. 分析下面代码的输出,并解释原因: ```python 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 查找顺序而不是更直观的「从外到内」?从性能和语言设计角度分析这种选择的意义。