Python 系列(二十三) 嵌套函数与 lambda 表达式详解,搞懂作用域与闭包
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 系列(二十四) 搞懂递归函数,从基线条件到实际应用
上一篇我们学习了 Python 的 map() 和 filter(),知道了如何批量转换数据、筛选符合条件的记录。
不过,函数的用法还不止这些。
假设你正在编写一个服务器巡检脚本,需要检查 CPU、内存和磁盘使用率。不同的检查任务可能有不同的阈值,但检查逻辑又有很多相似之处。有没有办法把公共逻辑封装起来,同时让不同的检查任务拥有自己的配置?
再比如,某个函数只在另一个函数内部使用,我们有必要把它定义成一个独立的全局函数吗?如果只是简单地处理两个数字,又有没有更简洁的写法?
这就涉及今天要学习的两个知识点:嵌套函数和 lambda 表达式。
学完这篇文章,你不仅要知道它们怎么写,还要理解变量是如何查找的、为什么会出现作用域陷阱,以及闭包究竟保存了什么。
嵌套函数是什么
所谓嵌套函数,就是在一个函数的内部定义另一个函数。
先看一个最简单的例子:
1 | def outer(x): |
输出结果:
1 | 15 |
这段代码并不复杂,但有几个细节值得注意。
首先,outer() 是外部函数,inner() 是定义在它内部的函数。
1 | def outer(x): |
调用 outer(10) 时,参数 x 的值为 10。此时,Python 执行 outer() 函数中的代码,并创建内部函数 inner()。
接下来:
1 | return inner |
这里返回的是函数对象 inner,而不是调用它的结果。
注意,两种写法有明显区别:
1 | return inner |
返回函数对象,调用方可以稍后再执行它。
1 | return inner(5) |
立即调用 inner(5),返回计算结果。
回到原来的代码:
1 | f = outer(10) |
f 接收了 outer() 返回的函数对象,因此可以继续调用:
1 | print(f(5)) |
这相当于调用内部函数,并传入参数 y = 5。
最终计算:
1 | x + y |
也就是 10 + 5,得到 15。
整个过程可以用下面的流程理解:
flowchart LR
A["调用 outer(10)"] --> B["创建内部函数 inner"]
B --> C["返回 inner 函数对象"]
C --> D["变量 f 引用该函数"]
D --> E["调用 f(5)"]
E --> F["读取 x = 10 和 y = 5"]
F --> G["返回 15"]这里有一个关键问题:调用 outer() 后,outer() 已经执行完毕,为什么内部函数仍然能够使用参数 x?
这就涉及 Python 的作用域和闭包。
作用域:Python 到底在哪里查找变量
作用域可以理解为名称在程序中可以被访问的范围。
在学习嵌套函数之前,先明确一个容易混淆的地方:变量名的查找规则,与变量赋值时的名称绑定规则,不是一回事。
我们先从普通的变量查找开始。
理解 LEGB 名称查找规则
在函数中读取一个名称时,可以使用 LEGB 作为理解名称查找过程的简化模型:
- L(Local):当前函数的局部作用域。
- E(Enclosing):外围函数的作用域。
- G(Global):当前模块的全局作用域。
- B(Built-in):Python 内置名称所在的作用域。
例如:
1 | message = "全局变量" |
输出结果:
1 | 内部函数变量 |
为什么输出的是“内部函数变量”?
因为 inner() 中存在一个名为 message 的局部变量。读取这个名称时,Python 首先在当前函数的局部作用域中找到了它,因此不需要继续查找外围函数或全局作用域。
再看一个例子:
1 | message = "全局变量" |
输出结果:
1 | 外部函数变量 |
这次,inner() 内部没有为 message 创建新的局部绑定,因此在执行这条读取语句时,Python 会继续查找外围函数作用域,最终找到 outer() 中的 message。
如果外围函数也没有这个名称,查找还可以继续到全局作用域和内置作用域。
可以将这种普通名称读取的查找过程理解为:
flowchart TD
A["读取名称"] --> B{"当前函数局部作用域 L"}
B -->|找到| Z["使用该名称绑定"]
B -->|未找到| C{"外围函数作用域 E"}
C -->|找到| Z
C -->|未找到| D{"模块全局作用域 G"}
D -->|找到| Z
D -->|未找到| E{"内置作用域 B"}
E -->|找到| Z
E -->|未找到| F["引发 NameError 等名称查找错误"]注意,这是一张用于理解普通名称读取的简化图。Python 会根据编译阶段确定的名称绑定规则处理函数中的局部变量,不能把 LEGB 理解成每次赋值时都从 L 开始逐层寻找并修改变量。
作用域陷阱:为什么会出现 UnboundLocalError
下面这段代码看起来似乎没有问题:
1 | count = 10 |
但执行后会报错:
1 | Traceback (most recent call last): |
为什么会这样?
很多初学者会认为:既然全局作用域已经定义了 count = 10,那么 Python 应该先读取这个值,再加上 1。
但 Python 并不是这样处理的。
在普通函数中,如果函数体内对某个名称进行了赋值,且没有通过 global 或 nonlocal 等声明改变其绑定规则,Python 通常会在编译阶段将这个名称判定为当前函数的局部变量。
例如:
1 | def inner(): |
Python 会将这里的 count 判定为 inner() 的局部变量。执行 count = count + 1 时,需要先读取右侧的 count,但这个局部变量此时还没有绑定值,因此会触发 UnboundLocalError。
注意:这不是 LEGB 名称查找逐层搜索后失败的结果,而是局部名称已经确定、但读取时尚未获得值所导致的错误。 Python 不会因为这个局部变量尚未赋值,就自动退回全局作用域读取同名变量。
如果确实要修改模块级的 count,可以使用 global:
1 | count = 10 |
输出结果:
1 | 11 |
global count 声明 count 使用模块全局作用域中的绑定,因此赋值会更新全局变量。
如果要修改的是外围函数中的变量,而不是全局变量,则应该使用 nonlocal。这两种声明的作用范围不同,不能互相替代。
nonlocal:修改外围函数中的变量
nonlocal 用于在嵌套函数中声明:某个名称绑定在外围的函数作用域中,当前函数需要使用这个已有绑定,而不是创建同名的局部绑定。
例如:
1 | def outer(): |
输出结果:
1 | 11 |
执行过程如下:
- 调用
outer(),创建局部变量count,值为10。 - 调用内部函数
inner()。 nonlocal count声明要使用外围函数中的count绑定。count = count + 1将这个绑定对应的值更新为11。- 内部函数和外部函数随后读取到的都是更新后的值。
可以将这个过程理解为:
flowchart LR
A["调用 outer()"] --> B["创建 count = 10"]
B --> C["调用 inner()"]
C --> D["声明 nonlocal count"]
D --> E["将外围函数中的 count 更新为 11"]
E --> F["inner() 输出 11"]
F --> G["outer() 输出 11"]这里还要注意:nonlocal 必须对应某个外围函数作用域中已经存在的绑定,不能用它来声明一个全局变量。如果要修改模块级名称,应使用 global。
另外,修改对象与重新绑定名称也不同。
例如:
1 | def outer(): |
输出结果:
1 | ['web-01'] |
这里不需要 nonlocal。因为 servers.append() 修改的是列表对象本身,并没有让名称 servers 重新绑定到另一个对象。
但如果改成:
1 | def outer(): |
此时,servers 被重新绑定到一个新的列表对象,因此需要 nonlocal。
掌握这个区别,后面理解闭包和嵌套函数就容易多了。
闭包:为什么外部函数执行完,内部函数还能使用变量
回到最开始的例子:
1 | def outer(x): |
当 outer(10) 执行结束后,内部函数 inner() 仍然可以访问 x,并不是因为 Python 将 x 变成了全局变量,而是因为返回的函数对象保留了访问相关外围作用域绑定的能力。
这种函数与其所需的外围作用域绑定共同构成的机制,称为闭包(closure)。
闭包常见于以下场景:
- 创建具有不同配置的函数。
- 保存函数需要使用的状态。
- 将公共逻辑封装起来,避免每次调用都重复传入相同的配置参数。
通过实际运行观察闭包
理解概念还不够,我们可以直接查看函数对象的属性。
1 | def make_checker(threshold): |
输出结果:
1 | False |
逐行解释。
首先:
1 | check_cpu = make_checker(80) |
创建了一个 CPU 检查函数,它使用的阈值为 80。
然后:
1 | print(check_cpu(75)) |
分别检查 CPU 使用率为 75 和 90 的情况。由于判断条件是 cpu_usage > threshold,因此前者为 False,后者为 True。
接下来,我们观察两个属性。
__code__.co_freevars
1 | print(check_cpu.__code__.co_freevars) |
输出:
1 | ('threshold',) |
co_freevars 会列出函数代码中引用的自由变量名称。这里的 threshold 不是 check() 自己的局部参数,而是来自外围函数的作用域。
__closure__
1 | print(check_cpu.__closure__[0].cell_contents) |
输出:
1 | 80 |
对于这个例子,__closure__ 中保存了闭包单元,cell_contents 可以让我们观察单元中当前关联的值。
这就提供了一个可以实际验证的证据:返回的函数不仅能继续执行,而且确实保留了访问外围函数变量的能力。
需要注意的是,__closure__ 可能为 None,例如某个函数没有需要通过闭包单元保存的自由变量时,就不能直接访问 __closure__[0]。上面的访问方式适用于这个已经确认具有闭包的示例。
嵌套函数的运维实战:创建不同阈值的检查器
了解闭包后,我们来做一个更贴近 Linux 运维的例子。
假设你正在编写服务器巡检脚本,需要检查不同服务器的 CPU 使用率:
- 普通业务服务器:CPU 使用率超过
80%时告警。 - 核心业务服务器:CPU 使用率超过
90%时告警。
如果每次检查都传入阈值,当然也能实现。但当检查逻辑逐渐增多时,我们可以把阈值封装在一个检查器中。
1 | def make_cpu_checker(threshold): |
输出结果:
1 | web-01: CPU 使用率 85% 超过阈值 80%,需要关注 |
这个例子有几个值得学习的地方。
第一,公共逻辑只需要编写一次。
检查 CPU 使用率、比较阈值、输出结果,这些逻辑都集中在 check() 中,不需要为普通服务器和核心服务器分别复制一份代码。
第二,不同的检查器拥有不同的配置。
1 | normal_checker = make_cpu_checker(80) |
这两次调用分别创建了不同的函数对象,它们使用不同的 threshold 绑定。
因此,虽然检查逻辑相同,但配置可以不同。
第三,调用检查器时不需要反复传入阈值。
1 | normal_checker("web-01", 85) |
调用方只需要提供服务器名称和 CPU 使用率,阈值已经由创建检查器时的参数确定。
这就是闭包在运维脚本中的一个实用价值:将公共逻辑与特定配置组合起来,生成可以重复使用的函数。
当然,生产环境中的 CPU 使用率通常还需要考虑采样周期、告警持续时间、数据来源以及误报处理。这里先聚焦函数封装和闭包,不把完整监控系统的复杂性混进示例中。
lambda 表达式:简洁地创建小型函数
嵌套函数解决了函数封装和作用域的问题。接下来,我们看看另一种创建函数的方式:lambda 表达式。
普通函数通常使用 def 定义:
1 | def add(a, b): |
输出结果:
1 | 5 |
如果函数逻辑很简单,只是将两个参数相加,也可以写成:
1 | add = lambda a, b: a + b |
输出结果同样是:
1 | 5 |
lambda 的基本语法是:
1 | lambda 参数: 表达式 |
它会创建一个函数对象,并将表达式的计算结果作为返回值。
例如:
1 | square = lambda x: x * x |
输出结果:
1 | 25 |
这里的 lambda x: x * x 相当于一个接收参数 x、返回 x * x 的小型函数。
不过,lambda 只能包含一个表达式,不能像普通 def 函数那样直接编写多条语句。因此,它适合简单、短小的逻辑;如果函数包含复杂判断、异常处理或多步操作,通常还是使用 def 更清晰。
lambda 的实际用途:配合 map、filter 和 sorted
上一篇文章已经学习了 map() 和 filter()。现在,我们可以把 lambda 用到这些函数中。
假设有一组服务器 CPU 使用率:
1 | cpu_usage = [35, 82, 67, 91, 48] |
使用 map() 批量处理数据
如果希望将每个 CPU 使用率转换为对应的剩余百分比,可以这样写:
1 | cpu_usage = [35, 82, 67, 91, 48] |
输出结果:
1 | [65, 18, 33, 9, 52] |
lambda usage: 100 - usage 定义了转换规则,map() 将这条规则应用到每个元素上,最后使用 list() 将结果转换为列表。
这里的“剩余百分比”只是演示数据转换的示例,并不意味着 CPU 使用率的补数一定等于真实系统中的 CPU 空闲率。实际监控数据还要考虑指标定义和采样口径。
使用 filter() 筛选数据
如果希望找出 CPU 使用率超过 80% 的记录:
1 | cpu_usage = [35, 82, 67, 91, 48] |
输出结果:
1 | [82, 91] |
这里,lambda usage: usage > 80 返回布尔值。filter() 保留判断结果为真的元素,因此最终得到 [82, 91]。
使用 sorted() 排序
再来看一个运维中常见的需求:对服务器的 CPU 使用率从高到低排序。
1 | servers = [ |
输出结果:
1 | [{'name': 'web-02', 'cpu': 92}, {'name': 'web-01', 'cpu': 75}, {'name': 'db-01', 'cpu': 68}] |
其中:
1 | key=lambda server: server["cpu"] |
表示使用每条服务器记录中的 cpu 字段作为排序依据。
reverse=True 表示按降序排列,因此 CPU 使用率最高的服务器排在最前面。
这就是 lambda 最常见的用法之一:当某个函数需要接收一个简单的处理规则时,可以用 lambda 快速提供这个规则,而不必专门定义一个只使用一次的函数。
嵌套函数、闭包和 lambda 有什么区别
| 概念 | 主要用途 | 典型场景 |
|---|---|---|
| 嵌套函数 | 在函数内部定义另一个函数 | 封装内部逻辑 |
| 闭包 | 让函数继续访问相关的外围作用域绑定 | 创建带有独立配置的检查器 |
nonlocal | 在嵌套函数中重新绑定外围函数的名称 | 实现计数器或更新内部状态 |
lambda | 简洁地创建只有一个表达式的函数 | 排序、映射和筛选 |
这几个概念可以组合使用,但它们并不是同一回事。
例如,下面的代码使用 lambda 创建了一个闭包:
1 | def make_multiplier(factor): |
输出结果:
1 | 10 |
make_multiplier() 返回的 lambda 函数会访问外围函数中的 factor。因此,double 和 triple 虽然使用相同的函数逻辑,却分别关联了不同的乘数。
这说明 lambda 也可以形成闭包。闭包的关键在于函数对外围作用域绑定的访问能力,而不是函数究竟使用 def 还是 lambda 定义。
实际开发中应该怎么选择
学习这些语法,不是为了把代码写得越短越好,而是为了让程序更容易理解、维护和扩展。
可以记住几个简单的原则:
- 需要封装内部逻辑时,考虑使用嵌套函数。
- 需要让函数保留特定配置时,考虑使用闭包。
- 需要在嵌套函数中重新绑定外围函数的变量时,使用
nonlocal。 - 只需要提供一条简单的表达式规则时,可以考虑使用
lambda。 - 函数逻辑较复杂,或者需要清晰的函数名时,优先使用
def。
例如,在服务器巡检脚本中,如果多个检查任务使用相同的判断逻辑、但阈值不同,可以用闭包生成不同的检查器。如果只是对服务器列表进行排序,直接使用 key=lambda ... 就足够了。
不要为了使用嵌套函数或 lambda 而使用它们。代码的可读性和可维护性,比少写几行更重要。
写在最后
这篇文章,我们从嵌套函数出发,逐步理解了 Python 的作用域、LEGB 名称查找模型、global 与 nonlocal 的区别,以及闭包如何让函数继续访问外围作用域中的绑定。
同时,我们还通过 __closure__ 和 co_freevars 实际观察了闭包,并使用 map()、filter() 和 sorted() 练习了 lambda 表达式。
接下来,我们将学习另一个有趣的话题:递归函数。
当一个函数能够调用自身时,程序就可以用一种简洁的方式处理具有重复结构的问题。不过,递归也有自己的使用条件和限制。下一篇文章,我们再结合实际示例,把它讲清楚。
