Python 系列(二十) 变量作用域是什么?一文搞懂 global 和 nonlocal
1 | 作者:李晓辉 |
- Python 系列(一) 从零开始认识 Python,入门其实没那么难
- Python 系列(二) 学 Python 的第一步,把开发环境搭建好
- Python 系列(三) 基础语法避坑指南,缩进、大小写、续行与注释
- Python 系列(四) Python 输入输出详解,print()、input() 与常见避坑指南
- Python 系列(五) 数值类型与运算符详解,整除、浮点精度与进制转换实战
- Python 系列(六) 字符串处理实战,查找、替换、切片与日志分析
- Python 系列(七) 类型转换详解,int()、float()、str() 与金额计算避坑
- Python 系列(八) Python 流程控制入门,掌握 if、elif 和 else
- Python 系列(九) Python 逻辑运算符详解,and、or、not 一篇搞懂
- Python 系列(十) while、for 循环实战,彻底搞懂 range、break 与 continue
- Python 系列(十一) list、tuple、set、dict,到底该怎么选?
- Python 系列(十二) List 列表实战,搞定数据增删改查与批量处理
- Python 系列(十三) Tuple 元组实战,搞懂不可变特性与 enumerate() 遍历
- Python 系列(十四) Set 集合实战,搞定数据去重与集合运算
- Python 系列(十五) Dict 字典详解,掌握快速查找与键值操作
- Python 系列(十六) sort() 和 sorted() 详解,掌握数据排序技巧
- Python 系列(十七) 自定义函数入门,让重复代码一次编写多次使用
- Python 系列(十八) 函数参数详解,搞懂位置参数、关键字参数和默认参数
- Python 系列(十九) 从 *args 到 **kwargs,让函数调用更灵活
- Python 系列(二十) 变量作用域是什么?一文搞懂 global 和 nonlocal
- Python 系列(二十一) 搞懂变量、对象和引用,理解函数参数传递机制
- Python 系列(二十二) map() 和 filter() 详解,轻松实现批量数据处理与筛选
- Python 系列(二十三) 嵌套函数与 lambda 表达式详解,搞懂作用域与闭包
前面我们学习了 Python 函数的位置参数、关键字参数、默认参数,以及
*args和**kwargs等可变参数。现在,我们已经能够编写函数,也知道如何灵活地向函数传递数据了。
但接下来还有一个问题:函数中定义的变量,为什么有时候在函数外面无法访问?为什么修改一个变量,还需要使用
global或nonlocal?比如,我们想统计脚本检查了多少台服务器,能不能直接在函数中修改外面的计数器?如果一个函数里面还有另一个函数,内部函数又该如何修改外部函数中的变量?
这些问题都与 Python 的变量作用域有关。
什么是变量作用域?
先看一个简单的例子:
1 | def check_server(): |
运行结果:
1 | web-01 |
这里定义了一个变量 server_name,它位于 check_server() 函数内部。
如果我们尝试在函数外部访问它:
1 | def check_server(): |
运行后,程序会抛出 NameError,提示 server_name 没有定义。
1 | Traceback (most recent call last): |
为什么会这样?
因为 server_name 是函数内部定义的局部变量。函数外部无法直接通过这个名称访问函数内部的局部绑定。
我们可以把变量作用域理解为:一个名称在哪些代码区域内可以被访问,以及它的绑定属于哪个作用域。
在 Python 中,不同位置定义的名称,其作用域可能不同。
- 函数内部定义的名称,通常属于局部作用域。
- 模块顶层定义的名称,通常属于全局作用域。
- 嵌套函数可以访问符合规则的外层函数作用域中的名称。
- Python 还提供了内置作用域,其中包含
print()、len()等内置名称。
理解这些规则,就能解释为什么有些名称可以直接使用,有些名称却需要借助 global 或 nonlocal。
Python 如何查找变量?LEGB 规则
Python 中常用 LEGB 来描述函数中普通名称的查找顺序。
LEGB 分别代表:
| 名称 | 英文 | 含义 |
|---|---|---|
| L | Local | 当前函数的局部作用域 |
| E | Enclosing | 外层嵌套函数的作用域 |
| G | Global | 当前模块的全局作用域 |
| B | Built-in | Python 内置作用域 |
当函数需要读取一个普通名称时,可以先从当前局部作用域查找;如果没有找到,再依次检查外层函数、全局作用域和内置作用域。
flowchart TD
A["读取一个名称"] --> B{"局部作用域 L"}
B -->|找到| R["使用该名称绑定"]
B -->|未找到| C{"外层函数作用域 E"}
C -->|找到| R
C -->|未找到| D{"全局作用域 G"}
D -->|找到| R
D -->|未找到| E{"内置作用域 B"}
E -->|找到| R
E -->|未找到| F["抛出 NameError"]来看一个例子:
1 | name = "Global" |
运行结果:
1 | Local |
inner() 函数中存在局部变量 name,因此读取 name 时直接使用局部变量,不会继续查找外层作用域。
如果删除 inner() 中的 name = "Local",它就会使用 outer() 中的 name,输出 Enclosing。
如果外层函数也没有定义 name,Python 才会继续查找模块全局作用域。
需要注意:LEGB 描述的是普通名称的查找顺序,不是赋值时寻找变量的顺序。 Python 会根据函数中的名称绑定规则确定赋值目标,不能简单理解为赋值时也按照 LEGB 从内到外查找。
局部变量:函数内部的变量
先看最常见的局部变量:
1 | def check_server(): |
运行结果:
1 | running |
变量 status 在函数内部定义,因此它属于当前函数的局部作用域。
在这个例子中,status 只供函数内部使用。如果在函数外部直接访问 status,就会因为找不到这个名称而报错。
在实际运维脚本中,下面这种写法也很常见:
1 | def check_disk(): |
运行结果:
1 | 磁盘使用率:85% |
disk_usage 只是当前检查函数使用的数据,不需要让其他函数直接访问它。
把这类变量留在函数内部,可以减少不同函数之间的意外干扰,让代码更容易维护。
如果其他函数也需要使用磁盘使用率,可以通过函数返回值将结果传递出去,而不是直接访问局部变量。
全局变量:模块级别的变量
如果变量定义在函数外部、模块顶层,它通常属于全局作用域。
例如:
1 | server_name = "web-01" |
运行结果:
1 | web-01 |
这里的 server_name 定义在模块顶层。
函数内部没有定义同名的局部变量,因此 Python 可以在全局作用域中找到它。
这说明:函数可以读取全局变量,但读取全局变量和重新绑定全局变量是两回事。
这个区别非常重要。
为什么修改全局变量需要 global?
假设我们想统计服务器检查次数:
1 | count = 0 |
你可能觉得,count 已经在函数外面定义了,函数中直接加一不就可以了吗?
实际上,这段代码会抛出 UnboundLocalError。
1 | Traceback (most recent call last): |
原因在于:Python 会根据函数中的名称绑定情况确定名称的作用域。由于函数中存在 count += 1 这样的赋值操作,count 会被视为当前函数中的局部名称,除非通过 global 或 nonlocal 等规则明确指定其他绑定作用域。
而 count += 1 不只是修改操作,它还需要先读取 count 原来的值。此时,函数内部的局部名称还没有绑定到一个值,因此发生错误。
这时候就需要使用 global。
使用 global 修改全局变量
修改后的代码如下:
1 | count = 0 |
运行结果:
1 | 2 |
关键就在这一行:
1 | global count |
它告诉 Python:当前函数中的 count 指向模块级别的全局名称绑定,而不是函数自己的局部名称。
因此,每调用一次 check_server(),全局变量 count 就会增加一次。
整个过程可以这样理解:
flowchart LR
A["全局变量 count = 0"] --> B["调用 check_server()"]
B --> C["声明 global count"]
C --> D["执行 count += 1"]
D --> E["重新绑定全局 count"]
E --> F["count = 1"]如果函数调用两次,最终结果就是 2。
需要注意,global 的作用是指定名称绑定到模块全局作用域。它不会把所有变量都变成全局变量,也不会改变列表、字典等对象本身的修改规则。
修改对象与重新绑定名称,有什么区别?
这里还有一个很容易混淆的地方:修改对象内部的内容,不等于重新绑定变量名称。
先看一个例子:
1 | servers = ["web-01", "web-02"] |
运行结果:
1 | ['web-01', 'web-02', 'web-03'] |
这里没有使用 global,但列表内容确实发生了变化。
这是因为 servers.append() 修改的是列表对象内部的内容。函数读取了全局名称 servers,通过它访问列表对象,再调用 append() 修改列表,并没有将名称 servers 重新绑定到另一个对象。
再来看另一种情况:
1 | servers = ["web-01", "web-02"] |
运行结果:
1 | ['web-03'] |
虽然函数内部也使用了 servers 这个名字,但由于函数中存在赋值操作,servers 被视为局部名称。函数里的赋值创建了一个新的局部绑定,并没有改变全局名称 servers 原来的绑定。
如果希望重新绑定全局名称,可以这样写:
1 | servers = ["web-01", "web-02"] |
运行结果:
1 | ['web-03'] |
所以,判断是否需要 global,不能只看函数有没有修改函数外部的数据,而要看函数是否需要重新绑定全局名称。
在自动化运维脚本中,如果只是向已有列表追加服务器,或者修改字典中的检查结果,通常不需要 global。如果要让某个名称重新指向另一个对象,就需要考虑相应的作用域规则。
global 并不是读取变量的必需条件
如果只是读取全局变量,并不需要使用 global:
1 | count = 10 |
运行结果:
1 | 10 |
这里没有在函数中给 count 赋值,因此 Python 可以从全局作用域读取它。
只有当你需要在函数内部重新绑定某个全局名称时,才需要声明 global。
例如:
1 | status = "running" |
运行结果:
1 | stopped |
不过,在实际项目中,不建议为了方便就大量使用全局变量。多个函数共同修改同一个全局状态,容易让程序的执行结果变得难以追踪。
对于服务器检查次数这样的数据,也可以通过函数参数和返回值来传递,或者将状态封装到对象中。这样通常更容易测试和维护。
嵌套函数中的变量怎么办?
前面我们讨论的是函数和模块之间的变量关系。
但 Python 还支持嵌套函数,也就是在一个函数内部再定义另一个函数。
例如:
1 | def outer(): |
运行结果:
1 | 外层函数 |
虽然 message 不是 inner() 自己定义的局部变量,但 inner() 仍然可以访问它。
这是因为 LEGB 中的 E,也就是 Enclosing,表示外层嵌套函数的作用域。
当 inner() 在自己的局部作用域中找不到 message 时,就会继续向外层函数查找。
不过,能够读取外层函数中的变量,不代表可以直接给它重新赋值。
看下面的例子:
1 | def outer(): |
这段代码同样会抛出 UnboundLocalError。
1 | Traceback (most recent call last): |
原因是 inner() 中的 count += 1 会让 Python 把 count 视为 inner() 的局部名称,而不是外层 outer() 中的名称。
这时候,我们需要另一个关键字:nonlocal。
使用 nonlocal 修改外层函数的变量
nonlocal 用于嵌套函数中,声明某个名称绑定到最近的、包含该名称的外层函数作用域,而不是当前函数的局部作用域或模块全局作用域。
修改后的代码如下:
1 | def outer(): |
运行结果:
1 | 2 |
这里的关键是:
1 | nonlocal count |
它告诉 Python:当前函数中的 count 不是 inner() 自己的局部名称,而是使用外层函数中的 count。
每次调用 inner(),都会将外层的 count 增加一次。因此调用两次后,outer() 返回 2。
执行过程可以表示为:
flowchart LR
A["调用 outer()"] --> B["定义外层 count = 0"]
B --> C["调用 inner()"]
C --> D["声明 nonlocal count"]
D --> E["执行 count += 1"]
E --> F["外层 count = 1"]
F --> G["再次调用 inner()"]
G --> H["外层 count = 2"]
H --> I["return count"]需要注意,nonlocal 只能用于存在相应外层函数绑定的嵌套作用域。如果外层函数中不存在这个名称,Python 就会报错;它不能用来声明模块级别的全局变量。
global 和 nonlocal 有什么区别?
两者都能影响函数内部名称的绑定位置,但目标作用域不同。
| 对比项 | global | nonlocal |
|---|---|---|
| 目标作用域 | 当前模块的全局作用域 | 最近的、包含该名称的外层函数作用域 |
| 常见使用场景 | 函数重新绑定模块级变量 | 内部函数重新绑定外层函数中的变量 |
| 是否需要嵌套函数 | 不需要 | 需要存在合适的外层函数作用域 |
| 目标名称不存在时 | 赋值可以创建模块级绑定 | 必须存在符合条件的外层绑定 |
可以简单记住:
global:我要操作的是模块级别的名称绑定。nonlocal:我要操作的是外层函数中的名称绑定。
再看一个对比:
1 | count = 0 |
运行结果:
1 | 1 |
在这个例子中:
count属于模块级别,因此使用global。total属于外层函数outer(),因此使用nonlocal。inner()调用一次,两者各增加一次。
虽然这个例子同时使用了两个关键字,但在日常开发中,最好只在确实需要修改相应作用域中的名称绑定时才使用它们。
实际开发中应该如何选择?
在编写 Python 运维脚本时,我们经常会处理服务器列表、检查结果、执行状态等数据。
遇到变量共享问题时,可以优先考虑以下方式:
- 只在函数内部使用的数据:定义为局部变量。
- 需要向函数提供的数据:优先通过参数传入。
- 需要将处理结果交给调用者的数据:优先通过
return返回。 - 确实需要重新绑定模块级名称时:再考虑全局变量和
global。 - 需要内部函数维护外层函数状态时:可以考虑
nonlocal,也可以根据复杂程度使用类或其他结构封装状态。
例如,统计服务器检查次数并不一定要使用全局变量:
1 | def check_server(count): |
运行结果:
1 | 2 |
这里通过参数传入计数值,再通过返回值把更新后的结果交还给调用者。
这种方式的好处是,函数依赖什么数据、产生什么结果,都更加明确。
当然,global 和 nonlocal 并不是不能使用。理解它们的工作原理,才能在阅读现有代码、分析闭包或设计状态管理逻辑时做出正确选择。
写在最后
这篇文章我们学习了 Python 变量作用域,重点理解了几个问题:
- 为什么函数内部定义的局部变量,不能直接在函数外部访问?
- 为什么函数可以读取全局变量,却需要使用
global才能在函数内部重新绑定它? - 为什么修改列表内容通常不需要
global,但重新绑定全局名称时需要考虑作用域规则? - 为什么嵌套函数可以读取外层函数的变量,却需要使用
nonlocal才能重新绑定外层变量?
掌握 LEGB 查找规则后,这些现象就不再神秘了。
下一篇,我们将继续学习 Python 中一个非常重要的概念:变量、对象与引用之间的关系。
届时我们会进一步理解,为什么把一个列表传入函数后,函数内部修改列表的内容,外部也可能观察到变化;以及为什么这种现象不能简单地理解为传统意义上的“引用传递”。
