Python 系列(五十三) 从零理解生成器,轻松处理大量数据
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() 同时遍历多个集合
前面我们学习了列表、元组、集合和字典,也了解了如何使用列表推导式和字典推导式快速创建数据。
如果只需要处理几个数据,直接创建一个列表就足够了。但如果需要处理几十万条日志、上百万条数据,甚至需要逐条检查大量服务器的信息,我们是否还需要一次性把所有数据都放进列表中呢?
这就引出了 Python 中一个非常实用的功能:生成器(Generator)。
生成器可以让我们在需要数据时,再逐个生成数据,而不是提前把所有数据都准备好。对于处理大量数据的程序来说,这种方式通常更加节省内存。
为什么需要生成器
假设我们要生成从 1 到 1000000 的整数。
对于刚刚接触 Python 的小白来说,最容易想到的方式就是使用 range() 配合列表,将所有数据保存起来。
1 | numbers = list(range(1, 1000001)) |
这里先解释几个容易看不懂的地方:
range(1, 1000001)表示生成从 1 开始、到 1000000 结束的整数序列。注意,结束位置1000001本身不包含在内。list()将这些整数全部转换成一个列表。numbers[:5]表示取出列表最前面的 5 个元素。len(numbers)用于计算列表中有多少个元素。
运行结果:
1 | [1, 2, 3, 4, 5] |
程序运行后,变量 numbers 保存了包含一百万个整数的列表。
如果只是想依次读取这些数字,我们真的有必要提前创建并保存全部一百万个整数吗?
对于某些场景来说,这样做并不划算。
例如,假设我们要逐条读取日志,只需要分析当前这一行,然后再读取下一行。与其把所有日志内容一次性读入内存,不如每次只处理当前需要的数据。
生成器就是为这类需求提供的一种解决方案。
认识生成器
可以先把生成器理解为一个能够按需提供数据的对象。
普通列表会保存已经创建好的元素,而生成器可以在每次需要数据时执行相应的代码,生成下一个结果。
下面先创建一个最简单的生成器。
1 | def number_generator(): |
这段代码看起来和普通函数很相似,但有一个关键区别:函数内部使用了 yield。
yield 可以把一个值交给调用者,并暂停当前函数的执行。等到下一次需要数据时,函数再从上次暂停的位置继续执行。
需要注意,定义这个函数时,函数体并不会立即执行。
我们先调用它,看看会得到什么。
1 | gen = number_generator() |
运行结果类似下面这样:
1 | <generator object number_generator at 0x00000123456789AB> |
其中的内存地址每次运行时可能不同。
这里得到的并不是数字 1,而是一个生成器对象。它可以记住当前执行到什么位置,并在需要时继续提供数据。
接下来,我们学习如何从生成器中获取数据。
使用 next() 获取下一个数据
Python 提供了 next() 函数,可以从生成器中获取下一个数据。
继续使用上面的 number_generator() 函数:
1 | gen = number_generator() |
运行结果:
1 | 1 |
为什么三次调用分别得到了不同的数字?
因为生成器每次返回一个值后,都会暂停在相应的 yield 位置。
我们可以把执行过程理解为:
flowchart TD
A["创建生成器对象"] --> B["第一次调用 next()"]
B --> C["执行到 yield 1"]
C --> D["返回数字 1,并暂停"]
D --> E["第二次调用 next()"]
E --> F["继续执行到 yield 2"]
F --> G["返回数字 2,并暂停"]
G --> H["第三次调用 next()"]
H --> I["继续执行到 yield 3"]
I --> J["返回数字 3,并暂停"]这就是生成器的基本工作方式:每次需要数据时,继续执行函数,直到遇到下一个 yield。
生成器没有数据时会怎样
上面的生成器只有三个 yield,只能提供三个数据。
如果我们继续调用 next(),会发生什么?
1 | gen = number_generator() |
前三次调用分别返回 1、2 和 3。第四次调用时,生成器已经没有更多数据,因此会抛出 StopIteration 异常。
运行结果的最后部分类似这样:
1 | 1 |
这并不代表生成器出现了程序错误,而是说明它已经执行完毕,没有更多数据可以提供。
不过,在日常编程中,我们通常不需要自己反复调用 next()。Python 的 for 循环可以自动获取数据,并在生成器结束时停止循环。
使用 for 循环遍历生成器
我们可以直接使用 for 循环读取生成器提供的数据。
1 | def number_generator(): |
运行结果:
1 | 1 |
这里的 for 循环会自动完成以下工作:
- 获取生成器提供的下一个数据。
- 将数据赋值给变量
number。 - 执行循环体中的
print(number)。 - 继续获取下一个数据,直到生成器结束。
当生成器没有更多数据时,for 循环会自动停止,不需要我们手动处理 StopIteration。
对于初学者来说,可以先记住一个简单的结论:使用 next() 可以手动获取一个数据,使用 for 循环可以自动遍历生成器提供的数据。
使用 yield 生成大量数据
前面的例子需要我们手动写出每一个 yield。如果需要生成一百万个整数,难道要写一百万行代码吗?
当然不需要。
我们可以结合 while 循环或者 for 循环,让生成器逐个生成数据。
例如,下面的生成器可以依次提供从 1 到 5 的整数。
1 | def number_generator(): |
运行结果:
1 | 1 |
我们逐步理解这段代码。
首先,number = 1 将变量 number 初始化为 1。
接下来,while number <= 5 表示只要 number 小于或等于 5,就继续执行循环。
循环内部的 yield number 会把当前数字交给调用者,然后暂停生成器。
当下一次需要数据时,程序从 yield number 后面继续执行,运行 number += 1,让数字增加 1。
随后,程序再次判断 while 条件,并决定是否继续生成下一个数字。
整个过程如下:
flowchart TD
A["number = 1"] --> B{"number <= 5?"}
B -->|是| C["yield number"]
C --> D["返回当前数字"]
D --> E["number += 1"]
E --> B
B -->|否| F["生成器结束"]这里有一个特别重要的细节:yield 暂停时,变量 number 的当前值以及函数的执行状态都会保留下来。下一次继续执行时,程序会从暂停的位置接着运行。
如果把上面的条件改成 number <= 1000000,就可以用同样的代码逐个生成一百万个整数,而不必提前创建一个包含一百万个元素的列表。
当然,生成器并不是完全不占用内存。它仍然需要保存自己的执行状态和当前需要的数据,只是避免了提前保存整个数据序列。
结合 Linux 运维场景理解生成器
对于从事 Linux 运维的同学来说,生成器有一个非常容易理解的应用场景:逐条处理日志。
假设我们有一个名为 app.log 的应用日志文件,内容如下:
1 | INFO 服务启动成功 |
现在,我们希望逐条读取日志,只输出包含 ERROR 的记录。
如果文件比较小,可以直接把全部内容读入内存。但如果日志文件达到几个 GB,一次性读取全部内容就可能占用大量内存。
这时,我们可以使用生成器逐行读取文件。
先在当前工作目录中创建 app.log 文件,将上面的日志内容保存进去。
然后创建一个名为 read_log.py 的 Python 文件,写入以下代码:
1 | def read_log(filename): |
运行命令:
1 | python read_log.py |
运行结果:
1 | ERROR 数据库连接失败 |
我们来看看这个程序是如何工作的。
定义日志生成器
1 | def read_log(filename): |
read_log() 是一个生成器函数,因为函数内部使用了 yield。
open() 用于打开日志文件,encoding="utf-8" 指定文本编码为 UTF-8。
for line in file 会逐行读取文件,而不是主动将整个文件一次性读入内存。
每次执行到 yield line 时,生成器就会把当前读取到的这一行交给调用者,并暂停执行。
这里有一个容易被忽略的细节:生成器暂停时,with open() 代码块并没有结束,文件通常也不会立即关闭。
原因是 yield 只是暂停函数的执行,并不会像 return 那样直接结束函数。因此,文件会在生成器继续执行并离开 with 代码块时关闭;如果生成器被提前关闭,也会执行相应的清理操作。
这意味着,如果我们只取出第一行就不再继续读取,文件可能仍然处于打开状态,直到生成器被关闭或清理。因此,使用生成器读取文件时,也必须考虑资源的释放问题。
逐条检查日志
1 | for log_line in read_log("app.log"): |
这里的 for 循环不断从生成器获取日志内容。
if "ERROR" in log_line 用于判断当前日志是否包含 ERROR。
只有满足条件的日志才会被输出。
strip() 用于去除字符串两端的空白字符,包括行尾的换行符,因此输出结果不会多出一个空行。
for 循环正常遍历完整个生成器时,文件会随着生成器执行结束而正常关闭。对于这个完整遍历的示例,资源管理是明确的。
不过,如果我们只想读取第一条日志,或者在循环过程中提前退出,就需要额外关注文件何时关闭。
提前停止读取时,如何释放文件资源
假设我们只想获取日志文件的第一行:
1 | log_generator = read_log("app.log") |
运行结果:
1 | INFO 服务启动成功 |
这里的 close() 用于关闭生成器。对于包含 with open() 的生成器,关闭生成器会使其退出相应的执行上下文,从而关闭已经打开的文件。
这个示例明确告诉我们:如果提前停止读取,不应该想当然地认为文件已经自动关闭。
在生产代码中,如果需要提前退出,应该设计清晰的资源管理方式。对于一次性读取文件的场景,也可以直接使用普通文件迭代,不一定非要再封装一层生成器。
例如:
1 | with open("app.log", "r", encoding="utf-8") as file: |
这种写法直接使用文件对象逐行读取。即使只读取一行,离开 with 代码块时文件也会关闭。
如果确实需要把文件读取封装为生成器,并且要确保提前退出时及时释放资源,可以使用 contextlib.closing():
1 | from contextlib import closing |
closing() 会在离开 with 代码块时调用生成器的 close() 方法。这样,即使没有遍历完整个文件,也能确保生成器得到关闭。
需要注意,生成器的 close() 并不是关闭任意外部资源的万能方法。这里能够关闭文件,是因为生成器内部使用了 with open(),关闭生成器时会退出这个上下文。
对于超大日志文件,这种逐行读取方式通常比 file.read() 一次性读取整个文件更节省内存。不过,如果后续代码又把所有日志收集到列表中,仍然可能占用大量内存。
生成器是一次性使用的吗
理解生成器的另一个重点,是它的可复用限制。
列表可以反复遍历,但生成器通常是一次性迭代对象。数据被取出后,生成器会继续向后执行;当生成器结束后,不能直接让它从头开始。
我们通过一个例子来理解。
1 | def number_generator(): |
运行结果:
1 | 1 |
为什么第二次循环没有输出任何数字?
因为第一次循环已经取完了生成器中的所有数据,生成器执行完毕。第二次循环使用的仍然是同一个生成器对象,它已经没有数据可以提供。
我们可以把这个过程理解为:
flowchart TD
A["创建生成器对象"] --> B["第一次遍历"]
B --> C["依次获取 1、2、3"]
C --> D["生成器执行结束"]
D --> E["第二次遍历同一个对象"]
E --> F["没有更多数据,循环结束"]为什么列表可以重复遍历
再来看看列表的情况:
1 | numbers = [1, 2, 3] |
运行结果:
1 | 1 |
列表会保存这些元素,因此我们可以多次遍历它。
生成器则不同。它会随着数据的逐个取出而向前推进,通常不会把已经生成的数据自动保存起来供下一次遍历。
所以,生成器更适合只需要顺序处理一次的数据流;列表则适合需要重复访问、随机读取或多次遍历的数据集合。
如果需要重复使用生成器怎么办
如果我们需要重新获取数据,可以再次调用生成器函数,创建一个新的生成器对象。
例如:
1 | def number_generator(): |
运行结果:
1 | 第一次遍历: |
这里的关键是:
1 | gen = number_generator() |
每次调用 number_generator(),都会创建一个新的生成器对象。
因此,第二次遍历并不是让原来的生成器重新开始,而是创建了一个新的生成器,再次执行相同的生成逻辑。
对于日志读取程序也是一样的。如果需要重新读取日志,可以再次调用 read_log("app.log"),获得新的生成器对象。
不过,这种方式有一个前提:底层数据仍然可以重新读取。例如,普通日志文件可以重新打开,但如果生成器依赖的是已经耗尽的网络连接、已经关闭的文件,或者一次性消费的数据源,就需要重新建立或获取相应的数据来源。
如果需要多次遍历同一批数据,也可以先把生成器的结果保存到列表中,但这样会重新占用保存全部数据所需的内存。
1 | def number_generator(): |
运行结果:
1 | [1, 2, 3] |
这种方式适合数据量较小、确实需要重复使用的场景。如果数据非常庞大,就要权衡重复访问的便利性和内存占用。
生成器与列表有什么区别
学习到这里,我们再回头比较列表和生成器。
| 对比项目 | 列表 | 生成器 |
|---|---|---|
| 数据获取方式 | 通常提前创建并保存元素 | 需要时逐个生成 |
| 内存使用 | 取决于保存的数据量及元素大小 | 通常只需保存执行状态和当前需要的数据 |
| 能否重复遍历 | 可以直接多次遍历 | 同一个生成器耗尽后不能直接重新遍历 |
| 获取单个数据 | 可以使用下标访问 | 可以使用 next() 获取下一个数据 |
| 适用场景 | 需要保存、重复访问数据 | 按顺序处理大量数据 |
这里需要特别说明:生成器并不意味着在任何情况下都比列表更快。对于很小的数据集合,列表可能更简单;如果需要反复随机访问某个位置,列表也更合适。
生成器的主要优势是按需生成数据,避免了不必要地保存大量元素。
例如,下面的代码分别使用列表和生成器生成整数:
1 | numbers_list = [number for number in range(1, 6)] |
运行结果:
1 | [1, 2, 3, 4, 5] |
注意两者的写法:
- 列表推导式使用方括号
[],例如[number for number in range(1, 6)]。 - 生成器表达式使用圆括号
(),例如(number for number in range(1, 6))。
两者虽然都可以通过循环产生数据,但列表推导式会创建列表,而生成器表达式会创建生成器对象。
生成器表达式是下一篇文章要重点学习的内容。
学习生成器时容易遇到的问题
为什么调用生成器函数后,没有立即得到数字
因为调用生成器函数时,Python 首先创建生成器对象。函数体会在第一次请求数据时才开始执行。
为什么每次调用 next(),得到的数字都不一样
因为生成器会记住上次暂停的位置。下一次请求数据时,会从那里继续执行,而不是重新从头运行。
生成器能不能无限生成数据
可以。例如,下面的生成器可以不断生成递增的整数:
1 | def number_generator(): |
运行结果:
1 | 1 |
这里的 while True 表示循环条件始终成立,因此生成器不会主动结束。
不过,只有调用者不断请求数据时,生成器才会继续生成。上面的代码只调用了三次 next(),所以只输出了三个数字。
如果使用 for 循环遍历这个无限生成器,却没有设置停止条件,循环就会一直执行。因此,在处理无限生成器时,需要特别注意终止条件。
生成器可以随时重新开始吗
不能直接重置已经耗尽的生成器。
如果需要重新获取数据,通常应再次调用生成器函数,创建新的生成器对象;如果数据量不大且需要多次访问,也可以将结果保存到列表中。
还要注意,生成器函数的定义可以重复使用,但每次调用它得到的是一个独立的新生成器对象。不要把“函数可以反复调用”和“同一个生成器可以反复遍历”混为一谈。
写在最后
这篇文章,我们从为什么需要生成器开始,学习了 yield 的基本用法,以及如何通过 next() 和 for 循环逐个获取数据。
还结合 Linux 运维中的日志分析场景,演示了如何使用生成器逐行读取日志,并筛选出包含 ERROR 的记录。同时,我们补充了文件资源的释放问题,以及生成器一次性消费、不能直接重复遍历的限制。
学习生成器时,先记住四个关键点:
yield:返回一个数据,并暂停生成器的执行。next():获取生成器提供的下一个数据。for:自动遍历生成器提供的数据,直到生成器结束。- 重新调用生成器函数:创建新的生成器对象,而不是重置原来的生成器。
生成器特别适合按需处理大量数据,但并不意味着所有场景都必须使用它。选择合适的数据处理方式,并且正确管理文件等外部资源,才是编写实用 Python 程序的关键。
下一篇,我们将继续学习生成器表达式,看看如何像列表推导式一样,用简洁的语法创建生成器。
