Python LEGB规则详解:作用域、UnboundLocalError与global/nonlocal避坑指南

发布时间:2026/10/15 5:38:12

Python LEGB规则详解:作用域、UnboundLocalError与global/nonlocal避坑指南 先看一段能劝退不少新手的代码count 10 def show(): print(count) count 20 show()如果只看代码逻辑函数里先是读count然后再写count怎么看都应该是先打印 10然后函数内部把 count 改成 20。结果一跑直接抛异常报错是UnboundLocalError: local variable count referenced before assignment。很多从 Java、C 转过来的朋友第一反应是Python 是不是有什么毛病。其实 Python 没毛病它只是在严格地执行一套叫 LEGB 的名字查找规则。这篇文章我想把 LEGB 规则完整拆一遍它为什么存在、四层作用域分别在什么时候生效、global 和 nonlocal 到底解决了什么问题以及几个最容易踩的坑和对应的排查思路。不管你是刚入门还是已经写了几年 Python应该都能从里面梳理出一些之前忽略的细节。1. 先从一个反直觉的报错说起UnboundLocalError 到底在抱怨什么1.1 报错现场读在前、写在后也会翻车上面这个例子里函数体同时存在print(count)和count 20两行。只要把函数里的赋值删掉程序立刻恢复正常count 10 def show(): print(count) show() # 10同样一个名字 count赋值语句存在与否直接决定了函数内部能不能读到模块层的值。这个现象很容易被误解成Python 看代码顺序先看前面的 print就应该先打印全局变量再创建局部变量。但真实机制和代码执行顺序几乎没有关系。1.2 编译期就定好的局部名单关键点在于Python 在编译函数定义的时候就会把函数体内出现过的所有赋值目标收集起来提前归类。这个分类不发生在运行到那一行时而是发生在整个 def 语句被编译的那一刻。哪些操作会被算作往函数局部层写入名字普通赋值语句x 1增强赋值x 1、x - 1for 循环的目标变量for x in ...import 导入的名字import os、from os import path参数名天然就是局部变量del 语句的目标del x上面例子里的count 20属于普通赋值所以 count 被划进了 show 函数的局部名字集合。编译器一旦判定 count 是局部变量函数内部对 count 的所有读取都只会去局部层找根本不会回头去看模块层。于是print(count)执行时局部层里 count 还没有被绑定任何值直接报 UnboundLocalError。拿dis模块看一眼字节码会更直观在 CPython 里show 函数对 count 的读取和写入会分别编译成LOAD_FAST和STORE_FAST指令LOAD_FAST是专门读取局部变量的指令它不会再去全局层搜索。也就是说作用域类型在编译阶段就已经定死了不是在运行到某一行的瞬间才临时判断。1.3 没有命中的时候查找才会继续往外走那什么时候 Python 才往外层找答案是编译时没被归类成局部变量的名字。比如函数体从头到尾没有给 x 赋过值那么 x 就被当成非局部名字运行时按 LEGB 的顺序逐层查找先局部再嵌套函数的外层再全局最后内建。这里有个非常容易混淆的点所谓LEGB 查找顺序主要针对的是那些没有被局部化的名字。一个名字只要在函数体内有过赋值操作它就彻底变成局部户查找时连 E、G、B 都不会看。也就是说LEGB 规则真正管的是不在局部名单里的名字应该去哪找而不是每次访问都从 L 开始一层层试过去。2. LEGB 四层名字空间Python 查名字时翻抽屉的顺序2.1 L 层函数调用时临时搭起来的局部抽屉L 层Local是每次函数调用时新建的名字空间。调用开始这层抽屉拉出来函数返回或抛异常抽屉整体销毁。参数、函数体内的赋值、推导式里的循环变量Python 3 中都装在这层。def calc(a, b): total a b return total这里的 a、b 是参数total 是局部赋值三者都在 L 层。每次调用 calc 都是独立的一层两次调用互不影响。递归函数能工作靠的也是每层递归都有一份独立的 L 层。2.2 E 层嵌套函数之间的中间夹层E 层Enclosing只有在出现嵌套函数时才存在。外层函数的局部层对内层函数来说就是封装层。def outer(): msg hello def inner(): print(msg) # msg 不在 inner 的 L 层去 outer 的 L 层找到了它 inner() outer()inner 里面没有给 msg 赋值它被当作非局部名字查找时先翻自己的局部层没找到就翻 outer 的局部层——在 CPython 里这里用的是LOAD_DEREF指令专门读取闭包单元里的值。如果你写过闭包、装饰器、状态保持这类代码其实天天都在和 E 层打交道。2.3 G 层模块顶层的公共区域G 层Global是当前模块最外层那个名字空间。模块顶层赋的值、import 进来的模块名和变量名都住在这一层。同一个模块里定义的函数没被局部化且非嵌套的名字查找时都会到 G 层来拿。data [1, 2, 3] def total(): t 0 for n in data: # data 从 G 层读取 t n return tdata 在函数里没有被赋值所以按顺序查到 G 层拿到模块级的列表。这也是很多脚本项目把读取全局配置写进函数的依据。2.4 B 层解释器启动时就装好的内建抽屉B 层Built-in由builtins模块填充print、len、range、int、list这些内建函数和异常类型都在这里。它查找优先级最低只有当局部、嵌套、全局都找不到时才轮到 B 层兜底。正因为 B 层优先级最低你可以在自己的模块里定义一个叫len或print的函数局部和全局层会遮蔽内建层。偶尔在教育演示或调试场景会有意这么做但在生产代码里强烈不建议——别人读代码时默认len是内建函数你悄悄换成自己的实现排查成本极高。2.5 一张表记住四层的差别层名字来源查找优先级往里写名字生命周期L Local参数、函数体内赋值、导入、for 目标第 1 优先直接赋值即写入跟随函数调用调用结束销毁E Enclosing外层函数的局部变量第 2 优先需要 nonlocal 声明跟随外层函数调用G Global模块顶层赋值、import 导入第 3 优先需要 global 声明跟随模块加载B Built-inbuiltins 模块提供的内建名字最后兜底一般不应该写解释器启动到退出这张表值得收藏。后面遇到报错可以先对照这张表判断Python 是在哪一层找不到名字还是因为赋值导致名字被关进了局部层。3. 修改外层名字的前置条件global 与 nonlocal 的适用边界3.1 global把名字绑定从局部层移到模块层如果函数体内直接写count 20count 会被判定为局部变量模块层的 count 完全不受影响。想故意修改模块层的 count必须加global声明count 10 def add_one(): global count count 1 add_one() print(count) # 11注意count 1本质上是读旧值算新值再写回同名变量。这三步在同一行里完成如果没有 global 声明前面的读就会触发 UnboundLocalError根本轮不到写。加了 global 之后读和写都指向模块层。在模块顶层写global x是合法的但毫无必要因为模块顶层本身就是全局层。这个写法偶尔出现在别人代码里时通常只是改代码时留下的冗余。3.2 nonlocal只对嵌套函数有效的外层绑定nonlocal 解决的问题和 global 类似但作用对象是最近的外层函数的局部变量而不是模块层的全局变量。它只能在嵌套函数内部使用而且必须先于该名字在函数内的任何使用出现。def counter(): n 0 def bump(): nonlocal n n 1 bump() bump() return n print(counter()) # 2如果去掉 nonlocalbump 内部的n 1会把 n 变成 bump 自己的局部变量结果 counter 里的 n 始终是 0。如果外层根本不存在 nnonlocal 声明会在编译期直接报SyntaxError而不是等到运行时。需要强调的是只读外层变量不需要 nonlocal。def inner(): print(msg)这种读取完全合法因为 inner 里没有给 msg 赋值编译时 msg 不会被局部化。nonlocal 只在一个场景下是必须的——函数体内要给外层函数的某个名字重新绑定。3.3 原地修改为什么不需要任何声明这是所有 LEGB 相关问题的重灾区。很多初学者以为想用全局列表就得 global想改全局列表更得 global于是到处加 global。实际上对于可变对象的原地修改什么都不用加items [] def add(x): items.append(x) # 不需要 global items [x] # 这一行会报 UnboundLocalError第一行append是调用 items 指向的列表对象的方法它改变的是列表内部状态并没有给 items 这个名字重新绑定。Python 从 G 层读到 items 指向的列表对象然后往这个对象里塞了一个元素没有任何写回名字的动作所以无需声明。第二行items [x]不一样它展开成读取旧值、拼接出新列表、把新列表绑定回 items。最后一步是对名字 items 的重新绑定在函数体内发生于是 items 被局部化读取阶段就炸了。判断口诀其实很简单你是在改对象内容还是在改名字指向。改对象内容不需要声明改名字指向才需要 global 或 nonlocal。这个区分几乎能解决 80% 的为什么我改不动外层变量的困惑。4. 闭包与循环变量的延迟绑定LEGB 最经典的连环坑4.1 闭包捕获的是变量单元不是当时的快照当你在一个函数里定义另一个函数并且内层函数引用了外层函数的局部变量时CPython 会把这个变量放进一个单元cell对象里。外层函数和内层函数共同引用同一个单元而不是把当时的数值拷贝一份给内层。因此外层变量后续怎么变内层函数读到的就是最新值。def make_printer(): value 初版 def printer(): print(value) value 改版 return printer p make_printer() p() # 改版printer 最终打印的是改版不是初版。这说明闭包根本没有冻结那一刻的值它只是持有一个可以随时查看最新内容的通道。理解这个机制是理解延迟绑定问题的前提。4.2 for 循环不产生作用域三个 lambda 全指向同一个 i延迟绑定最经典的出场方式就是循环里造函数funcs [] for i in range(3): funcs.append(lambda: i) for f in funcs: print(f()) # 2 2 2直觉上可能期待输出 0、1、2实际全是 2。原因有两层第一for 循环不是作用域。这里的 i 属于模块层或者所在函数层三次循环都是同一个 i 变量只是它的值从 0 被改成 1、再改成 2。第二三个 lambda 都引用了同一个 i 变量单元。循环结束后 i 停在 2于是每个 lambda 执行时读到的都是 2。哪怕写成列表推导式[lambda: i for i in range(3)]Python 3 给推导式单独开了一个局部作用域i 是该作用域里的变量三个 lambda 创建时仍然捕获的是同一个推导式作用域中的 i。等推导式执行完毕i 的最终值是 2结果同样是三个函数输出 2。这个坑在 GUI 回调、定时器、批量注册事件处理器时特别常见。比如给一组按钮绑定点击回调回调里需要知道自己是第几个按钮稍不留神就全打印最后一个。4.3 绕过延迟绑定的几种常规写法思路其实就一条让每个闭包捕获一个独立的、在创建那一刻就确定的值而不是共享同一个循环变量。第一种用默认参数把当前值钉死funcs [lambda ii: i for i in range(3)] print([f() for f in funcs]) # [0, 1, 2]默认参数在函数定义时求值lambda 定义那一刻的 i 会被绑定到默认参数上之后循环变量再怎么变都影响不到它。第二种用工厂函数隔离作用域def make(i): return lambda: i funcs [make(i) for i in range(3)] print([f() for f in funcs]) # [0, 1, 2]make 函数的参数 i 是它自己的局部变量每次调用都有一份独立空间lambda 捕获到的就是那个独立空间。第三种用 functools.partial 提前绑好参数from functools import partial def show(i): return i funcs [partial(show, i) for i in range(3)] print([f() for f in funcs]) # [0, 1, 2]到这里我要提醒一句很多代码规范里禁止在循环中定义 lambda的建议本质原因就是这里。只要能在一个独立作用域里创建函数延迟绑定问题就能被绕开。5. 类体、推导式、import 与 del四个容易记错的作用域边界5.1 类体内的名字不进入方法的嵌套作用域链类体确实会创建一个名字空间但它不会像函数一样形成嵌套作用域。类里面的方法不会把类体当作 E 层去查找。class A: x 10 def show(self): print(x) # NameError: name x is not defined A().show()show 里面访问 x 时查找顺序是show 的局部层 → 模块全局层 → 内建层。类体那层直接被跳过。想访问类属性要么用self.x要么显式写A.x。这个行为最初接触时非常反直觉但它确实存在而且这是 Python 官方文档里明确定义的类作用域不参与嵌套函数的闭包链。5.2 Python 3 的推导式有自己的局部作用域Python 3 里列表推导式、字典推导式、集合推导式和生成器表达式都会为循环变量单独开一个局部作用域。推导式里面的循环变量不会漏到外层x outer result [x for x in ab] print(x) # outer这个例子里推导式内部的 x 只在推导式作用域内有效外层 x 完全没被碰。这算是 Python 3 相对 Python 2 的一个重要改进——在 Python 2 里列表推导式的循环变量会泄漏到外层作用域经常莫名其妙覆盖同名的外部变量。如果你还要维护兼容 Python 2 的老代码遇到这种情况得格外小心。5.3 import 导入的名字落在当前模块的 G 层from math import sin和import math这两种写法结果都只是把一个名字绑定到当前模块的全局层。区别在于绑定的名字是sin还是math。在函数内部访问sin时如果函数体里没有from math import sin语句那么 sin 就是通过 G 层找到的。但注意一点一旦函数体内出现了from math import sin这个 sin 就成了函数的局部名字。它和模块层同名导入互不冲突函数内部的 sin 指向的是函数局部导入的结果。这种写法很少见但如果你在排查为什么函数里用的 sin 和我模块顶部导入的不一样可以先检查函数体里是不是又有一次导入。5.4 del 操作会直接影响名字的绑定状态del 在作用域分类里也是一个写入动作它会删除局部层的名字绑定。比如x 1 def f(): print(x) del x f() # UnboundLocalError和普通赋值一样del 的存在让 x 被划为局部变量。前面的 print 访问不到尚未绑定的局部 x直接报错。想删除全局层的 x同样需要先 global 声明。这个边界很少有人提但确实会导致一些看上去毫无道理的报错。6. 从报错反推问题作用域故障的排查思路与编码习惯6.1 从报错文案快速判断作用域故障类型排查作用域问题第一步不是打开编辑器盯着代码看而是先看报错类型它能直接告诉你故障发生在哪一层。报错含义最常见原因NameError: name xxx is not defined四层都找过了没有这个名字拼写错误、忘了导入、名字在调用之后才定义UnboundLocalError: local variable xxx referenced before assignment名字被局部化但使用时还没绑定函数体内后文有赋值导致前面的读取失败SyntaxError: nonlocal declaration follows use of variablenonlocal 声明位置不对nonlocal 必须在该名字于函数内的任何使用之前出现看到 UnboundLocalError第一反应应该是这个函数体里某处给同一个名字赋了值。多数时候就是 1.2 节说的局部化判定在起作用。看到 NameError则优先检查这个名字定义在哪一层、有没有被遮蔽。6.2 locals()、globals()、dir() 组合调试法真到要动手查的时候我习惯用这三个工具globals()返回当前模块 G 层的字典能直接看到模块层都有哪些名字、值是什么。locals()在函数内部返回当前 L 层的字典在模块顶层调用时它和globals()返回同一个东西。dir()不带参数时返回当前所在层的名字列表适合快速概览。在出问题的函数里临时加一行对比两层各自有没有这个名字def show(): print(globals:, globals().get(count)) print(locals:, locals().get(count)) print(count) # 原来的报错点如果 globals 里有 count、locals 里没有说明问题在函数体某处把 count 局部化了如果 globals 里都没有那就得往上查导入和定义顺序。这招比纯靠脑子想快得多尤其适合大函数里十几个同名前缀的变量混在一起的场景。6.3 三个能显著减少 LEGB 误判的编码习惯第一个习惯不要遮蔽内建名字。变量叫list、dict、type、max等于把 B 层的名字盖住。短期内可能没事但三个月后某个角落用list(...)时行为会变得莫名其妙。改名成本远比排查成本低。第二个习惯尽量用参数传递而不是在函数里读模块层的全局变量。函数如果依赖 G 层的某个可变状态每次调用都要想着这个值现在在哪儿被改过心智负担很重。把值作为参数传进去函数的 L 层自己持有作用域链条更短测试也更友好。第三个习惯nonlocal 能不用就不用。闭包需要改外层状态时我通常会先考虑是不是可以设计成外层提供一个可变容器内层只改容器内容或者干脆把逻辑抽成一个小类。比如计数器与其在闭包里 nonlocal 一个数字不如用itertools.count或者一个只有两个方法的类。倒不是 nonlocal 有什么问题而是跨层改绑定本身就是最容易和局部化判定纠缠在一起的操作代码越少用LEGB 踩坑面越小。最后分享一个我个人的小习惯只要代码里出现函数内读一个名字同时函数内另一个地方又在写同一个名字这种结构先停一下默认它有问题然后明确自己想让它读的是哪一层。LEGB 规则本身不难难的是我们经常以为自己在读外层实际上编译器早已把它变成了局部名。判断清楚编译期分类和运行期查找这两步几乎所有作用域相关的报错都能在一分钟内定位。
延伸阅读

更多相关文章

2026/10/15 5:38:12

C 数据溢出与精度丢失

有符号 short 溢出&#xff08;有符号整数溢出属于未定义行为&#xff09; #include <stdio.h> int main() {short num 0x7FFF;printf("%hd \r\n", num);num 0x8000;printf("%hd \r\n", num);return 0; }short&#xff1a;2 字节 16bit&#xff0…

2026/10/15 5:33:12

电容触摸按键灵敏度调试指南:原理、参数与实战避坑

简介&#xff1a;面向电磁炉及家电控制面板的硬件/嵌入式开发者&#xff0c;这份资料系统讲解电容触摸按键的原理与调试方法。内容先分析感应按键电路&#xff0c;说明信号产生单元输出约几百KHz、占空比约50%的高电平信号&#xff0c;再经信号整形单元处理后送入MCU的AD口&…

2026/10/15 5:33:12

Agent能力拆解:会话、工具、MCP、记忆四模块实战

我先破了题再落笔。“Hermes Agent能力篇&#xff1a;会话、工具、MCP、记忆”这个标题&#xff0c;核心不是某个具体实现版本号&#xff0c;而是构建一个可用、可信、可扩展的智能体骨架时&#xff0c;四个绕不开的底层模块&#xff1a;会话能力管的是“怎么把天聊下去”&…

2026/10/15 6:48:16

传递闭包与Floyd-Warshall算法:从原理到位运算优化实践

1. 传递闭包问题到底是什么1.1 从一个实际问题说起做过后端系统设计的人&#xff0c;大概率遇到过这类需求&#xff1a;系统里有几十个服务节点&#xff0c;服务之间通过消息队列或接口互相调用&#xff0c;我想要知道其中某个节点一旦挂掉&#xff0c;会直接或间接影响哪些下游…

2026/10/15 6:48:15

微信防撤回完整指南:Windows 下三步装好 RevokeMsgPatcher

微信防撤回完整指南&#xff1a;Windows 下三步装好 RevokeMsgPatcher 【免费下载链接】REFramework Mod loader, scripting platform, and VR support for all RE Engine games 项目地址: https://gitcode.com/GitHub_Trending/re/REFramework RevokeMsgPatcher 是一款…

2026/10/15 6:48:15

AI驱动文档转PPT:从PDF/Word到可编辑演示文稿

你有没有过这种经历&#xff1a;客户发来一份写好的PDF方案&#xff0c;领导要求“把它改成PPT汇报版”&#xff1b;或者团队沉淀了十几个Word文档&#xff0c;要在一个季度述职会上全部转成交付材料。我试过无数次手工搬砖——复制、粘贴、降级字号、拆长段落、画示意图&#…

2026/10/14 11:31:03

Python调用Gemini Structured Outputs实现工单路由门禁

客服工单最怕的不是模型“答错一句话”&#xff0c;而是它给出一段看起来合理的说明&#xff0c;程序却从中猜错优先级。通俗做法是&#xff1a;要求模型只交 JSON&#xff08;JavaScript Object Notation&#xff0c;轻量数据格式&#xff09;&#xff0c;再让代码验证它。Gem…

2026/10/14 17:41:07

Spring Boot超市进销存系统毕设实战:从需求拆解到答辩通关

最近带的一个学生项目组里&#xff0c;有A同学跑来问我&#xff1a;选什么毕设题目最稳妥&#xff0c;既能让评审老师觉得工作量够&#xff0c;又不会在答辩时被问到语无伦次。我第一反应就是推荐基于Spring Boot的超市仓库管理系统——也就是超市进销存系统。这个题目乍一看平…

2026/10/14 2:32:55

Flutter StatefulWidget 生命周期核心解析

很多刚开始接触 Flutter 的朋友&#xff0c;在看完一堆“Hello World”和基础组件之后&#xff0c;大概率都会撞上同一堵墙&#xff1a;StatefulWidget 里那堆 initState、build、dispose 方法&#xff0c;到底什么时候被调用&#xff1f;为什么顺序是那样&#xff1f;在里面到…

2026/10/15 0:02:22

防关联浏览器哪个好?三款产品实测差异解析

新手做多店铺&#xff0c;最纠结的就是防关联浏览器选哪个。飞跨、网易、站斧这三款被问得最多&#xff0c;先给结论&#xff1a;要一体化省心、长期便宜&#xff0c;选站斧&#xff1b;要网易招牌和稳&#xff0c;选网易&#xff1b;预算紧做东南亚 Lazada&#xff0c;选飞跨。…

2026/10/15 0:02:22

影刀RPA深度实战:大模型Fine-tuning定制RPA专用Prompt与指令生成

影刀RPA深度实战&#xff1a;大模型Fine-tuning定制RPA专用Prompt与指令生成 一、技术背景 大模型&#xff08;LLM&#xff09;在通用场景下表现强大&#xff0c;但当我们将 GPT-4 或 Qwen 直接用于生成影刀RPA操作指令和 XPath 定位时&#xff0c;准确率通常只有 40%-60%。原因…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑