Python 函数与作用域:LEGB 规则、闭包陷阱与实战避坑指南

2026-05-26 29 预计阅读时间:1 分钟
来源:realpython.com AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:6 分钟

写 Python 的人大多遇到过这类诡异时刻:变量明明赋值了,函数里却报 UnboundLocalError;嵌套函数改外层变量死活不生效;默认参数列表居然在多次调用间"共享"了状态。这些问题都指向同一个根——作用域规则。理解 LEGB、搞清参数与实参的区别、掌握闭包的边界,才能写出不靠运气运行的代码。

LEGB:Python 查找变量的四层顺序

Python 遇到一个变量名时,按 Local → Enclosing → Global → Built-in 四层依次搜索:

  • L — Local:当前函数体内的局部命名空间。
  • E — Enclosing:外层嵌套函数的命名空间(如果有闭包)。
  • G — Global:模块级命名空间,即 .py 文件顶层定义的变量。
  • B — Built-inbuiltins 模块,如 lenprintException

找到即停;找不到则抛 NameError。一个经典坑:

x = 10

def update():
    x = x + 1  # ❌ UnboundLocalError
    return x

update()

函数体内出现了赋值语句 x = ...,Python 编译器在定义时就把 x 标记为局部变量。执行到 x + 1 时,局部 x 还没赋值,于是报错。修复方式是显式声明:

x = 10

def update():
    global x       # 告诉编译器:x 是全局的,不要当局部
    x = x + 1      # ✅ 正常
    return x

print(update())    # 11
print(x)           # 11

同理,嵌套函数想改外层变量用 nonlocal,而不是 global——两者作用层级不同,混用是另一个常见 bug。

参数 vs 实参:别把形参当变量类型

函数定义里的 parameter(形参) 是名字占位符;调用时传入的 argument(实参) 才是具体值。区分这两件事有助于理解默认参数陷阱:

def append_item(item, bucket=[]):
    bucket.append(item)
    return bucket

print(append_item("a"))  # ['a']
print(append_item("b"))  # ['a', 'b']  —— 不是 ['b']!

默认值 [] 在函数定义时一次性求值并绑定到形参 bucket,后续调用共享同一个列表对象。正确做法是把默认值换成不可变对象,在函数体内再创建:

def append_item(item, bucket=None):
    if bucket is None:
        bucket = []
    bucket.append(item)
    return bucket

print(append_item("a"))  # ['a']
print(append_item("b"))  # ['b']  ✅

这条规则对所有可变默认值(dictset、用户自定义对象)都适用。

内嵌函数与闭包:状态捕获的边界

内嵌函数(inner function)可以捕获外层函数的变量,形成闭包。但捕获的是变量引用,不是值的快照:

def make_counters():
    counters = []

    def counter(i):
        counters.append(i)
        return counters

    return counter

c = make_counters()
print(c(1))  # [1]
print(c(2))  # [1, 2]

闭包 counter 持有外层 counters 列表的引用,每次调用都往同一个列表追加。如果你想让每个闭包持有独立状态,需要用默认参数做"值快照":

def make_adders():
    adders = []

    for n in range(3):
        # 用默认参数 n_val=n 在定义时冻结当前 n 的值
        def adder(x, n_val=n):
            return x + n_val
        adders.append(adder)

    return adders

fns = make_adders()
print(fns[0](10))  # 10 + 0 = 10
print(fns[1](10))  # 10 + 1 = 11
print(fns[2](10))  # 10 + 2 = 12

如果不加 n_val=n,三个 adder 都会捕获同一个变量 n,循环结束后 n=2,结果全部返回 12——这是闭包循环陷阱的经典表现。

实战速查:作用域诊断 checklist

遇到变量行为异常时,按以下顺序排查:

  1. 函数体内有没有赋值语句? 有赋值 → Python 默认当局部变量;想改外层加 global / nonlocal
  2. 默认参数是不是可变对象? 是 → 改成 None,在函数体内初始化。
  3. 闭包在循环里创建? → 用默认参数冻结迭代变量,或用 functools.partial
  4. 变量名和 built-in 重名? → LEGB 的 B 层会被覆盖;避免用 listdictid 做变量名。
  5. 类方法里的 self.xxx 和模块级 xxx 混淆? → 类体本身是一个命名空间,self 是实例属性,不带 self 的是类属性,三者查找路径不同。

一个综合示例,把上述要点串起来:

# scope_demo.py —— 可直接运行

total = 0  # 模块级全局变量

def register(name, tags=None):
    """演示默认参数、global、闭包"""
    global total
    total += 1

    if tags is None:          # ✅ 避免可变默认值陷阱
        tags = []

    def badge(level, _name=name):  # ✅ 闭包冻结 name
        return f"[{_name}|L{level}]"

    tags.append(badge(1))
    return tags

# 三次独立调用,tags 不共享
print(register("alice"))        # ['[alice|L1]']
print(register("bob"))          # ['[bob|L1]']
print(register("carol"))        # ['[carol|L1]']
print("total registrations:", total)  # 3

运行方式:

python scope_demo.py

作用域规则不复杂,但编译期标记和运行期查找的分离容易让人踩坑。记住:赋值决定归属,LEGB 决定查找顺序,默认参数在定义时求值——这三条足以覆盖日常绝大多数作用域问题。


相关推荐