Python 系列(五十四) 理解生成器表达式与惰性计算
series: python-intro
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 系列(二十五) 聊聊模块与 import,让代码告别重复编写
- Python 系列(二十六) Python 模块入门,从 import 到模块复用
- Python 系列(二十七) Python dir() 函数详解,快速查看模块中的函数与属性
- Python 系列(二十八) 不会用 Python 标准库?教你快速查找模块和函数
- Python 系列(二十九) 搞懂 Python sys 模块,让脚本更适合自动化运维
- Python 系列(三十) 为什么 0.1 + 0.2 不等于 0.3?彻底搞懂 Decimal
- Python 系列(三十一) 搞懂 datetime 日期时间,轻松处理时区与时间计算
- Python 系列(三十二) 面向对象编程入门,从零理解类、对象、封装与继承
- Python 系列(三十三) 什么是类和对象?零基础理解 Python class
- Python 系列(三十四) Python 属性详解,搞懂类属性、实例属性和 setter
- Python 系列(三十五) 学会特殊方法,让对象打印出你想要的内容
- Python 系列(三十六) 多个对象如何共享数据?彻底搞懂类变量
- Python 系列(三十七) 搞懂父类、子类和方法重写,真正理解继承与多态
- Python 系列(三十八) 如何判断数据类型?三个内置函数一次讲明白
- Python 系列(三十九) 搞懂异常模型与常见异常类型,读懂程序报错
- Python 系列(四十) 从零学会异常处理,掌握 try、except、else 和 finally
- Python 系列(四十一) ValueError、TypeError 到底是什么?一文搞懂常见异常
- Python 系列(四十二) ValueError、TypeError 到底是什么?一文搞懂常见异常
- Python 系列(四十三) 别只会处理异常!学会自定义异常,让程序更容易维护
- Python 系列(四十四) 从零认识 assert 断言,让程序学会检查自己
- Python 系列(四十五) 使用 sys 模块处理标准输入与输出
- Python 系列(四十六) 使用 open() 读取和写入文件
- Python 系列(四十七) 学会读写文本文件,让程序保存运行结果
- Python 系列(四十八) 搞懂 bytes、bytearray 和二进制读写
- Python 系列(四十九) 使用 seek() 和 tell() 灵活控制文件读取位置
- Python 系列(五十) 掌握 os 和 os.path,实现文件与目录自动化管理
- Python 系列(五十一) 掌握列表推导式,轻松实现列表生成与数据筛选
- Python 系列(五十二) 掌握字典推导式,让字典创建更简单
- Python 系列(五十三) 从零理解生成器,轻松处理大量数据
- Python 系列(五十五) 使用 zip() 同时遍历多个集合
前面我们学习了生成器,知道可以使用 yield 让函数一次产生一个结果,而不是一次性返回所有结果。
例如,我们可以编写一个生成器函数,依次计算数字的平方:
1 | def generate_squares(): |
运行结果:
1 | 0 |
这个程序先定义了一个生成器函数。每次执行到 yield 时,函数就会产生一个结果,并暂停执行。等到需要下一个结果时,函数再从上次暂停的位置继续执行。
不过,你可能会发现:这个函数的代码并不复杂,只是循环取出数字、计算平方,然后产生结果。为了完成这么简单的工作,是否一定要专门定义一个函数呢?
Python 提供了另一种更简洁的写法:生成器表达式(Generator Expression)。
它可以把循环和计算过程写在一对圆括号中,不需要专门定义一个生成器函数。
为什么需要生成器表达式
假设我们需要处理 100 万个数字,并计算每个数字的平方。
如果使用列表推导式,可以这样写:
1 | squares = [x * x for x in range(1000000)] |
这行代码会计算从 0 到 999999 的所有数字的平方,并将结果保存到一个列表中。
也就是说,程序需要创建一个包含 100 万个计算结果的列表。
如果后续操作需要反复访问这些结果,列表就很方便。但如果我们只想依次处理每个结果,并不需要把所有结果同时保存在内存中,那么有没有更合适的方式?
这时就可以使用生成器表达式:
1 | squares = (x * x for x in range(1000000)) |
注意观察这两行代码的区别:
1 | # 列表推导式:使用方括号 |
两者的计算规则很相似,但创建出来的对象不同。
列表推导式会立即计算所有结果,并将它们保存到列表中;生成器表达式则会创建一个生成器对象,等到需要结果时,再逐个计算。
这种按需执行计算的特点,称为惰性计算(Lazy Evaluation)。
需要注意,生成器表达式并不是创建时什么都不执行。Python 会在创建时求值最外层 for 右侧的表达式,以取得迭代所需的数据源;但循环体中的计算会延迟到获取结果时才执行。
生成器表达式的基本语法
生成器表达式的基本语法如下:
1 | (表达式 for 变量 in 可迭代对象) |
这几个部分分别表示:
表达式:决定每次产生什么结果。for 变量 in 可迭代对象:依次从数据中取出一个元素,并赋值给变量。- 圆括号
():将整个表达式组合起来,创建生成器对象。
例如:
1 | gen = (x * x for x in range(5)) |
我们可以把它拆开理解:
range(5):提供从0到4的数字。x:每次接收一个数字。x * x:计算当前数字的平方。- 圆括号:创建生成器表达式对应的生成器对象。
整个过程可以表示为:
flowchart TD
A["创建生成器表达式"] --> B["取得最外层可迭代对象"]
B --> C["创建生成器对象"]
C --> D["开始获取结果"]
D --> E["取出下一个元素"]
E --> F["执行表达式计算"]
F --> G["产生一个结果"]
G --> H{"是否继续获取结果?"}
H -->|是| E
H -->|否| I["暂停获取,或在数据耗尽后结束"]这里需要区分两个阶段:创建生成器对象,以及开始从生成器中获取结果。
创建生成器对象时,不会立即计算所有数字的平方;只有开始遍历或调用 next() 时,生成器才会逐步执行循环体中的计算。
创建生成器时发生了什么
先创建一个最简单的生成器表达式:
1 | gen = (x * x for x in range(5)) |
运行结果类似:
1 | <generator object <genexpr> at 0x...> |
你可能会感到奇怪:为什么没有输出 0、1、4、9、16,反而出现了一段看起来比较复杂的内容?
这是因为 gen 保存的不是计算结果组成的列表,而是一个生成器对象。
它可以理解为一个能够按需提供数据的对象。创建它时,Python 会准备好生成器的执行状态,并取得最外层 for 所需的可迭代对象,但不会立即计算所有元素的平方。
其中的 0x... 是对象标识信息的显示形式,具体内容可能不同,不必特别关注。
创建时并不是所有操作都延迟执行
为了准确理解生成器表达式,我们来看一个例子:
1 | def get_numbers(): |
运行结果:
1 | 正在准备数据 |
为什么 get_numbers() 已经执行了,而 x * x 还没有计算?
因为 get_numbers() 位于最外层 for 的右侧:
1 | for x in get_numbers() |
Python 创建生成器表达式时,需要先求值这个表达式,取得可供迭代的数据源。因此,函数 get_numbers() 会立即执行,并返回列表 [1, 2, 3]。
但是,生成器表达式中的循环体还没有开始执行,所以 x * x 并没有计算。
如果接下来调用:
1 | print(next(gen)) |
运行结果会继续出现:
1 | 1 |
此时生成器才开始从数据源中取出第一个元素 1,计算 1 * 1,并产生结果 1。
因此,准确地说:
生成器表达式会延迟执行循环体中的计算,但创建时仍会求值最外层 for 右侧的表达式,以取得迭代所需的可迭代对象。
使用 for 循环获取生成器中的数据
最常见的方式是使用 for 循环遍历生成器:
1 | gen = (x * x for x in range(5)) |
运行结果:
1 | 0 |
程序的执行过程是这样的:
- 第一次循环,获取数字
0,计算0 * 0,产生结果0。 - 第二次循环,获取数字
1,计算1 * 1,产生结果1。 - 第三次循环,获取数字
2,计算2 * 2,产生结果4。 - 后续循环依次产生
9和16。 - 所有结果产生完毕后,循环自动结束。
每次生成一个结果后,生成器都会保留当前的执行状态。下一次需要结果时,它会从上次暂停的位置继续执行,而不是重新从头计算。
使用 next() 一次获取一个结果
除了 for 循环,我们还可以使用 next() 函数手动获取生成器中的下一个结果。
例如:
1 | gen = (x * x for x in range(5)) |
运行结果:
1 | 0 |
每调用一次 next(gen),就会尝试获取一个新的结果。
第一次调用,生成器产生 0;第二次调用,产生 1;第三次调用,产生 4。
为了更清楚地观察计算发生的时机,我们可以编写一个带有打印信息的函数:
1 | def square(x): |
运行结果:
1 | 生成器创建完成 |
注意观察输出顺序。
创建生成器时,程序只输出了 生成器创建完成,没有出现任何平方计算信息。
第一次调用 next(gen) 时,才输出 正在计算 0 的平方,然后返回 0。
第二次调用 next(gen) 时,才计算数字 1 的平方,然后返回 1。
由于程序只调用了两次 next(),数字 2 的平方还没有计算。
这个实验说明,生成器表达式中的循环体会随着结果的获取逐步执行,而不是在创建生成器时一次性完成。
但是,如果最外层 for 右侧的表达式本身包含函数调用,那么这个函数调用仍然可能在创建生成器时发生。这一点不能与循环体的惰性执行混为一谈。
当所有结果都取完之后,如果再次调用 next(),就会触发 StopIteration 异常,表示生成器已经没有更多结果了。
例如:
1 | gen = (x * x for x in range(2)) |
运行结果:
1 | 0 |
这是生成器正常结束时的行为,并不意味着生成器本身出现了故障。
实际开发中,如果只是想依次处理所有结果,通常使用 for 循环即可,不需要自己判断什么时候结束。
生成器表达式适合哪些场景
生成器表达式特别适合这样的任务:数据量可能很大,但我们只需要依次处理数据,而不需要同时保存全部结果。
对于 Linux 运维人员来说,处理日志就是一个很典型的例子。
假设我们有一个 access.log 文件,里面记录了 Nginx 的访问日志。我们希望找出所有包含 HTTP 500 状态码的日志行。
先使用下面的命令创建一个用于演示的小文件:
1 | cat > access.log <<'EOF' |
这段命令会创建一个名为 access.log 的文件,其中包含四条模拟访问日志。
接下来,创建 Python 文件 filter_logs.py:
1 | with open("access.log", "r", encoding="utf-8") as file: |
运行程序:
1 | python filter_logs.py |
运行结果:
1 | 192.168.1.11 - - [21/Oct/2026:10:00:01 +0800] "GET /api HTTP/1.1" 500 |
我们重点分析这一行:
1 | error_logs = (line.strip() for line in file if " 500" in line) |
它包含三个重要部分:
for line in file:依次读取文件中的每一行。if " 500" in line:只保留包含指定文本的日志行。line.strip():去掉每行首尾的空白字符,包括行尾的换行符。
创建 error_logs 时,Python 会取得文件对象 file 作为可迭代对象,但不会立即把整个文件读完,也不会立即执行所有日志行的筛选和处理。
当 for log in error_logs 开始遍历时,生成器才会逐步从文件中读取一行,检查这一行是否符合条件,并对符合条件的行执行 strip(),然后产生结果。
对于大型日志文件,这种写法可以避免额外创建一个保存全部匹配结果的列表。
不过,这里还有一个需要注意的地方:文件对象本身也是可迭代对象,读取会随着生成器的遍历而进行。因此,必须确保文件在生成器使用期间仍然处于打开状态。
上面的代码将生成器的创建和遍历都放在 with 代码块内,正是为了保证这一点。
另外,这个示例中的 500 只是为了演示生成器表达式的使用方式。在实际环境中,如果需要精确识别 HTTP 状态码,建议解析日志格式,而不是简单地依赖字符串包含关系。
生成器表达式与列表推导式有什么区别
现在我们已经知道,生成器表达式和列表推导式都可以通过循环创建数据,但它们的行为并不相同。
我们通过一个例子进行比较:
1 | # 列表推导式 |
运行结果:
1 | [0, 1, 4, 9, 16] |
虽然最终显示的结果相同,但两者在创建数据时的行为不同。
| 对比项目 | 列表推导式 | 生成器表达式 |
|---|---|---|
| 语法 | 使用 [] | 使用 () |
| 创建时是否计算全部结果 | 是 | 否 |
| 是否保存全部结果 | 是 | 否 |
| 是否可以按需产生结果 | 否,结果已创建 | 是 |
| 能否直接多次遍历全部结果 | 可以 | 不可以直接重复消费同一个生成器 |
| 适合场景 | 需要保存、反复访问结果 | 依次处理数据、减少额外结果存储 |
这里还需要解释一下 list(numbers_gen)。
list() 会把生成器中剩余的结果全部取出来,并创建一个列表。因此,它适合用于演示或确实需要全部结果的情况,但如果我们的目标是避免一次性保存大量结果,就不应该随意将大型生成器转换成列表。
另外,列表推导式中的计算通常在创建列表时完成;生成器表达式中的循环体则是在遍历过程中逐步执行。它们不仅在内存使用方式上存在差异,在计算发生的时机上也不同。
生成器表达式只能使用一次吗
严格来说,生成器表达式创建的生成器对象是一次性迭代器。
例如:
1 | gen = (x * x for x in range(3)) |
运行结果:
1 | 0 |
第二次循环没有输出任何数字。
原因是第一次循环已经取完了生成器中的所有结果。生成器不会因为重新执行一次 for 循环,就自动从头开始计算。
如果确实需要再次遍历,可以重新创建生成器:
1 | gen = (x * x for x in range(3)) |
这样两次循环都能得到完整结果。
不过,如果数据来自一个已经关闭的文件,或者来自网络响应、数据库游标等有生命周期限制的资源,仅仅重新创建生成器表达式并不一定就能恢复数据。必须确保数据源本身仍然可用,必要时重新打开文件或重新获取数据。
因此,使用生成器时,要记住一个重要原则:生成器对象中的结果会随着遍历逐步被消费,遍历结束后不能直接从头再来。
生成器表达式与生成器函数有什么区别
前面我们已经学过生成器函数,它使用 def 定义,并通过 yield 产生结果。
例如:
1 | def generate_squares(): |
使用生成器表达式,可以写成:
1 | generate_squares = (x * x for x in range(5)) |
两种方式都可以逐个产生平方值,但适用场景有所不同。
生成器表达式适合简单的循环和计算逻辑,写法简洁,适合快速创建一个生成器。
生成器函数则适合更复杂的处理流程。例如,需要多步计算、多个条件分支、异常处理,或者需要在不同位置产生结果时,使用生成器函数通常更加清晰。
可以简单理解为:
- 逻辑简单、只需要一个循环表达式时,优先考虑生成器表达式。
- 逻辑复杂、需要多步处理时,优先考虑生成器函数。
它们并不是互相替代的关系,而是两种适用于不同场景的工具。
使用生成器表达式时需要注意什么
生成器表达式虽然简洁,但也有一些常见的注意事项。
不要误以为创建生成器就已经完成计算。
1 | gen = (x * x for x in range(5)) |
执行这行代码后,生成器对象已经创建,但所有平方值并没有全部计算完成。只有在遍历或调用 next() 时,才会逐步产生结果。
同时,不要忽略最外层 for 右侧表达式的执行时机。如果这里调用了一个函数,该函数可能在创建生成器表达式时就会执行。
不要在需要反复访问结果时,直接把生成器当作列表使用。
生成器不能像列表一样通过 gen[0] 获取第一个元素,也不能直接通过 len(gen) 获取剩余元素数量。如果需要索引、长度或者多次访问,可以根据实际需求使用列表。
不要忽略生成器依赖的数据源。
如果生成器表达式依赖一个文件对象,那么在遍历过程中,文件必须保持打开状态。资源已经关闭时,继续读取可能会失败。
不要认为生成器一定比列表更快。
生成器的主要优势是按需产生结果,避免一次性保存全部结果。对于需要反复使用的数据,列表可能更方便;对于数据量很小的任务,使用生成器也不一定会带来明显的性能优势。
动手练习
学完之后,可以尝试下面三个小练习。
生成偶数
创建一个生成器表达式,依次产生 0 到 10 之间的偶数。
参考代码:
1 | even_numbers = (x for x in range(11) if x % 2 == 0) |
运行结果:
1 | 0 |
这里的 x % 2 == 0 表示判断数字除以 2 后的余数是否为 0。满足条件的数字才会被产生出来。
转换服务器名称
假设我们有一组服务器名称,需要统一转换成大写形式:
1 | servers = ["web01", "web02", "db01"] |
运行结果:
1 | WEB01 |
这个示例说明,生成器表达式不只能计算数字,也可以转换字符串或处理其他类型的数据。
筛选高 CPU 使用率
假设我们已经获取了几台服务器的 CPU 使用率:
1 | cpu_usage = [35, 92, 68, 88, 41] |
运行结果:
1 | 92 |
程序会逐个检查 CPU 使用率,只产生大于或等于 80 的数值。
需要说明的是,这里演示的是对已有列表进行筛选。如果数据最初已经全部保存在列表中,生成器只能避免额外创建一份筛选结果列表,并不能消除原始列表占用的内存。
写在最后
通过这篇文章,我们学习了生成器表达式的基本语法,以及如何使用 for 循环和 next() 获取生成器中的结果。
生成器表达式的核心特点是:使用简洁的圆括号语法,按需产生数据,而不是提前创建包含全部结果的列表。
理解惰性计算时,尤其要记住两个要点:
- 创建生成器表达式时,Python 会求值最外层
for右侧的表达式,以取得迭代所需的数据源。 - 生成器表达式中的循环体会在获取结果时逐步执行,而不是在创建时计算所有结果。
在日常 Python 开发和 Linux 运维工作中,当我们需要筛选日志、转换数据或逐个处理大量记录时,生成器表达式可以让代码更加简洁,也有助于减少额外的内存占用。
但要记住,生成器对象中的结果会随着遍历逐步被消费。如果后续需要反复访问所有结果,就应该根据实际需求选择列表,或者重新创建生成器。
下一篇,我们将学习 Python 中另一个非常实用的内置函数:zip()。它可以把多个可迭代对象中的元素按照位置配对,帮助我们同时处理多组相关数据。
