1
2
3
4
5
6
7
作者:李晓辉

联系方式:

1. 微信:Lxh_Chat

2. 邮箱:939958092@qq.com
  1. Python 系列(一) 从零开始认识 Python,入门其实没那么难
  2. Python 系列(二) 学 Python 的第一步,把开发环境搭建好
  3. Python 系列(三) 基础语法避坑指南,缩进、大小写、续行与注释
  4. Python 系列(四) Python 输入输出详解,print()、input() 与常见避坑指南
  5. Python 系列(五) 数值类型与运算符详解,整除、浮点精度与进制转换实战
  6. Python 系列(六) 字符串处理实战,查找、替换、切片与日志分析
  7. Python 系列(七) 类型转换详解,int()、float()、str() 与金额计算避坑
  8. Python 系列(八) Python 流程控制入门,掌握 if、elif 和 else
  9. Python 系列(九) Python 逻辑运算符详解,and、or、not 一篇搞懂
  10. Python 系列(十) while、for 循环实战,彻底搞懂 range、break 与 continue
  11. Python 系列(十一) list、tuple、set、dict,到底该怎么选?
  12. Python 系列(十二) List 列表实战,搞定数据增删改查与批量处理
  13. Python 系列(十三) Tuple 元组实战,搞懂不可变特性与 enumerate() 遍历
  14. Python 系列(十四) Set 集合实战,搞定数据去重与集合运算
  15. Python 系列(十五) Dict 字典详解,掌握快速查找与键值操作
  16. Python 系列(十六) sort() 和 sorted() 详解,掌握数据排序技巧
  17. Python 系列(十七) 自定义函数入门,让重复代码一次编写多次使用
  18. Python 系列(十八) 函数参数详解,搞懂位置参数、关键字参数和默认参数
  19. Python 系列(十九) 从 *args 到 **kwargs,让函数调用更灵活
  20. Python 系列(二十) 变量作用域是什么?一文搞懂 global 和 nonlocal
  21. Python 系列(二十一) 搞懂变量、对象和引用,理解函数参数传递机制
  22. Python 系列(二十二) map() 和 filter() 详解,轻松实现批量数据处理与筛选
  23. Python 系列(二十三) 嵌套函数与 lambda 表达式详解,搞懂作用域与闭包
  24. Python 系列(二十四) 搞懂递归函数,从基线条件到实际应用
  25. Python 系列(二十五) 聊聊模块与 import,让代码告别重复编写
  26. Python 系列(二十六) Python 模块入门,从 import 到模块复用
  27. Python 系列(二十七) Python dir() 函数详解,快速查看模块中的函数与属性
  28. Python 系列(二十八) 不会用 Python 标准库?教你快速查找模块和函数
  29. Python 系列(二十九) 搞懂 Python sys 模块,让脚本更适合自动化运维
  30. Python 系列(三十) 为什么 0.1 + 0.2 不等于 0.3?彻底搞懂 Decimal
  31. Python 系列(三十一) 搞懂 datetime 日期时间,轻松处理时区与时间计算
  32. Python 系列(三十二) 面向对象编程入门,从零理解类、对象、封装与继承
  33. Python 系列(三十三) 什么是类和对象?零基础理解 Python class
  34. Python 系列(三十四) Python 属性详解,搞懂类属性、实例属性和 setter
  35. Python 系列(三十五) 学会特殊方法,让对象打印出你想要的内容
  36. Python 系列(三十六) 多个对象如何共享数据?彻底搞懂类变量
  37. Python 系列(三十七) 搞懂父类、子类和方法重写,真正理解继承与多态
  38. Python 系列(三十八) 如何判断数据类型?三个内置函数一次讲明白
  39. Python 系列(三十九) 搞懂异常模型与常见异常类型,读懂程序报错
  40. Python 系列(四十) 从零学会异常处理,掌握 try、except、else 和 finally
  41. Python 系列(四十一) ValueError、TypeError 到底是什么?一文搞懂常见异常
  42. Python 系列(四十二) ValueError、TypeError 到底是什么?一文搞懂常见异常
  43. Python 系列(四十三) 别只会处理异常!学会自定义异常,让程序更容易维护
  44. Python 系列(四十四) 从零认识 assert 断言,让程序学会检查自己
  45. Python 系列(四十五) 使用 sys 模块处理标准输入与输出
  46. Python 系列(四十六) 使用 open() 读取和写入文件
  47. Python 系列(四十七) 学会读写文本文件,让程序保存运行结果
  48. Python 系列(四十八) 搞懂 bytes、bytearray 和二进制读写
  49. Python 系列(四十九) 使用 seek() 和 tell() 灵活控制文件读取位置
  50. Python 系列(五十) 掌握 os 和 os.path,实现文件与目录自动化管理
  51. Python 系列(五十一) 掌握列表推导式,轻松实现列表生成与数据筛选
  52. Python 系列(五十二) 掌握字典推导式,让字典创建更简单
  53. Python 系列(五十三) 从零理解生成器,轻松处理大量数据
  54. Python 系列(五十五) 使用 zip() 同时遍历多个集合

前面我们学习了列表、元组、集合和字典,也了解了如何使用列表推导式和字典推导式快速创建数据。

如果只需要处理几个数据,直接创建一个列表就足够了。但如果需要处理几十万条日志、上百万条数据,甚至需要逐条检查大量服务器的信息,我们是否还需要一次性把所有数据都放进列表中呢?

这就引出了 Python 中一个非常实用的功能:生成器(Generator)。

生成器可以让我们在需要数据时,再逐个生成数据,而不是提前把所有数据都准备好。对于处理大量数据的程序来说,这种方式通常更加节省内存。

为什么需要生成器

假设我们要生成从 1 到 1000000 的整数。

对于刚刚接触 Python 的小白来说,最容易想到的方式就是使用 range() 配合列表,将所有数据保存起来。

1
2
3
4
numbers = list(range(1, 1000001))

print(numbers[:5])
print(len(numbers))

这里先解释几个容易看不懂的地方:

  • range(1, 1000001) 表示生成从 1 开始、到 1000000 结束的整数序列。注意,结束位置 1000001 本身不包含在内。
  • list() 将这些整数全部转换成一个列表。
  • numbers[:5] 表示取出列表最前面的 5 个元素。
  • len(numbers) 用于计算列表中有多少个元素。

运行结果:

1
2
[1, 2, 3, 4, 5]
1000000

程序运行后,变量 numbers 保存了包含一百万个整数的列表。

如果只是想依次读取这些数字,我们真的有必要提前创建并保存全部一百万个整数吗?

对于某些场景来说,这样做并不划算。

例如,假设我们要逐条读取日志,只需要分析当前这一行,然后再读取下一行。与其把所有日志内容一次性读入内存,不如每次只处理当前需要的数据。

生成器就是为这类需求提供的一种解决方案。

认识生成器

可以先把生成器理解为一个能够按需提供数据的对象。

普通列表会保存已经创建好的元素,而生成器可以在每次需要数据时执行相应的代码,生成下一个结果。

下面先创建一个最简单的生成器。

1
2
3
4
def number_generator():
yield 1
yield 2
yield 3

这段代码看起来和普通函数很相似,但有一个关键区别:函数内部使用了 yield。

yield 可以把一个值交给调用者,并暂停当前函数的执行。等到下一次需要数据时,函数再从上次暂停的位置继续执行。

需要注意,定义这个函数时,函数体并不会立即执行。

我们先调用它,看看会得到什么。

1
2
3
gen = number_generator()

print(gen)

运行结果类似下面这样:

1
<generator object number_generator at 0x00000123456789AB>

其中的内存地址每次运行时可能不同。

这里得到的并不是数字 1,而是一个生成器对象。它可以记住当前执行到什么位置,并在需要时继续提供数据。

接下来,我们学习如何从生成器中获取数据。

使用 next() 获取下一个数据

Python 提供了 next() 函数,可以从生成器中获取下一个数据。

继续使用上面的 number_generator() 函数:

1
2
3
4
5
gen = number_generator()

print(next(gen))
print(next(gen))
print(next(gen))

运行结果:

1
2
3
1
2
3

为什么三次调用分别得到了不同的数字?

因为生成器每次返回一个值后,都会暂停在相应的 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
2
3
4
5
6
gen = number_generator()

print(next(gen))
print(next(gen))
print(next(gen))
print(next(gen))

前三次调用分别返回 1、2 和 3。第四次调用时,生成器已经没有更多数据,因此会抛出 StopIteration 异常。

运行结果的最后部分类似这样:

1
2
3
4
5
6
1
2
3
Traceback (most recent call last):
...
StopIteration

这并不代表生成器出现了程序错误,而是说明它已经执行完毕,没有更多数据可以提供。

不过,在日常编程中,我们通常不需要自己反复调用 next()。Python 的 for 循环可以自动获取数据,并在生成器结束时停止循环。

使用 for 循环遍历生成器

我们可以直接使用 for 循环读取生成器提供的数据。

1
2
3
4
5
6
7
def number_generator():
yield 1
yield 2
yield 3

for number in number_generator():
print(number)

运行结果:

1
2
3
1
2
3

这里的 for 循环会自动完成以下工作:

  • 获取生成器提供的下一个数据。
  • 将数据赋值给变量 number。
  • 执行循环体中的 print(number)。
  • 继续获取下一个数据,直到生成器结束。

当生成器没有更多数据时,for 循环会自动停止,不需要我们手动处理 StopIteration。

对于初学者来说,可以先记住一个简单的结论:使用 next() 可以手动获取一个数据,使用 for 循环可以自动遍历生成器提供的数据。

使用 yield 生成大量数据

前面的例子需要我们手动写出每一个 yield。如果需要生成一百万个整数,难道要写一百万行代码吗?

当然不需要。

我们可以结合 while 循环或者 for 循环,让生成器逐个生成数据。

例如,下面的生成器可以依次提供从 1 到 5 的整数。

1
2
3
4
5
6
7
8
9
def number_generator():
number = 1

while number <= 5:
yield number
number += 1

for number in number_generator():
print(number)

运行结果:

1
2
3
4
5
1
2
3
4
5

我们逐步理解这段代码。

首先,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
2
3
4
5
INFO 服务启动成功
INFO 用户登录成功
WARNING 磁盘使用率达到 85%
ERROR 数据库连接失败
INFO 服务恢复正常

现在,我们希望逐条读取日志,只输出包含 ERROR 的记录。

如果文件比较小,可以直接把全部内容读入内存。但如果日志文件达到几个 GB,一次性读取全部内容就可能占用大量内存。

这时,我们可以使用生成器逐行读取文件。

先在当前工作目录中创建 app.log 文件,将上面的日志内容保存进去。

然后创建一个名为 read_log.py 的 Python 文件,写入以下代码:

1
2
3
4
5
6
7
8
def read_log(filename):
with open(filename, "r", encoding="utf-8") as file:
for line in file:
yield line

for log_line in read_log("app.log"):
if "ERROR" in log_line:
print(log_line.strip())

运行命令:

1
python read_log.py

运行结果:

1
ERROR 数据库连接失败

我们来看看这个程序是如何工作的。

定义日志生成器

1
2
3
4
def read_log(filename):
with open(filename, "r", encoding="utf-8") as file:
for line in file:
yield line

read_log() 是一个生成器函数,因为函数内部使用了 yield。

open() 用于打开日志文件,encoding="utf-8" 指定文本编码为 UTF-8。

for line in file 会逐行读取文件,而不是主动将整个文件一次性读入内存。

每次执行到 yield line 时,生成器就会把当前读取到的这一行交给调用者,并暂停执行。

这里有一个容易被忽略的细节:生成器暂停时,with open() 代码块并没有结束,文件通常也不会立即关闭。

原因是 yield 只是暂停函数的执行,并不会像 return 那样直接结束函数。因此,文件会在生成器继续执行并离开 with 代码块时关闭;如果生成器被提前关闭,也会执行相应的清理操作。

这意味着,如果我们只取出第一行就不再继续读取,文件可能仍然处于打开状态,直到生成器被关闭或清理。因此,使用生成器读取文件时,也必须考虑资源的释放问题。

逐条检查日志

1
2
3
for log_line in read_log("app.log"):
if "ERROR" in log_line:
print(log_line.strip())

这里的 for 循环不断从生成器获取日志内容。

if "ERROR" in log_line 用于判断当前日志是否包含 ERROR。

只有满足条件的日志才会被输出。

strip() 用于去除字符串两端的空白字符,包括行尾的换行符,因此输出结果不会多出一个空行。

for 循环正常遍历完整个生成器时,文件会随着生成器执行结束而正常关闭。对于这个完整遍历的示例,资源管理是明确的。

不过,如果我们只想读取第一条日志,或者在循环过程中提前退出,就需要额外关注文件何时关闭。

提前停止读取时,如何释放文件资源

假设我们只想获取日志文件的第一行:

1
2
3
4
5
6
log_generator = read_log("app.log")

first_line = next(log_generator)
print(first_line.strip())

log_generator.close()

运行结果:

1
INFO 服务启动成功

这里的 close() 用于关闭生成器。对于包含 with open() 的生成器,关闭生成器会使其退出相应的执行上下文,从而关闭已经打开的文件。

这个示例明确告诉我们:如果提前停止读取,不应该想当然地认为文件已经自动关闭。

在生产代码中,如果需要提前退出,应该设计清晰的资源管理方式。对于一次性读取文件的场景,也可以直接使用普通文件迭代,不一定非要再封装一层生成器。

例如:

1
2
3
4
5
with open("app.log", "r", encoding="utf-8") as file:
first_line = next(file, None)

if first_line is not None:
print(first_line.strip())

这种写法直接使用文件对象逐行读取。即使只读取一行,离开 with 代码块时文件也会关闭。

如果确实需要把文件读取封装为生成器,并且要确保提前退出时及时释放资源,可以使用 contextlib.closing():

1
2
3
4
5
6
7
from contextlib import closing

with closing(read_log("app.log")) as log_generator:
first_line = next(log_generator, None)

if first_line is not None:
print(first_line.strip())

closing() 会在离开 with 代码块时调用生成器的 close() 方法。这样,即使没有遍历完整个文件,也能确保生成器得到关闭。

需要注意,生成器的 close() 并不是关闭任意外部资源的万能方法。这里能够关闭文件,是因为生成器内部使用了 with open(),关闭生成器时会退出这个上下文。

对于超大日志文件,这种逐行读取方式通常比 file.read() 一次性读取整个文件更节省内存。不过,如果后续代码又把所有日志收集到列表中,仍然可能占用大量内存。

生成器是一次性使用的吗

理解生成器的另一个重点,是它的可复用限制。

列表可以反复遍历,但生成器通常是一次性迭代对象。数据被取出后,生成器会继续向后执行;当生成器结束后,不能直接让它从头开始。

我们通过一个例子来理解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def number_generator():
yield 1
yield 2
yield 3

gen = number_generator()

for number in gen:
print(number)

print("再次遍历:")

for number in gen:
print(number)

运行结果:

1
2
3
4
1
2
3
再次遍历:

为什么第二次循环没有输出任何数字?

因为第一次循环已经取完了生成器中的所有数据,生成器执行完毕。第二次循环使用的仍然是同一个生成器对象,它已经没有数据可以提供。

我们可以把这个过程理解为:

flowchart TD
    A["创建生成器对象"] --> B["第一次遍历"]
    B --> C["依次获取 1、2、3"]
    C --> D["生成器执行结束"]
    D --> E["第二次遍历同一个对象"]
    E --> F["没有更多数据,循环结束"]

为什么列表可以重复遍历

再来看看列表的情况:

1
2
3
4
5
6
7
8
9
numbers = [1, 2, 3]

for number in numbers:
print(number)

print("再次遍历:")

for number in numbers:
print(number)

运行结果:

1
2
3
4
5
6
7
1
2
3
再次遍历:
1
2
3

列表会保存这些元素,因此我们可以多次遍历它。

生成器则不同。它会随着数据的逐个取出而向前推进,通常不会把已经生成的数据自动保存起来供下一次遍历。

所以,生成器更适合只需要顺序处理一次的数据流;列表则适合需要重复访问、随机读取或多次遍历的数据集合。

如果需要重复使用生成器怎么办

如果我们需要重新获取数据,可以再次调用生成器函数,创建一个新的生成器对象。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def number_generator():
yield 1
yield 2
yield 3

gen = number_generator()

print("第一次遍历:")
for number in gen:
print(number)

gen = number_generator()

print("第二次遍历:")
for number in gen:
print(number)

运行结果:

1
2
3
4
5
6
7
8
第一次遍历:
1
2
3
第二次遍历:
1
2
3

这里的关键是:

1
gen = number_generator()

每次调用 number_generator(),都会创建一个新的生成器对象。

因此,第二次遍历并不是让原来的生成器重新开始,而是创建了一个新的生成器,再次执行相同的生成逻辑。

对于日志读取程序也是一样的。如果需要重新读取日志,可以再次调用 read_log("app.log"),获得新的生成器对象。

不过,这种方式有一个前提:底层数据仍然可以重新读取。例如,普通日志文件可以重新打开,但如果生成器依赖的是已经耗尽的网络连接、已经关闭的文件,或者一次性消费的数据源,就需要重新建立或获取相应的数据来源。

如果需要多次遍历同一批数据,也可以先把生成器的结果保存到列表中,但这样会重新占用保存全部数据所需的内存。

1
2
3
4
5
6
7
8
9
def number_generator():
yield 1
yield 2
yield 3

numbers = list(number_generator())

print(numbers)
print(numbers)

运行结果:

1
2
[1, 2, 3]
[1, 2, 3]

这种方式适合数据量较小、确实需要重复使用的场景。如果数据非常庞大,就要权衡重复访问的便利性和内存占用。

生成器与列表有什么区别

学习到这里,我们再回头比较列表和生成器。

对比项目列表生成器
数据获取方式通常提前创建并保存元素需要时逐个生成
内存使用取决于保存的数据量及元素大小通常只需保存执行状态和当前需要的数据
能否重复遍历可以直接多次遍历同一个生成器耗尽后不能直接重新遍历
获取单个数据可以使用下标访问可以使用 next() 获取下一个数据
适用场景需要保存、重复访问数据按顺序处理大量数据

这里需要特别说明:生成器并不意味着在任何情况下都比列表更快。对于很小的数据集合,列表可能更简单;如果需要反复随机访问某个位置,列表也更合适。

生成器的主要优势是按需生成数据,避免了不必要地保存大量元素。

例如,下面的代码分别使用列表和生成器生成整数:

1
2
3
4
5
6
numbers_list = [number for number in range(1, 6)]
numbers_gen = (number for number in range(1, 6))

print(numbers_list)
print(next(numbers_gen))
print(next(numbers_gen))

运行结果:

1
2
3
[1, 2, 3, 4, 5]
1
2

注意两者的写法:

  • 列表推导式使用方括号 [],例如 [number for number in range(1, 6)]。
  • 生成器表达式使用圆括号 (),例如 (number for number in range(1, 6))。

两者虽然都可以通过循环产生数据,但列表推导式会创建列表,而生成器表达式会创建生成器对象。

生成器表达式是下一篇文章要重点学习的内容。

学习生成器时容易遇到的问题

为什么调用生成器函数后,没有立即得到数字

因为调用生成器函数时,Python 首先创建生成器对象。函数体会在第一次请求数据时才开始执行。

为什么每次调用 next(),得到的数字都不一样

因为生成器会记住上次暂停的位置。下一次请求数据时,会从那里继续执行,而不是重新从头运行。

生成器能不能无限生成数据

可以。例如,下面的生成器可以不断生成递增的整数:

1
2
3
4
5
6
7
8
9
10
11
12
def number_generator():
number = 1

while True:
yield number
number += 1

gen = number_generator()

print(next(gen))
print(next(gen))
print(next(gen))

运行结果:

1
2
3
1
2
3

这里的 while True 表示循环条件始终成立,因此生成器不会主动结束。

不过,只有调用者不断请求数据时,生成器才会继续生成。上面的代码只调用了三次 next(),所以只输出了三个数字。

如果使用 for 循环遍历这个无限生成器,却没有设置停止条件,循环就会一直执行。因此,在处理无限生成器时,需要特别注意终止条件。

生成器可以随时重新开始吗

不能直接重置已经耗尽的生成器。

如果需要重新获取数据,通常应再次调用生成器函数,创建新的生成器对象;如果数据量不大且需要多次访问,也可以将结果保存到列表中。

还要注意,生成器函数的定义可以重复使用,但每次调用它得到的是一个独立的新生成器对象。不要把“函数可以反复调用”和“同一个生成器可以反复遍历”混为一谈。

写在最后

这篇文章,我们从为什么需要生成器开始,学习了 yield 的基本用法,以及如何通过 next() 和 for 循环逐个获取数据。

还结合 Linux 运维中的日志分析场景,演示了如何使用生成器逐行读取日志,并筛选出包含 ERROR 的记录。同时,我们补充了文件资源的释放问题,以及生成器一次性消费、不能直接重复遍历的限制。

学习生成器时,先记住四个关键点:

  • yield:返回一个数据,并暂停生成器的执行。
  • next():获取生成器提供的下一个数据。
  • for:自动遍历生成器提供的数据,直到生成器结束。
  • 重新调用生成器函数:创建新的生成器对象,而不是重置原来的生成器。

生成器特别适合按需处理大量数据,但并不意味着所有场景都必须使用它。选择合适的数据处理方式,并且正确管理文件等外部资源,才是编写实用 Python 程序的关键。

下一篇,我们将继续学习生成器表达式,看看如何像列表推导式一样,用简洁的语法创建生成器。