资讯动态

Python作用域完全指南:LEGB规则、闭包与变量查找

发布时间:2026/9/17 18:26:27 来源:尧图企业网站定制
1. 作用域到底是个什么东西1.1 名字到对象的映射约定写过一段时间 Python 的人多半都碰到过这样一个报错name 张三 def hello(): print(name) hello()能正常运行输出“张三”。但如果你改成下面这样name 张三 def hello(): print(name) name 李四 hello()直接抛UnboundLocalError: local variable name referenced before assignment。两个程序唯一的差别就是函数内部多了一行赋值结果天翻地覆。之所以会这样关键就在于 Python 对“名字”的一套管理约定——名字和对象是分开的赋值操作把名字绑定到一个对象上而读取操作需要按照某种规则找到这个名字对应的对象。这套规则就是我们常说的“作用域”Scope。从底层看Python 解释器在编译函数时会扫描整个函数体发现函数内部对name有赋值语句于是就把name标记为函数的局部变量local variable。后面执行到print(name)时解释器认为你要打印一个“还没绑定的局部变量”于是直接给你报错——它压根不会去看外层的那个“张三”。这种“先扫描后执行”的机制让不少从 C、Java 转过来的开发者非常不适应。C 语言里变量要么是局部的要么是全局的你很容易通过声明位置判断范围。而 Python 里判断一个变量是局部还是全局看的是“这个函数体内有没有对这个名字的赋值操作”而不是看它出现在哪个位置。这个核心差异是理解 Python 作用域的第一步。1.2 Python 四大作用域LEGB 规则由于篇幅原因这里直接抛出 Python 官方文档中最核心的一张图——LEGB规则。它把变量查找顺序分为四层从内到外分别是缩写全称说明典型场景LLocal局部作用域函数内部、列表推导式Python 3中、lambda内部EEnclosing嵌套函数的外层作用域外层函数的局部变量即闭包场景GGlobal全局作用域模块级别的变量BBuilt-in内建作用域print、len、int等内建函数和异常类查找时严格按照 Local → Enclosing → Global → Built-in 的顺序找到即返回找不到就抛NameError。这里特别要强调的是嵌套函数场景。假设你写了一个工厂函数返回一个内部函数def outer(): x 1 def inner(): print(x) # 这里的 x 从哪里找 return inner func outer() func() # 输出 1inner内部没有定义x于是它往上一层在outer的 Enclosing 作用域里找到了x 1。这个“往外层逐个查找变量”的过程就是作用域链的查找行为。更直观的理解是Python 在调用一个函数时会为它创建一个独立的命名空间但这个命名空间不是孤立的它通过一个链条和自己的外部环境关联起来。当你访问一个变量时Python 会从当前函数的最内层命名空间开始沿着这条链条逐级向外查找直到找到为止。这也是global和nonlocal两个关键字存在的根本原因——它们本质上是在告诉编译器“这个变量不要标记为当前作用域的局部变量请去到外层或者全局作用域里去寻找绑定关系。”没有它们Python 就无法区分“我要重新定义一个局部变量”和“我想读取或修改一个外部变量”这两种意图。1.3 命名空间与生命周期作用域和命名空间是一体两面的关系。打个比方作用域是一张“查找地图”而命名空间就是地图上的“登记台账”。每次函数调用Python 都会创建一张新的局部台账函数返回台账销毁。这也是为什么递归调用时每层都有自己的局部变量互不干扰。全局命名空间则伴随模块的整个生命周期存在从模块被导入那一刻创建到进程结束才销毁。内建命名空间则由解释器启动时初始化包含了print、len、range等常用对象。生命周期差异带来的一个典型现象是如果你在模块顶层定义了变量result在函数内部直接读它完全没问题但如果你希望在函数内部修改全局变量却没有声明globalPython 会视为“创建一个同名的局部变量”并让全局变量与你“失联”。很多新人在这里栽跟头写了半天发现全局变量压根没变就是因为没有理解这个“台账新建”机制。2. 作用域链的查找流程从内到外是怎么回事2.1 一次变量访问的背后发生了什么为了把作用域链讲透建议先看下面这个稍微复杂一点的嵌套示例count 100 # 全局变量 def outer(): count 50 # 外层局部变量 def inner(): print(count) # 输出多少 inner() outer()猜一猜这个count会打印出多少答案是 50。因为查找过程如下首先在inner的局部命名空间中查找count没找到沿着作用域链到outer的 Enclosing 作用域找到count 50直接使用不再继续向全局查找。如果我把outer里的count 50注释掉呢这时inner内部没有局部count外层也没有于是沿着链条一路向外在全局命名空间中找到count 100打印 100。如果全局也没有呢最后会在内建命名空间中查找还是找不到抛NameError: name count is not defined。这个链式查找就是作用域链的全部秘密——它是一串从内到外排列的命名空间。嵌套层数越多链条越长查找越慢这也是为什么极端情况下局部变量访问比全局变量访问快一点点全局访问比内建访问快一点点。当然现代 Python 解释器做了很多优化这点性能差异在实际开发中几乎可以忽略但在写高性能代码时把高频访问的全局变量赋给一个局部变量仍然是一个容易且有效的微优化手段。2.2 一个例子搞懂闭包与作用域链的配合闭包Closure是理解作用域链最典型的场景。来看这个“计数器”实现def create_counter(): count 0 def increment(): nonlocal count count 1 return count return increment counter_a create_counter() print(counter_a()) # 1 print(counter_a()) # 2 counter_b create_counter() print(counter_b()) # 1这里有两个值得注意的细节。第一个是nonlocal count。在increment函数内部count 1既是对count的读取也是赋值。按照之前说的“扫描规则”如果不加nonlocalPython 会把count标记为increment的局部变量结果一执行就报UnboundLocalError。加了nonlocal之后Python 知道count是 Enclosing 作用域里的变量于是修改的就不是局部变量而是外层create_counter里绑定的那个整数对象。第二个是闭包的生命周期。create_counter函数早已返回理论上它的局部命名空间应该销毁了。但由于increment这个内部函数还持有对count的引用Python 会把被闭包引用的变量打包进increment.__closure__属性中让它们继续存活。你可以试试打印print(counter_a.__closure__[0].cell_contents) # 2这个设计精妙地保障了外部函数已经结束之后内部函数依然能正确访问外层变量。一门语言要做到这一点作用域链是基础支撑设施。而这也正是闭包在很多框架中被广泛用于数据隐藏的原因——通过闭包你能模拟出私有变量的效果外部无法直接访问count只能通过返回的increment函数操作它。2.3 作用域链查找不是“动态”的一个常见的误区是作用域链的查找是否在运行时按调用关系动态决定很多人以为函数被某个对象调用就能访问该对象的局部变量。其实不对。Python 的作用域链是按“词法作用域”Lexical Scoping决定的也就是在代码定义时就确定了的与函数在哪儿被调用无关。来看经典的“函数作为参数传入”示例def func(): x 1 def call(inner): print(inner()) call(lambda: x) func() # 输出 1如果你把lambda: x传给另一个函数它依然能访问func内部的x因为lambda定义的位置在func内部它的作用域链已经绑定了func的局部命名空间。这与 JavaScript 里的闭包行为很像都是词法作用域。这带来的实际意义是作用域链的形态在代码编译阶段就已固定你不需要考虑函数运行时被谁调用只需要关心它被定义在哪儿。这个特性也让静态分析工具如各类 linter能很准确地判断一个变量引用是否合理。3. 实操中最容易踩坑的作用域问题3.1 global 的误用与正确打开方式先问一个很常见的问题模块顶层定义的变量怎么在函数里修改它很多新手是这样写的count 0 def add(): count count 1 # 报错 UnboundLocalError add()报错原因前面分析过函数内部有赋值所以count被当作局部变量读取时却还没绑定。正确的写法是在函数内声明global countcount 0 def add(): global count count count 1 add() print(count) # 1但一旦代码里global用多了就说明设计上可能有问题。全局变量破坏了函数的纯度让函数不再只依赖自己的参数和返回值测试时需要额外维护外部状态。更合理的做法是把状态封装进类或者使用闭包尽量减少模块级别的可变全局变量。如果只是读取全局变量不需要加global解释器会自动沿作用域链查找。这一点一定要记住读取不需要声明修改才需要。3.2 nonlocal 的所有细节nonlocal是 Python 3 引入的用于在内层函数中修改外层函数的局部变量。它和global的差别在于global直接跳到模块级nonlocal只向上查找 Enclosing 作用域跳过全局作用域也不能指向内建作用域。nonlocal有一个严格限制它只能绑定位到“存在的外层局部变量”。如果你写nonlocal x但外层根本没有定义x解释器会直接报SyntaxError: no binding for nonlocal x found。这一点和global不同global允许你在模块级还没有定义的情况下先声明再赋值。下面这个“三层嵌套”场景能帮你彻底理解def outer(): x 1 def middle(): x 2 def inner(): nonlocal x x 3 print(x) # 3 inner() print(x) # 3注意这里 middle() print(x) # 1 outer()nonlocal x绑定的是哪一层的x答案是middle里的那个x 2因为inner向上查找遇到的第一个外层局部变量是middle的。所以执行完inner后middle的x变成了 3而outer的x依然是 1。这个行为给我们的启发是作用域链的“绑定就近原则”不仅对读取生效对 nonlocal 声明也同样生效。3.3 列表推导式与 lambda 的坑列表推导式在 Python 3 中拥有自己的局部作用域这一点和 Python 2 有很大不同。来看这个x 10 result [x for x in range(5)] print(x) # Python 3 输出 10Python 2 输出 4因为 Python 3 中推导式内部是个独立作用域循环变量x不会污染外部作用域。这个设计是个改进但也带来了一个隐蔽问题funcs [lambda: i for i in range(3)] print([f() for f in funcs])结果是多少可能出乎意料是[2, 2, 2]。原因在于这些 lambda 函数捕获的是i这个变量本身而不是每次循环时i的快照。推导式结束后i的最终值是 2所有 lambda 访问的都是同一个i。解决方式有两种一是循环时把i作为默认参数传入 lambdafuncs [lambda ii: i for i in range(3)] print([f() for f in funcs]) # [0, 1, 2]二是用双函数包装。默认参数方式更简洁但理解其原理后你会发现自己对“变量名”和“变量值”的区分又加深了一层。4. 特殊作用域场景的原理解读4.1 类作用域的小迷思相对于函数类体内定义的变量查找规则有些特殊。在类体内你直接访问前面定义的变量没问题class A: x 1 y x 1 # 可以 print(A.y) # 2但在类体内的函数中情况就不同了class A: x 1 def get_x(self): return x # NameError: name x is not defined print(A().get_x())报错原因在于函数定义和类体并不是同一个作用域链。当你进入get_x方法时作用域链为get_x的局部作用域 → 全局作用域 → 内建作用域并不包含A类体那个命名空间。类体在这里更像一个“临时执行代码块”它定义的变量构成了类的属性而不是函数闭包的 Enclosing 作用域。这个特性经常被拿来当面试题。实际开发中如果你在类方法里想访问类级常量应该通过self或类名去访问class A: x 1 def get_x(self): return self.x # 1 print(A().get_x())理解这一点能避免很多奇怪的NameError。4.2 eval / exec 与作用域的关系动态执行代码时作用域的处理也容易踩坑。eval默认在当前作用域中执行字符串表达式而exec则支持传入独立的全局和局部命名空间g {} l {} exec(sum_result 1 2, g, l) print(l[sum_result]) # 3如果你不传命名空间exec默认使用当前调用处的全局和局部命名空间。这意味着动态执行代码可能意外修改你的局部变量造成难以排查的副作用。在需要高度可控的动态执行场景始终显式传入独立的命名空间字典是一个我从实际项目中总结出的硬性习惯。此外eval和exec对局部命名空间的写入行为在 Python 3 中比较特殊——即使你传入了一个局部命名空间字典函数内部的局部变量赋值也无法通过该字典访问到。细节先不展开但请记住动态执行代码本身就是一把双刃剑能不用尽量不用。4.3 与 JavaScript 作用域链的对比如果你写过 JavaScript会更容易理解 Python 的作用域链因为二者都遵循词法作用域并且都支持闭包。但有一个重要区别JavaScript 早期版本使用var声明的变量是函数级作用域没有块级作用域而 Python 中if、for、while等语句本身不创建新的作用域。for i in range(3): pass print(i) # 2循环变量在 Python 的 for 循环后依然可见很多 Python 新手以为i是循环局部的实际上它属于包含这个 for 语句的函数或模块作用域。这个行为在 Python 3 的列表推导式中有了例外推导式有独立作用域但普通 for 循环仍然没有。了解 JS 的let和 Python for 循环变量的巨大差别有助于你在跨语言编程时保持高度的警惕性。而 JavaScript 中的var有变量提升hoistingPython 虽然没有语法层面的提升但由于“编译期扫描整个函数体确定局部变量”表现上也非常接近一种“伪提升”。两者都可能导致“变量在声明前被访问”的问题只是报错形式不同。这样一对比很多作用域相关的隐性 Bug 就显得不那么神秘了。5. 常见报错与排查技巧实录5.1 UnboundLocalError 的三种修复思路这是作用域问题中最常见的报错出现的本质就是“局部变量在赋值前被引用”。修复思路有三种修复思路修改方式适用场景把它变成全局变量在函数内用global声明变量本身就应该全局管理把它变成外层变量在嵌套函数中用nonlocal闭包计数器、状态保持重新设计成参数和返回值把值作为参数传入、结果返回绝大多数情况推荐第三种思路是我最推荐的。函数式编程理念中输入决定输出状态尽量显式传递。比如# 不推荐 count 0 def add(): global count count 1 # 推荐 def add(count): return count 1 count add(count)后者更容易测试也更容易推断。当然如果你正在写递归或深度回调的代码适度使用nonlocal/global也是合理的工具没有好坏滥用才是问题。5.2 排查作用域问题的一招实用技巧如果代码逻辑复杂肉眼看不出来变量到底在哪一层我建议你用locals()、globals()和vars()手动观察。在函数内插入一行调试输出def outer(): x 1 def inner(): y 2 print(locals:, locals()) print(globals keys contain x?, x in globals()) return x return inner()locals()返回当前局部命名空间的所有绑定关系globals()返回全局命名空间。你一眼就能看出当前层有哪些变量查找链是否在某处断开。这是最粗暴也最有效的排查方法比在一堆 print 中间猜来猜去强多了。还可以用inspect模块标准库import inspect def inner(): frame inspect.currentframe() print(inspect.getouterframes(frame))可以看到调用栈中每一层的函数名、文件名、行号定位作用域问题非常直观。不过注意inspect.currentframe()在性能敏感或生产环境下应谨慎使用调试完成后及时移除。5.3 延迟绑定Late Binding与闭包陷阱如果你在循环中创建函数并延迟执行很可能遇到闭包变量被“共享”的问题。除了前面列表推导式里的例子最常见的版本是handlers [] for i in range(3): def handler(): print(i) handlers.append(handler) for h in handlers: h() # 输出 2 2 2而不是 0 1 2很多人期待输出 0 1 2实际却是 2 2 2。原因在于 handler 访问的i是 for 循环所在函数作用域的同一个变量循环结束后i的值停留在 2。三个 handler 共享这一个变量打印的都是最终值。解决方案有多种默认参数绑定值def handler(ii):把当前值作为默认参数绑定。使用 partialfunctools.partial(print, i)。借助列表推导式handlers [lambda ii: i for i in range(3)]。这个陷阱在事件回调、信号连接、批量注册处理器等场景里非常常见值得反复提醒。5.4 日常编码中的几个作用域习惯在实际项目中我逐渐形成了几个固定的编码习惯用了几年下来作用域相关的 Bug 明显减少。第一模块顶层的全局变量全部使用大写命名并且在函数内尽量不直接修改它们统一通过封装函数操作。这样一眼就能分辨哪些是全局状态哪些是局部临时变量。第二函数的局部变量不要使用与全局变量相同的名字除非你刻意需要“遮蔽”。遮蔽偶尔能省事但阅读代码的人很容易误判变量的来源尤其在函数较长时。第三写嵌套函数时如果不确定变量到底应属于哪一层优先把外层变量作为参数显式传入内层函数。显式传递比隐式闭包捕获更易懂、更易维护。虽然闭包用起来很酷但代码的可读性应该排在“技巧展示”之前。6. 从作用域到代码设计的延伸思考6.1 作用域是状态管理的底层工具不管是写普通脚本还是开发大型框架作用域链都在帮我们隔离状态、控制访问范围。全局变量就像一个“公共广场”谁都能往里面放东西也容易被乱七八糟的修改搞得一团糟局部变量和闭包则是“私人房间”有清晰的边界对外部隐藏了不必要的细节。从设计模式的角度看作用域链让 Python 能够模仿“私有成员”的效果。比如使用闭包实现一个简单的计数器对象def make_counter(): value 0 def get(): return value def increment(): nonlocal value value 1 return value return {get: get, increment: increment} c make_counter() c[increment]() print(c[get]()) # 1外部拿不到value只能通过返回的方法访问数据完全隐藏。这种模式在模块化设计中非常实用。6.2 调试器、性能与作用域的微妙关系最后一节聊点偏实战的。Python 的调试器和性能分析工具在使用作用域时有一些微妙的差异。断点设置在某一行时你能在调试器的“变量窗格”中看到多种作用域的变量这是调试器框架通过逐层定位命名空间实现的。如果你对作用域链理解不够可能根本不知道某个变量该在哪个层级去排查。性能方面访问局部变量的速度确实比全局快一点因为局部变量在函数内是索引访问全局变量需要字典查询。但现代 Python 的优化已经让这种差距变得非常小。真正值得做的是在循环体内不要反复访问全局变量——把它在循环前赋给一个局部变量这是成本最低的优化手段之一。6.3 分享一个真正好用的编程习惯我个人从作用域链的机制中获得的启发是清楚知道每个变量的“管辖范围”这不仅是避免报错的问题更是代码清晰度和可维护性的基础。写函数之前先在心里过一遍哪些变量是函数外部应该知道的哪些应该被封装在函数内部函数是否需要通过返回值与外部通信而不是偷偷修改某个外部状态。有了这层思考代码的“作用域边界”就会很清晰读起来也流畅很多。最终我建议你把 LEGB 规则打印下来贴在显示器旁边或者在笔记本上画一遍作用域链的查找过程。一个看起来小小的规则实际影响的是代码的架构、可调试性和扩展能力。把这些概念嚼碎了后面学装饰器、迭代器、生成器遇到“作用域相关”的坑都会从容很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价