把Python生成器用在生产环境后,我被资深运维骂了半小时,直到我给他看了内存占用图

前言:那场让我记忆犹新的线上事故
还记得两年前,我刚入职一家电商公司,负责数据处理相关的工作。
有一天,运营给了我一个需求:把过去三年的订单数据全部导出来,做一个用户购买行为分析。数据量不大,也就三千多万条记录,几百个G。
"这还不简单?"我心想。不就是读数据、清洗、统计、输出报告嘛。
我噼里啪啦写了一版代码,用列表把数据都装起来,然后各种map、filter、groupby,写得那叫一个优雅。
# 我当时写的代码(简化版)
def process_orders():
orders = []
for line in open("orders_3years.csv"):
order = parse_order(line)
if order["status"] == "paid":
orders.append(order)
# 各种统计分析
total_amount = sum(o["amount"] for o in orders)
user_count = len(set(o["user_id"] for o in orders))
# ... 还有十几个统计指标
return report
写完之后,我在测试环境跑了一下,用的是一百万条测试数据,跑得挺快,几分钟就出结果了。
然后我就信心满满地把代码部署到生产环境,开始跑全量数据。
结果,跑了没十分钟,运维的电话就打过来了。
“你丫写的什么玩意儿?!服务器内存都快爆了!8G内存给你干到7.8G了,再跑下去就要OOM了!”
电话那头,是我们公司的资深运维王哥,出了名的脾气暴。
我当时就懵了:“不会吧?我测试环境跑得好好的啊…”
“测试环境才多少数据?生产环境三千多万条!你用列表全装内存里?你是想把服务器搞崩吗?赶紧给我停了!”
我赶紧把任务停了,灰溜溜地去改代码。
改什么呢?我突然想到之前看过的Python生成器(Generator),说是能节省内存。但我一直觉得那是"炫技"用的,实际工作中用不上。
死马当活马医吧。我把代码里的列表全改成了生成器,append改成了yield,列表推导式改成了生成器表达式。
改完之后,代码变成了这样:
# 改成生成器之后的代码
def read_orders():
for line in open("orders_3years.csv"):
yield parse_order(line)
def filter_paid(orders):
for order in orders:
if order["status"] == "paid":
yield order
def process_orders():
orders = read_orders()
paid_orders = filter_paid(orders)
# 流式统计,不用把所有数据装内存里
total_amount = 0
user_ids = set()
count = 0
for order in paid_orders:
total_amount += order["amount"]
user_ids.add(order["user_id"])
count += 1
# ... 其他统计
return report
改完之后,我心里也没底。毕竟之前没在生产环境用过生成器,不知道靠不靠谱。
我找到王哥,说我改好了,能不能再跑一次试试。
王哥白了我一眼:“你确定?别又把我服务器搞崩了。”
"应该…没问题吧。"我底气不足地说。
“行,再给你一次机会。要是再崩了,你就等着被老大骂吧。”
我战战兢兢地启动了任务,眼睛死死盯着监控面板上的内存曲线。
一分钟过去了…内存占用:200MB。
五分钟过去了…内存占用:230MB。
十分钟过去了…内存占用:250MB。
一直到任务跑完,内存最高也就300MB左右,CPU使用率也很平稳。
王哥凑过来,盯着监控看了半天,一脸不敢相信的表情:
“这…这就完了?三千多万条数据,内存才用了300MB?你是不是没跑全量?”
"跑了啊,都跑完了,结果都出来了。"我把报告递给他。
王哥翻了翻报告,又看了看监控,又看了看我,半天没说出话来。
过了好一会儿,他才憋出一句:“你这用的什么黑科技?”
"不是什么黑科技,就是Python的生成器(Generator)。"我笑着说。
那天之后,王哥对我态度好多了,还经常跟我讨教Python性能优化的问题。
很多人觉得生成器是个"高级特性",平时工作中用不上。但其实,生成器是Python中非常实用的一个特性,特别是在处理大数据量的时候,能帮你节省大量内存。
今天这篇文章,我就从最基础的概念讲起,带你深入理解Python生成器。看完这篇文章,你不仅能看懂生成器代码,还能在实际工作中灵活运用,写出既高效又优雅的代码。
第一章:什么是生成器?——从列表推导式说起
在讲生成器之前,我们先来看一个很常见的东西:列表推导式。
1.1 列表推导式的问题
列表推导式(List Comprehension)是Python中非常方便的语法,可以快速生成列表。
# 生成1到10的平方
squares = [x * x for x in range(1, 11)]
print(squares) # [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
列表推导式写起来简洁,用起来也方便。但是,它有一个问题:会一次性生成所有元素,全部放到内存里。
如果数据量小,当然没问题。但如果数据量很大呢?比如100万个、1000万个、甚至1亿个元素?
我们来做个实验:
import sys
# 生成100万个数字的列表
big_list = [x for x in range(1_000_000)]
print(f"列表占用内存: {sys.getsizeof(big_list) / 1024 / 1024:.2f} MB")
运行结果:
列表占用内存: 8.49 MB
100万个整数,占用了约8.5MB内存。好像也不多?
那1000万个呢?
big_list = [x for x in range(10_000_000)]
print(f"列表占用内存: {sys.getsizeof(big_list) / 1024 / 1024:.2f} MB")
运行结果:
列表占用内存: 85.98 MB
1000万个,约86MB。
那1亿个呢?
big_list = [x for x in range(100_000_000)]
print(f"列表占用内存: {sys.getsizeof(big_list) / 1024 / 1024:.2f} MB")
运行结果:
列表占用内存: 859.99 MB
1亿个整数,就要占860MB内存了。这还只是整数,如果是字符串、字典、对象呢?占用会大得多。
而实际工作中,我们处理的数据往往比这大得多。比如日志文件、订单数据、用户行为数据,动不动就是几个G、几十个G。如果都用列表装到内存里,再大的内存也不够用。
这时候,生成器就派上用场了。
1.2 生成器表达式:把[]改成()
生成器表达式(Generator Expression)是最简单的生成器创建方式。
你只要把列表推导式的方括号[]改成圆括号(),就得到了一个生成器表达式。
# 列表推导式
list_comp = [x * x for x in range(10)]
# 生成器表达式
gen_exp = (x * x for x in range(10))
print(list_comp) # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
print(gen_exp) # <generator object <genexpr> at 0x...>
看到了吗?生成器表达式不会直接生成所有元素,而是返回一个生成器对象(generator object)。
那这个生成器对象怎么用呢?用next()函数,或者用for循环遍历。
gen = (x * x for x in range(5))
print(next(gen)) # 0
print(next(gen)) # 1
print(next(gen)) # 4
print(next(gen)) # 9
print(next(gen)) # 16
print(next(gen)) # StopIteration 异常
每次调用next(),生成器就会计算并返回下一个值,直到没有更多元素了,抛出StopIteration异常。
当然,我们一般不会手动调用next(),而是用for循环来遍历:
gen = (x * x for x in range(5))
for num in gen:
print(num)
输出:
0
1
4
9
16
for循环会自动处理StopIteration异常,所以我们不用管。
现在,我们来看看生成器的内存占用:
import sys
# 生成器表达式
gen = (x for x in range(1_000_000))
print(f"生成器占用内存: {sys.getsizeof(gen)} 字节")
运行结果:
生成器占用内存: 112 字节
等等,多少?112字节?!
100万个元素的列表要占8.5MB,而生成器只占112字节?这差了快8万倍啊!
别急,我们再试试1000万、1亿的:
gen1 = (x for x in range(1_000_000)) # 100万
gen2 = (x for x in range(10_000_000)) # 1000万
gen3 = (x for x in range(100_000_000)) # 1亿
print(f"100万元素生成器: {sys.getsizeof(gen1)} 字节")
print(f"1000万元素生成器: {sys.getsizeof(gen2)} 字节")
print(f"1亿元素生成器: {sys.getsizeof(gen3)} 字节")
运行结果:
100万元素生成器: 112 字节
1000万元素生成器: 112 字节
1亿元素生成器: 112 字节
没错,不管是100万、1000万还是1亿,生成器的内存占用都是112字节!
为什么?因为生成器根本就没有把所有元素都生成出来放在内存里。它只是记住了生成规则,每次你要下一个元素的时候,它才现场算一个给你。
这就叫惰性求值(Lazy Evaluation)——需要的时候才计算,不需要就不计算。

1.3 生成器函数:yield关键字
生成器表达式虽然方便,但功能有限,只能实现简单的逻辑。如果要实现复杂的逻辑,就需要用生成器函数(Generator Function)。
生成器函数和普通函数看起来差不多,只是把return换成了yield。
def count_up_to(n):
"""生成1到n的整数"""
num = 1
while num <= n:
yield num
num += 1
这个函数看起来很普通,但因为里面有yield关键字,所以它不是普通函数,而是一个生成器函数。
调用生成器函数,不会执行函数体,而是返回一个生成器对象:
counter = count_up_to(5)
print(counter) # <generator object count_up_to at 0x...>
然后,我们可以用next()或者for循环来遍历:
counter = count_up_to(5)
print(next(counter)) # 1
print(next(counter)) # 2
print(next(counter)) # 3
print(next(counter)) # 4
print(next(counter)) # 5
print(next(counter)) # StopIteration
或者用for循环:
for num in count_up_to(5):
print(num)
输出:
看起来和普通函数差不多?但其实,生成器函数的执行流程和普通函数完全不一样。
我们下一章详细讲。
1.4 生成器的本质:迭代器的一种
看到这里,你可能会问:生成器到底是什么?
简单说,生成器是一种特殊的迭代器(Iterator)。
什么是迭代器?迭代器就是实现了迭代器协议的对象,也就是有__iter__()和__next__()方法的对象。
生成器自动实现了这两个方法,所以它是迭代器。
而迭代器又属于可迭代对象(Iterable),所以生成器可以用for循环遍历。
它们的关系是这样的:
可迭代对象(Iterable)
├── 序列(列表、元组、字符串等)
├── 字典
├── 集合
└── 迭代器(Iterator)
└── 生成器(Generator)
├── 生成器表达式
└── 生成器函数
简单说:
-
凡是可以用for循环遍历的,都是可迭代对象
-
凡是可以用next()调用的,都是迭代器
-
生成器是迭代器的一种,用yield或者生成器表达式创建
你可以用iter()函数把可迭代对象转换成迭代器:
my_list = [1, 2, 3]
my_iter = iter(my_list)
print(next(my_iter)) # 1
print(next(my_iter)) # 2
print(next(my_iter)) # 3
列表本身是可迭代对象,但不是迭代器(不能直接用next),用iter()转换之后就变成迭代器了。
而生成器本身就是迭代器,所以可以直接用next。
第二章:生成器的工作原理——深入理解yield
生成器最神奇的地方,就是yield关键字。它到底是怎么工作的?为什么函数可以"暂停"和"恢复"?
这一章,我们就来深入理解生成器的工作原理。
2.1 yield vs return
首先,我们来对比一下yield和return的区别。
普通函数(return):
def normal_func():
print("开始执行")
return 1
print("这行不会执行") # return之后的代码永远不会执行
result = normal_func()
print(result)
输出:
开始执行
1
普通函数遇到return,就会返回值,然后函数就结束了,后面的代码不会执行。下次再调用这个函数,会从头开始执行。
生成器函数(yield):
def gen_func():
print("开始执行")
yield 1
print("暂停后恢复,继续执行")
yield 2
print("再暂停,再恢复")
yield 3
print("结束")
gen = gen_func() # 调用生成器函数,不会执行函数体
print("生成器创建完成")
print(next(gen)) # 第一次next,开始执行
print("---")
print(next(gen)) # 第二次next,从yield 1后面继续
print("---")
print(next(gen)) # 第三次next,从yield 2后面继续
print("---")
print(next(gen)) # 第四次next,从yield 3后面继续,遇到结束,抛异常
输出:
生成器创建完成
开始执行
1
---
暂停后恢复,继续执行
2
---
再暂停,再恢复
3
---
结束
Traceback (most recent call last):
File "...", line ..., in <module>
print(next(gen))
StopIteration
发现了吗?生成器函数的执行是"断断续续"的:
-
调用生成器函数(
gen_func()),不会执行函数体,只是创建一个生成器对象 -
第一次调用
next(gen),函数开始执行,遇到第一个yield,暂停,把yield后面的值返回 -
第二次调用
next(gen),从上次暂停的地方继续执行,遇到第二个yield,再暂停,返回值 -
以此类推,直到函数执行完,抛出
StopIteration异常
这就是生成器最核心的特性:可以暂停,也可以恢复。每次暂停的时候,函数的所有状态(局部变量、执行位置等)都会被保存下来,恢复的时候接着用。

2.2 生成器的执行流程详解
我们再通过一个更详细的例子,来看看生成器的执行流程:
def fibonacci(n):
"""生成斐波那契数列的前n项"""
a, b = 0, 1
count = 0
print("生成器启动了!")
while count < n:
print(f"准备yield第{count+1}项: {b}")
yield b
print(f"从yield恢复,继续计算下一项")
a, b = b, a + b
count += 1
print("生成器结束了!")
# 创建生成器
fib = fibonacci(3)
print("生成器对象:", fib)
print("---")
# 第一次next
print("第一次next:")
result1 = next(fib)
print("返回值:", result1)
print("---")
# 第二次next
print("第二次next:")
result2 = next(fib)
print("返回值:", result2)
print("---")
# 第三次next
print("第三次next:")
result3 = next(fib)
print("返回值:", result3)
print("---")
# 第四次next(会抛异常)
print("第四次next:")
try:
result4 = next(fib)
print("返回值:", result4)
except StopIteration:
print("遇到StopIteration,生成器结束了")
运行结果:
生成器对象: <generator object fibonacci at 0x...>
---
第一次next:
生成器启动了!
准备yield第1项: 1
返回值: 1
---
第二次next:
从yield恢复,继续计算下一项
准备yield第2项: 1
返回值: 1
---
第三次next:
从yield恢复,继续计算下一项
准备yield第3项: 2
返回值: 2
---
第四次next:
从yield恢复,继续计算下一项
生成器结束了!
遇到StopIteration,生成器结束了
我们来一步步分析:
步骤1:创建生成器
fib = fibonacci(3)
-
调用生成器函数,不会执行函数体
-
只是创建了一个生成器对象,保存了函数的代码和参数
-
函数里的print都没有执行
步骤2:第一次next
next(fib)
-
函数开始执行,从第一行开始
-
打印"生成器启动了!"
-
进入while循环
-
打印"准备yield第1项: 1"
-
遇到
yield b,暂停执行 -
把b的值(1)返回给调用者
-
函数的所有状态(a=0, b=1, count=0, 执行位置等)都被保存下来
步骤3:第二次next
next(fib)
-
从上次暂停的地方恢复执行
-
打印"从yield恢复,继续计算下一项"
-
执行
a, b = b, a + b→ a=1, b=1 -
count变成1
-
回到while循环条件,count < 3,继续
-
打印"准备yield第2项: 1"
-
遇到
yield b,再次暂停 -
返回b的值(1)
步骤4:第三次next
next(fib)
-
恢复执行
-
打印"从yield恢复,继续计算下一项"
-
执行
a, b = b, a + b→ a=1, b=2 -
count变成2
-
回到while循环条件,count < 3,继续
-
打印"准备yield第3项: 2"
-
遇到
yield b,暂停 -
返回b的值(2)
步骤5:第四次next
next(fib)
-
恢复执行
-
打印"从yield恢复,继续计算下一项"
-
执行
a, b = b, a + b→ a=2, b=3 -
count变成3
-
回到while循环条件,count < 3不成立,退出循环
-
打印"生成器结束了!"
-
函数执行完毕,抛出
StopIteration异常
整个过程就像一个"拉拉链"的过程——你拉一下(next),它就走一点(yield一个值),再拉一下,再走一点,直到拉到底。
2.3 迭代器协议:iter__和__next
前面我们说过,生成器是一种迭代器。那迭代器到底是什么?
在Python中,迭代器协议包含两个方法:
-
__iter__():返回迭代器自身 -
__next__():返回下一个元素,如果没有了就抛出StopIteration
我们来验证一下生成器确实有这两个方法:
def gen():
yield 1
yield 2
g = gen()
print(hasattr(g, '__iter__')) # True
print(hasattr(g, '__next__')) # True
# 调用__iter__返回自身
print(g.__iter__() is g) # True
# 调用__next__和next()效果一样
print(g.__next__()) # 1
print(next(g)) # 2
没错,生成器确实实现了这两个方法,所以它是迭代器。
那为什么要有__iter__()方法,而且返回自身呢?
因为for循环的工作原理是这样的:
for x in something:
do_something(x)
等价于:
# 1. 先调用iter()获取迭代器
iterator = iter(something) # 内部调用something.__iter__()
while True:
try:
# 2. 不断调用next()获取下一个元素
x = next(iterator) # 内部调用iterator.__next__()
do_something(x)
except StopIteration:
# 3. 遇到StopIteration就结束
break
所以,只要是可迭代对象(有__iter__方法),就能用for循环遍历。
而迭代器本身也是可迭代对象(因为它有__iter__方法,返回自身),所以迭代器也能用for循环遍历。
这就是为什么生成器可以用for循环遍历的原因。
2.4 生成器的状态
生成器在生命周期中,有四种状态:
-
GEN_CREATED:创建完成,等待开始执行
-
GEN_RUNNING:正在执行(解释器在运行它)
-
GEN_SUSPENDED:暂停在某个yield处
-
GEN_CLOSED:执行完毕,已经关闭
我们可以用inspect模块来查看生成器的状态:
import inspect
def gen():
yield 1
yield 2
g = gen()
print(inspect.getgeneratorstate(g)) # GEN_CREATED
next(g)
print(inspect.getgeneratorstate(g)) # GEN_SUSPENDED
next(g)
print(inspect.getgeneratorstate(g)) # GEN_SUSPENDED
try:
next(g)
except StopIteration:
pass
print(inspect.getgeneratorstate(g)) # GEN_CLOSED
大部分时候,生成器都处于GEN_SUSPENDED状态——暂停在某个yield处,等待下一次next()调用。
这也是生成器节省内存的原因之一:它不需要把所有数据都存在内存里,只需要保存当前的状态,下次接着算就行。
第三章:生成器 vs 列表——内存占用大比拼
前面我们简单对比了一下生成器和列表的内存占用,这一章我们来做一个更全面的对比。
3.1 内存占用对比实验
我们来做一个实验:分别用列表和生成器生成N个数字,对比它们的内存占用。
import sys
import time
def test_list(n):
"""用列表生成n个数字"""
start_time = time.time()
data = [i for i in range(n)]
end_time = time.time()
memory = sys.getsizeof(data)
# 注意:sys.getsizeof只算列表本身的内存,不算里面的元素
# 但对于整数来说,小整数是共享的,所以额外开销不大
print(f"列表 - {n}个元素:")
print(f" 内存占用: {memory / 1024 / 1024:.2f} MB")
print(f" 生成时间: {end_time - start_time:.4f} 秒")
return data
def test_generator(n):
"""用生成器生成n个数字"""
start_time = time.time()
data = (i for i in range(n))
end_time = time.time()
memory = sys.getsizeof(data)
print(f"生成器 - {n}个元素:")
print(f" 内存占用: {memory} 字节")
print(f" 生成时间: {end_time - start_time:.4f} 秒")
return data
# 测试100万
print("=" * 50)
test_list(1_000_000)
print()
test_generator(1_000_000)
# 测试1000万
print("=" * 50)
test_list(10_000_000)
print()
test_generator(10_000_000)
# 测试1亿
print("=" * 50)
test_list(100_000_000)
print()
test_generator(100_000_000)
运行结果(大概数值,具体因环境而异):
==================================================
列表 - 1000000个元素:
内存占用: 8.49 MB
生成时间: 0.0345 秒
生成器 - 1000000个元素:
内存占用: 112 字节
生成时间: 0.0001 秒
==================================================
列表 - 10000000个元素:
内存占用: 85.98 MB
生成时间: 0.3124 秒
生成器 - 10000000个元素:
内存占用: 112 字节
生成时间: 0.0001 秒
==================================================
列表 - 100000000个元素:
内存占用: 859.99 MB
生成时间: 3.2341 秒
生成器 - 100000000个元素:
内存占用: 112 字节
生成时间: 0.0001 秒
从结果可以看出:
-
内存方面:列表的内存占用和元素数量成正比,元素越多,占用越大;而生成器不管多少元素,都是112字节。差距是几万倍甚至几十万倍。
-
生成时间方面:列表需要一次性生成所有元素,所以时间和数量成正比;生成器几乎是瞬间创建完成,因为它什么都没干,只是记住了规则。
3.2 为什么生成器这么省内存?
为什么差距这么大?根本原因在于惰性求值。
列表的工作方式:
-
一次性生成所有元素
-
把所有元素都存储在内存中
-
随时可以访问任意元素(随机访问)
-
可以多次遍历
生成器的工作方式:
-
只保存生成规则和当前状态
-
每次next()才计算下一个元素
-
只能顺序访问,不能随机访问
-
只能遍历一次
打个比方:
-
列表就像你把一整本书都打印出来,拿在手里,想翻哪页翻哪页,但书很重(占内存)
-
生成器就像一个电子书阅读器,每次只显示一页内容,很轻(省内存),但你只能一页一页往后翻,不能随便跳
3.3 生成器会不会更慢?
很多人会问:生成器是惰性求值,每次要的时候才计算,会不会比列表慢?
这个问题要分情况看。
场景一:只需要遍历一次
如果你只需要遍历数据一次,那生成器通常不会更慢,甚至可能更快。
为什么?因为:
-
列表需要先花时间把所有元素都生成好,然后再遍历
-
生成器是边生成边遍历,省去了一次性生成的时间
我们来测试一下:
import time
def sum_list(n):
data = [i for i in range(n)]
total = sum(data)
return total
def sum_generator(n):
data = (i for i in range(n))
total = sum(data)
return total
n = 10_000_000
start = time.time()
sum_list(n)
print(f"列表求和耗时: {time.time() - start:.4f}秒")
start = time.time()
sum_generator(n)
print(f"生成器求和耗时: {time.time() - start:.4f}秒")
运行结果:
列表求和耗时: 0.4567秒
生成器求和耗时: 0.3892秒
可以看到,生成器甚至还快了一点。因为列表需要先花时间生成所有元素,然后再求和;而生成器是边生成边加,省去了中间存储的开销。
场景二:需要多次遍历
如果你需要多次遍历数据,那生成器就不行了——因为生成器只能遍历一次,遍历完就空了。
gen = (i for i in range(5))
# 第一次遍历
print("第一次遍历:")
for x in gen:
print(x, end=" ")
print()
# 第二次遍历,什么都没有
print("第二次遍历:")
for x in gen:
print(x, end=" ")
print()
输出:
第一次遍历:
0 1 2 3 4
第二次遍历:
第二次遍历的时候,生成器已经耗尽了,所以什么都没有。
这时候你就需要用列表了,或者每次都重新创建生成器。
场景三:需要随机访问
如果你需要通过下标访问元素(比如data[100]),那生成器也不行。生成器只能顺序访问,不能跳。
这时候也得用列表,或者用其他支持随机访问的数据结构。
3.4 什么时候用生成器?什么时候用列表?
总结一下:
适合用生成器的场景:
-
数据量很大,内存装不下
-
只需要遍历一次
-
不需要随机访问
-
数据是流式的,不知道总共有多少
-
需要"流水线"式的处理
适合用列表的场景:
-
数据量不大,内存装得下
-
需要多次遍历
-
需要随机访问(通过下标)
-
需要修改、添加、删除元素
-
需要知道长度(len())
简单说:大数据量、一次遍历、流式处理 → 用生成器;小数据量、多次访问、需要随机访问 → 用列表。
第四章:生成器的高级用法——不只是迭代
很多人以为生成器只能用来迭代,其实不然。生成器还有一些高级用法,可以实现更强大的功能。
4.1 send():给生成器传值
我们知道,yield可以把值从生成器里"返回"给调用者。那反过来,能不能把值从调用者"传进"生成器里呢?
可以!用send()方法。
def echo():
"""一个回声生成器,收到什么就返回什么"""
print("生成器启动")
while True:
value = yield # yield在这里接收值
print(f"收到: {value}")
yield value * 2 # 返回处理后的值
gen = echo()
# 第一次必须用next()或者send(None)启动
next(gen) # 或者 gen.send(None)
# 输出: 生成器启动
# 用send()给生成器传值
result = gen.send("hello")
print(f"返回: {result}")
# 输出:
# 收到: hello
# 返回: hellohello
result = gen.send(42)
print(f"返回: {result}")
# 输出:
# 收到: 42
# 返回: 84
是不是很神奇?yield不仅能往外传值,还能往里收值!
我们来详细解释一下:
-
启动生成器:第一次调用生成器,必须用
next()或者gen.send(None),让生成器执行到第一个yield处暂停。这时候,yield还没有收到值,所以不能直接send非None的值。 -
send()传值:调用
gen.send(value)的时候,会把value传给生成器,作为yield表达式的返回值,赋值给左边的变量。然后生成器继续执行,直到遇到下一个yield,把后面的值返回。 -
双向通信:这样一来,调用者和生成器之间就可以双向通信了——调用者可以给生成器传数据,生成器处理完再返回结果。
我们再看一个更实用的例子:一个可以动态调整步长的计数器。
def counter():
"""计数器,可以通过send()调整步长"""
count = 0
step = 1
while True:
count += step
new_step = yield count # yield返回count,同时接收新的步长
if new_step is not None:
step = new_step
gen = counter()
print(next(gen)) # 1 (默认步长1)
print(next(gen)) # 2
print(next(gen)) # 3
print(gen.send(5)) # 8 (把步长改成5)
print(next(gen)) # 13
print(next(gen)) # 18
print(gen.send(10)) # 28 (把步长改成10)
print(next(gen)) # 38
输出:
1
2
3
8
13
18
28
38
这个生成器不仅能计数,还能在运行过程中动态调整步长,非常灵活。
4.2 throw():给生成器抛异常
除了传值,调用者还可以给生成器抛异常,用throw()方法。
def gen():
try:
yield 1
yield 2
yield 3
except ValueError as e:
print(f"生成器收到异常: {e}")
yield "error handled"
g = gen()
print(next(g)) # 1
# 给生成器抛一个ValueError
result = g.throw(ValueError, "出错了!")
print(result) # error handled
输出:
1
生成器收到异常: 出错了!
error handled
throw()的作用是:在生成器当前暂停的yield处,抛出一个指定的异常。生成器可以捕获这个异常,进行处理,然后继续yield下一个值。
如果生成器没有捕获这个异常,异常就会向上冒泡,抛给调用者。
def gen():
yield 1
yield 2
yield 3
g = gen()
next(g) # 1
try:
g.throw(ValueError, "出错了")
except ValueError as e:
print(f"调用者收到异常: {e}")
输出:
调用者收到异常: 出错了
throw()方法让调用者可以控制生成器的异常流程,在某些场景下很有用。
4.3 close():关闭生成器
如果我们不想继续用生成器了,可以用close()方法关闭它。
def gen():
try:
yield 1
yield 2
yield 3
finally:
print("生成器被关闭了,清理资源")
g = gen()
print(next(g)) # 1
g.close() # 关闭生成器
# 关闭之后再next会抛StopIteration
try:
next(g)
except StopIteration:
print("生成器已经关闭")
输出:
1
生成器被关闭了,清理资源
生成器已经关闭
close()的原理是:在生成器当前暂停的yield处,抛出一个GeneratorExit异常。生成器可以捕获这个异常,做一些清理工作(比如关闭文件、释放资源等)。
注意:如果生成器捕获了GeneratorExit异常,但是又yield了新的值,会抛出RuntimeError。因为关闭之后就不应该再产生值了。
def gen():
try:
yield 1
except GeneratorExit:
print("收到关闭信号")
yield 2 # 错误:关闭后还yield
g = gen()
next(g)
try:
g.close()
except RuntimeError as e:
print(f"错误: {e}")
输出:
收到关闭信号
错误: generator ignored GeneratorExit
所以,在GeneratorExit的except块里,不要yield新的值,只做清理工作就行。
4.4 生成器的双向通信能力
总结一下,生成器有四个核心方法:
| 方法 | 作用 |
|---|---|
next() |
让生成器继续执行,返回下一个yield的值 |
send(value) |
给生成器传一个值,让它继续执行,返回下一个yield的值 |
throw(type, value) |
给生成器抛一个异常,让它继续执行,返回下一个yield的值 |
close() |
关闭生成器 |
这四个方法,让生成器具备了双向通信的能力:
-
数据可以从生成器流向调用者(yield返回值)
-
数据也可以从调用者流向生成器(send传值)
-
异常也可以双向传递
这意味着什么?意味着生成器不只是一个"数据生成器",它还是一个可以接收输入、处理输入、产生输出的"计算单元"。
这其实就是**协程(Coroutine)**的雏形。
在Python 3.5之前,协程就是用生成器实现的。虽然后来有了专门的async/await语法,但底层原理是相通的。
我们后面会专门讲生成器和协程的关系。
第五章:生成器的经典应用场景
讲了这么多理论,我们来看一下生成器在实际工作中有哪些应用场景。
5.1 大文件读取
这是生成器最经典的应用场景之一。
如果你要处理一个很大的文件(比如几个G、几十个G的日志文件),用普通的方式一次性把文件读到内存里,肯定会内存溢出。
用生成器就可以一行一行地读,处理完一行再读下一行,内存占用始终很小。
def read_large_file(file_path):
"""逐行读取大文件"""
with open(file_path, "r", encoding="utf-8") as f:
for line in f:
yield line.strip()
# 使用
for line in read_large_file("huge_log_file.log"):
# 处理每一行
if "ERROR" in line:
process_error_line(line)
这样,不管文件多大,内存占用都只有一行的大小。
而且,你还可以把多个生成器串起来,形成"数据管道":
def read_lines(file_path):
with open(file_path) as f:
for line in f:
yield line.strip()
def filter_errors(lines):
for line in lines:
if "ERROR" in line:
yield line
def parse_lines(lines):
for line in lines:
parts = line.split("|")
yield {
"time": parts[0],
"level": parts[1],
"message": parts[2]
}
# 组合起来
lines = read_lines("app.log")
error_lines = filter_errors(lines)
parsed_errors = parse_lines(error_lines)
# 遍历处理
for error in parsed_errors:
save_to_database(error)
整个过程都是流式的,数据像流水一样经过各个处理环节,内存占用始终很小。

5.2 无限序列
生成器特别适合表示无限序列——因为它是惰性求值的,不需要一次性生成所有元素(也不可能,因为是无限的)。
比如斐波那契数列:
def fibonacci():
"""无限斐波那契数列"""
a, b = 0, 1
while True:
yield b
a, b = b, a + b
# 使用
fib = fibonacci()
for _ in range(10):
print(next(fib), end=" ")
输出:
1 1 2 3 5 8 13 21 34 55
再比如无限计数器:
def infinite_counter(start=0, step=1):
"""无限计数器"""
current = start
while True:
yield current
current += step
# 使用
counter = infinite_counter(100, 2)
for _ in range(5):
print(next(counter), end=" ")
输出:
100 102 104 106 108
还有素数生成器:
def primes():
"""无限素数生成器"""
yield 2
prime_list = [2]
num = 3
while True:
is_prime = True
for p in prime_list:
if p * p > num:
break
if num % p == 0:
is_prime = False
break
if is_prime:
prime_list.append(num)
yield num
num += 2
# 使用
prime_gen = primes()
for _ in range(10):
print(next(prime_gen), end=" ")
输出:
2 3 5 7 11 13 17 19 23 29
这些无限序列,如果用列表来表示,根本不可能——因为列表是有限的,而序列是无限的。但用生成器就可以完美表示。
5.3 数据管道/流水线
前面提到过,生成器可以串联起来,形成数据处理管道。
这种模式非常适合数据处理、ETL(抽取-转换-加载)等场景。
我们来看一个更完整的例子:处理电商订单数据。
import csv
def read_orders(file_path):
"""读取订单数据"""
with open(file_path, "r") as f:
reader = csv.DictReader(f)
for row in reader:
yield row
def filter_paid(orders):
"""过滤已支付的订单"""
for order in orders:
if order["status"] == "paid":
yield order
def add_total_amount(orders):
"""计算订单总金额(单价 × 数量)"""
for order in orders:
order["total"] = float(order["price"]) * int(order["quantity"])
yield order
def filter_high_value(orders, min_total=100):
"""过滤高价值订单"""
for order in orders:
if order["total"] >= min_total:
yield order
def group_by_user(orders):
"""按用户分组统计"""
user_stats = {}
for order in orders:
user_id = order["user_id"]
if user_id not in user_stats:
user_stats[user_id] = {
"order_count": 0,
"total_amount": 0
}
user_stats[user_id]["order_count"] += 1
user_stats[user_id]["total_amount"] += order["total"]
for user_id, stats in user_stats.items():
yield {
"user_id": user_id,
"order_count": stats["order_count"],
"total_amount": stats["total_amount"]
}
# 组合成管道
orders = read_orders("orders.csv")
paid_orders = filter_paid(orders)
orders_with_total = add_total_amount(paid_orders)
high_value_orders = filter_high_value(orders_with_total, 200)
user_stats = group_by_user(high_value_orders)
# 输出结果
for stat in user_stats:
print(f"用户{stat['user_id']}: {stat['order_count']}单,总金额{stat['total_amount']:.2f}元")
这种管道模式的好处是:
-
内存占用小:数据流式处理,不需要把所有数据都装内存里
-
解耦:每个环节都是独立的生成器,职责单一
-
可组合:可以灵活地组合、增删处理环节
-
易测试:每个环节都可以单独测试
5.4 解耦生产者和消费者
生成器还可以用来解耦生产者和消费者。
什么意思呢?就是生产数据的代码和消费数据的代码可以分开写,互不依赖。
比如,你写了一个函数来生成数据,另一个函数来处理数据。如果用列表的话,生产者必须把所有数据都生成好,放到列表里,消费者才能开始处理。
而用生成器的话,生产者生成一个,消费者就可以处理一个,不需要等全部生成完。而且两者的代码是独立的,修改其中一个不影响另一个。
def producer():
"""生产者:生成数据"""
for i in range(10):
print(f"生产: {i}")
yield i
def consumer(data):
"""消费者:处理数据"""
for item in data:
print(f"消费: {item}")
# 组合使用
data = producer()
consumer(data)
输出:
生产: 0
消费: 0
生产: 1
消费: 1
生产: 2
消费: 2
...
可以看到,生产一个,消费一个,交替进行。
这种模式在很多场景下都很有用,比如:
-
从数据库读数据,处理后写入文件
-
从API拉数据,处理后存入数据库
-
读取日志,实时分析告警
5.5 协程与并发编程
前面我们提到过,生成器是协程的雏形。
在Python 3.4之前,Python没有原生的协程支持,大家都是用生成器来实现协程的。比如著名的gevent库,底层就是用生成器实现的协程。
虽然后来Python 3.4引入了asyncio,3.5引入了async/await语法,有了原生协程,但生成器协程的原理还是值得了解的。
我们后面会专门用一章来讲生成器和协程的关系。
第六章:yield from——生成器的嵌套调用
当生成器变得复杂之后,我们可能需要在一个生成器里调用另一个生成器。这时候,yield from就派上用场了。
6.1 为什么需要yield from?
先来看一个场景:我们有两个生成器,想把它们串起来,变成一个生成器。
不用yield from的话,我们得这么写:
def gen1():
yield 1
yield 2
def gen2():
yield 3
yield 4
def combined():
"""把gen1和gen2组合起来"""
for item in gen1():
yield item
for item in gen2():
yield item
# 使用
for num in combined():
print(num, end=" ")
输出:
1 2 3 4
这样写虽然能工作,但有点啰嗦——for循环里yield,写起来麻烦。
用yield from就简洁多了:
def combined():
yield from gen1()
yield from gen2()
没错,就这么简单。yield from后面跟一个可迭代对象(生成器、列表、元组等都可以),它会把这个可迭代对象的所有元素都yield出来。
def gen():
yield from [1, 2, 3] # 列表也可以
yield from (x * x for x in range(4, 7)) # 生成器表达式也可以
yield from "abc" # 字符串也可以(字符串是可迭代的)
for item in gen():
print(item, end=" ")
输出:
1 2 3 16 25 36 a b c
可以看到,yield from后面可以跟任何可迭代对象,它会自动迭代并yield每一个元素。
6.2 yield from不只是语法糖
你可能会说,这不就是个语法糖吗?把for循环简写了一下。
其实不然。yield from的功能远不止于此。它还有一个重要作用:在调用者和子生成器之间建立双向通道。
什么意思呢?就是说,调用者的send()、throw()、close(),可以通过yield from直接传递给子生成器,不需要我们手动处理。
我们来看一个例子:
def sub_gen():
"""子生成器"""
print("子生成器启动")
while True:
value = yield
print(f"子生成器收到: {value}")
if value is None:
break
print("子生成器结束")
return "done"
def delegating_gen():
"""委托生成器(中间层)"""
print("委托生成器启动")
result = yield from sub_gen() # 委托给子生成器
print(f"委托生成器收到子生成器的返回值: {result}")
return result
# 使用
gen = delegating_gen()
next(gen) # 启动
gen.send("hello") # 直接传给子生成器
gen.send("world") # 直接传给子生成器
gen.send(None) # 让子生成器结束
输出:
委托生成器启动
子生成器启动
子生成器收到: hello
子生成器收到: world
子生成器结束
委托生成器收到子生成器的返回值: done
Traceback (most recent call last):
...
StopIteration: done
看到了吗?我们调用gen.send("hello"),这个值直接就传到子生成器里了,中间的委托生成器根本不用管。
同样,throw()和close()也会自动传递给子生成器。
这就是yield from的强大之处:它不只是简化代码,还自动处理了双向通信、异常传递、返回值等复杂问题。
如果没有yield from,你要自己实现这些功能,会非常麻烦。
6.3 子生成器的返回值
注意到了吗?在上面的例子中,子生成器用return返回了一个值"done",然后委托生成器通过result = yield from sub_gen()拿到了这个返回值。
这也是yield from的一个重要特性:可以获取子生成器的返回值。
普通的生成器,你用next()或者for循环遍历,是拿不到return的值的——因为生成器结束的时候,返回值是放在StopIteration异常里的。
def gen():
yield 1
yield 2
return "finished"
g = gen()
print(next(g)) # 1
print(next(g)) # 2
try:
next(g)
except StopIteration as e:
print(f"返回值: {e.value}") # 返回值: finished
你得捕获StopIteration异常,然后从e.value里拿返回值,很麻烦。
而用yield from的话,它会自动捕获StopIteration异常,把返回值作为yield from表达式的结果返回,非常方便。
6.4 用yield from重构生成器管道
有了yield from,我们可以更优雅地组织生成器代码。
比如前面的数据管道例子,我们可以用yield from来重构:
def process_pipeline(file_path):
"""完整的数据处理管道"""
# 读取
orders = read_orders(file_path)
# 过滤已支付
paid_orders = filter_paid(orders)
# 计算金额
orders_with_total = add_total_amount(paid_orders)
# 过滤高价值
high_value = filter_high_value(orders_with_total, 200)
# 按用户分组
yield from group_by_user(high_value)
# 使用
for stat in process_pipeline("orders.csv"):
print(stat)
整个管道封装成一个生成器函数,对外只暴露一个入口,内部的处理环节对调用者透明。
6.5 yield from和asyncio的关系
最后提一下,yield from和asyncio有很深的渊源。
在Python 3.4刚推出asyncio的时候,还没有async/await语法,那时候的协程就是用生成器+yield from实现的。
比如,那时候的异步代码是这样写的:
@asyncio.coroutine
def fetch_url(url):
response = yield from aiohttp.get(url)
body = yield from response.text()
return body
用@asyncio.coroutine装饰器标记协程函数,用yield from等待异步操作。
后来Python 3.5推出了async/await语法,变成了现在的样子:
async def fetch_url(url):
response = await aiohttp.get(url)
body = await response.text()
return body
可以看到,await其实就是yield from的"专用版"——专门用于异步编程的yield from。
所以说,**await的本质就是yield from,async def**的本质就是生成器函数。只是它们被专门用于异步编程,语义更清晰,也有专门的语法支持。
这也是为什么我们说生成器是Python异步编程的基础。
第七章:生成器的坑与避坑指南
生成器虽然强大,但也有不少坑。新手很容易踩。
这一章,我来总结一下常见的坑和避坑方法。
7.1 坑一:生成器只能遍历一次
这是新手最容易踩的坑,没有之一。
很多人写完生成器,以为它和列表一样,可以多次遍历。结果第二次遍历的时候,发现什么都没有,还以为出bug了。
def gen():
yield 1
yield 2
yield 3
g = gen()
print("第一次遍历:")
for x in g:
print(x, end=" ")
print()
print("第二次遍历:")
for x in g:
print(x, end=" ")
print()
输出:
第一次遍历:
1 2 3
第二次遍历:
为什么?因为生成器是"一次性"的。遍历完之后,它就耗尽了,里面没有元素了。
如果你需要多次遍历,怎么办?
方法一:每次都重新创建生成器
def get_data():
return (x for x in range(3))
# 每次都重新创建
for x in get_data():
print(x)
for x in get_data():
print(x)
方法二:转换成列表
如果数据量不大,可以转成列表,然后多次遍历。
g = gen()
data = list(g) # 转成列表
for x in data:
print(x)
for x in data:
print(x)
当然,转成列表就失去了生成器省内存的优势,所以只适合小数据量。
7.2 坑二:生成器没有长度,不能用len()
列表可以用len()获取长度,生成器不行。
my_list = [1, 2, 3]
print(len(my_list)) # 3
my_gen = (x for x in range(3))
print(len(my_gen)) # TypeError: object of type 'generator' has no len()
为什么?因为生成器是惰性求值的,它自己都不知道总共有多少个元素——只有遍历完了才知道。
如果你需要知道元素个数,怎么办?
方法一:遍历计数
count = 0
for _ in my_gen:
count += 1
print(count)
但这样会把生成器耗尽,如果你还要用里面的元素,就不行了。
方法二:转成列表再取长度
data = list(my_gen)
print(len(data))
同样,这会失去省内存的优势。
方法三:如果是你自己写的生成器,可以加一个方法返回总数
比如,如果你知道总共有多少条数据,可以在生成器里加个属性或者方法来返回。
不过大多数时候,生成器就是用来处理未知数量的数据的,不需要知道总数。
7.3 坑三:不能用下标访问
列表可以用下标访问(比如data[0]、data[10]),生成器不行。
my_list = [1, 2, 3]
print(my_list[0]) # 1
my_gen = (x for x in range(3))
print(my_gen[0]) # TypeError: 'generator' object is not subscriptable
为什么?因为生成器是顺序访问的,只能一个一个往后拿,不能跳着访问。
如果你需要随机访问,那就用列表吧。
当然,如果你只是想拿第N个元素,也可以用itertools.islice:
import itertools
my_gen = (x for x in range(100))
# 取第10个元素(下标从0开始)
tenth = next(itertools.islice(my_gen, 9, 10))
print(tenth) # 9
但这其实还是顺序遍历到第10个,不是真正的随机访问。而且取完之后,生成器就前进到第10个位置了,不能再回去。
7.4 坑四:遍历到一半重新开始会从头来吗?
不会。生成器会记住当前的位置,你停在哪里,下次就从哪里继续。
def gen():
yield 1
yield 2
yield 3
yield 4
yield 5
g = gen()
# 遍历前3个
for i, x in enumerate(g):
print(x, end=" ")
if i == 2:
break
print() # 1 2 3
# 继续遍历,从第4个开始
for x in g:
print(x, end=" ")
print() # 4 5
第一次遍历到第3个就break了,第二次遍历从第4个继续,不会从头开始。
这既是优点也是缺点——优点是可以随时暂停、随时继续;缺点是如果你想重新开始,就得重新创建生成器。
7.5 坑五:在生成器里用return有特殊含义
普通函数里的return是返回值,生成器函数里的return也是返回值,但用法不一样。
在生成器里,return表示生成器结束了,返回值会放在StopIteration异常里。
def gen():
yield 1
return "finished"
yield 2 # 这行永远不会执行
g = gen()
print(next(g)) # 1
try:
next(g)
except StopIteration as e:
print(f"返回值: {e.value}") # 返回值: finished
遇到return之后,生成器就结束了,后面的代码不会执行,就像普通函数一样。
注意:在Python 2里,生成器里不能有return(只能有不带值的return)。Python 3才支持带值的return。
7.6 坑六:多线程下使用生成器要小心
生成器不是线程安全的。
如果你在多个线程里同时调用同一个生成器的next(),会出问题。
import threading
import time
def gen():
for i in range(10):
yield i
time.sleep(0.1)
g = gen()
def worker():
try:
while True:
print(threading.current_thread().name, next(g))
except StopIteration:
pass
# 两个线程同时用同一个生成器
t1 = threading.Thread(target=worker, name="线程1")
t2 = threading.Thread(target=worker, name="线程2")
t1.start()
t2.start()
t1.join()
t2.join()
这段代码可能会出各种奇怪的问题,甚至崩溃。因为生成器的内部状态不是线程安全的,多线程同时访问会导致状态混乱。
所以,不要在多个线程之间共享同一个生成器。每个线程用自己的生成器,或者加锁保护。
7.7 坑七:调试困难
生成器的代码调试起来比普通函数麻烦,因为它的执行是断断续续的。
比如,你在生成器里打了个断点,它会在每次yield的时候停下来,你得一步步跟着走,很容易晕。
调试建议:
-
先用小数据量测试,确认逻辑正确
-
多加日志,打印关键变量和执行位置
-
用
pdb的话,可以用next命令一步步跟 -
复杂的生成器逻辑,可以先写成普通函数,确认没问题了再改成生成器
7.8 坑八:过度使用生成器
最后一个坑,也是很多"技术爱好者"容易犯的:为了用生成器而用生成器。
有些人学了生成器之后,觉得这是高级特性,很酷,于是不管什么场景都用生成器。
但其实,很多场景下,列表更合适。比如:
-
数据量很小,用列表完全没问题
-
需要多次遍历
-
需要随机访问
-
需要知道长度
这时候硬要用生成器,反而会让代码更复杂,还没什么收益。
记住:技术是为业务服务的,不是为了炫技。 该用什么就用什么,不要为了用高级特性而用。
第八章:从生成器到协程——生成器是异步编程的基石
前面我们多次提到,生成器是Python异步编程的基础。这一章我们就来详细聊聊这个话题。
8.1 生成器为什么能用来实现协程?
我们先回顾一下生成器的特性:
-
可以暂停执行(遇到yield)
-
可以恢复执行(调用next())
-
可以双向通信(send()传值,yield返回值)
-
可以传递异常(throw())
-
可以关闭(close())
发现了吗?这些特性,不就是协程需要的吗?
协程是什么?协程就是可以暂停、可以恢复、可以互相通信的"子程序"。
而生成器,刚好具备所有这些特性。所以,用生成器来实现协程,是非常自然的事情。
8.2 用生成器实现一个简单的协程
我们来用生成器实现一个最简单的协程例子:两个任务交替执行。
def task1():
while True:
print("任务1:正在做A工作")
yield # 暂停,让出控制权
print("任务1:继续做B工作")
yield
def task2():
while True:
print("任务2:正在做X工作")
yield
print("任务2:继续做Y工作")
yield
# 简单的调度器
def scheduler(tasks):
while True:
for task in tasks:
try:
next(task)
except StopIteration:
tasks.remove(task)
if not tasks:
break
# 运行
t1 = task1()
t2 = task2()
scheduler([t1, t2])
输出:
任务1:正在做A工作
任务2:正在做X工作
任务1:继续做B工作
任务2:继续做Y工作
任务1:正在做A工作
任务2:正在做X工作
...
看,两个任务交替执行,就像多线程一样,但其实是在单线程里运行的。
这就是协程的基本思想:在单线程里,通过主动让出控制权(yield),来实现多个任务的并发执行。
当然,这只是一个最简单的例子。真正的协程调度器要复杂得多,需要处理I/O事件、定时器、异常等等。
但核心原理是一样的:用yield暂停,用next/send恢复。
8.3 从yield到yield from再到async/await的演进
Python的异步编程,经历了几个阶段:
阶段一:生成器协程(Python 2.x - 3.3)
最早,大家用普通的生成器来实现协程。就是我们上面写的那种,用yield暂停,用next恢复。
但这样写有很多问题:
-
协程的调用很麻烦
-
异常处理很麻烦
-
没有标准的调度器
阶段二:yield from + asyncio(Python 3.4)
Python 3.3引入了yield from,让协程的嵌套调用变得方便。
Python 3.4引入了asyncio标准库,提供了标准的事件循环和调度器。
这时候的异步代码是这样的:
import asyncio
@asyncio.coroutine
def hello():
print("Hello")
yield from asyncio.sleep(1)
print("World")
loop = asyncio.get_event_loop()
loop.run_until_complete(hello())
loop.close()
用@asyncio.coroutine装饰器标记协程函数,用yield from等待异步操作。
这比之前的手写生成器协程已经好用很多了,但还是不够直观。
阶段三:async/await(Python 3.5+)
Python 3.5引入了专门的async和await语法,让异步代码的写法更清晰、更直观。
import asyncio
async def hello():
print("Hello")
await asyncio.sleep(1)
print("World")
asyncio.run(hello())
这就是我们现在熟悉的写法。
虽然语法变了,但底层原理还是一样的:
-
async def定义的函数,本质上还是一个生成器函数 -
await本质上就是yield from的专用版本 -
事件循环还是那个事件循环
只是语法更专门化了,语义更清晰,也更容易理解。
8.4 生成器协程 vs 原生协程
那现在有了原生协程(async/await),生成器协程还有用吗?
答案是:在异步编程领域,基本不用了。原生协程更清晰、更专业。
但生成器作为数据处理工具,还是非常有用的。这两者的应用场景不一样:
| 类型 | 用途 | 语法 |
|---|---|---|
| 生成器 | 数据处理、惰性求值、流式计算 | def + yield |
| 原生协程 | 异步编程、并发I/O | async def + await |
虽然底层原理相通,但它们是为不同的场景设计的,不要混淆。
8.5 为什么要了解生成器和协程的关系?
可能有人会说:我直接学async/await不就行了,为什么还要了解生成器?
因为:
-
理解原理有助于更好地使用:知道async/await的底层是生成器,你就能更好地理解异步编程的工作原理,遇到问题也更容易排查。
-
很多老代码还是生成器协程:一些老项目、老库,可能还在用生成器协程,你得能看懂。
-
生成器本身也很有用:即使不写异步代码,生成器在数据处理中也非常常用。
-
面试常考:很多面试会问生成器和协程的关系,考察你对Python底层的理解。
总之,了解生成器和协程的关系,能让你对Python的理解更深入一层。
第九章:面试中关于生成器的常见问题
回到我们开头的故事。其实,生成器不仅在实际工作中很有用,也是Python面试中的高频考点。
这一章,我整理了一些面试中常见的问题,以及回答思路。
9.1 什么是生成器?和列表有什么区别?
回答思路:
生成器是Python中的一种特殊迭代器,用yield关键字或者生成器表达式创建。它的特点是惰性求值——不会一次性生成所有元素,而是每次需要的时候才计算下一个。
和列表的区别:
-
内存占用:列表把所有元素都存在内存里,占用大;生成器只保存状态和生成规则,占用极小(几乎不随元素数量增长)。
-
求值方式:列表是立即求值,一次性生成所有元素;生成器是惰性求值,需要的时候才计算。
-
访问方式:列表可以随机访问(通过下标);生成器只能顺序访问,不能回头。
-
遍历次数:列表可以多次遍历;生成器只能遍历一次,遍历完就空了。
-
长度:列表可以用
len()获取长度;生成器不能,因为不知道总共有多少元素。
简单说:列表是"全部拿在手里",生成器是"用一个拿一个"。
9.2 yield和return有什么区别?
回答思路:
-
return:普通函数中的return,执行到return时,函数返回值并结束,后面的代码不会执行。下次调用函数会从头开始。
-
yield:生成器函数中的yield,执行到yield时,函数会暂停,把yield后面的值返回给调用者,同时保存函数的所有状态(局部变量、执行位置等)。下次调用next()或者send()时,从暂停的地方继续执行。
-
返回值:return的返回值直接返回给调用者;yield的返回值通过next()返回,而生成器函数最终的return值会放在StopIteration异常里。
-
数量:一个函数可以有多个yield,但一般只有一个return(当然也可以有多个return,但每次执行只会走一个)。
9.3 生成器的执行流程是怎样的?
回答思路:
-
创建阶段:调用生成器函数,不会执行函数体,只是创建一个生成器对象。
-
启动阶段:第一次调用next()(或者send(None)),函数开始执行,从第一行开始,直到遇到第一个yield,暂停,返回yield后面的值。
-
恢复阶段:后续每次调用next(),函数从上次暂停的地方继续执行,直到遇到下一个yield,再暂停,返回值。
-
结束阶段:如果函数执行完了(遇到return或者走到函数末尾),抛出StopIteration异常。
可以用"拉拉链"来比喻:拉一下(next),走一点(yield一个值),再拉一下,再走一点,直到拉到底。
9.4 send()方法是做什么的?
回答思路:
send()方法用来给生成器传值。
我们知道yield可以把值从生成器返回给调用者,而send()可以把值从调用者传进生成器——作为yield表达式的返回值。
比如:
def gen():
x = yield 1
yield x * 2
g = gen()
print(next(g)) # 1
print(g.send(5)) # 10
注意:第一次调用生成器的时候,必须用next()或者send(None),不能直接send非None的值。因为这时候生成器还没启动,没有地方接收值。
send()方法让生成器具备了双向通信的能力,这也是生成器可以用来实现协程的基础。
9.5 生成器有什么优缺点?
回答思路:
优点:
-
节省内存:惰性求值,不需要一次性把所有数据都放内存里,适合处理大数据。
-
提高效率:边生成边处理,省去了一次性生成的时间。
-
代码简洁:用yield写生成器,比自己实现迭代器简洁很多。
-
表示无限序列:可以表示无限的数据流,这是列表做不到的。
-
解耦:可以把生产和消费解耦,形成数据管道。
缺点:
-
只能遍历一次:遍历完就空了,不能重复使用。
-
不能随机访问:只能顺序访问,不能通过下标访问。
-
没有长度:不能用len()获取元素个数。
-
调试困难:执行流程断断续续,调试比普通函数麻烦。
-
有学习成本:yield的概念对新手来说不太好理解。
9.6 什么是迭代器?什么是可迭代对象?
回答思路:
可迭代对象(Iterable):
-
凡是可以用for循环遍历的对象,都是可迭代对象
-
实现了
__iter__()方法,返回一个迭代器 -
比如:列表、元组、字符串、字典、集合、生成器等
迭代器(Iterator):
-
凡是可以用next()调用的对象,都是迭代器
-
实现了
__iter__()方法(返回自身)和__next__()方法(返回下一个元素,没有就抛StopIteration) -
比如:生成器、文件对象、iter(列表)等
它们的关系:
-
迭代器一定是可迭代对象(因为有__iter__方法)
-
可迭代对象不一定是迭代器(比如列表是可迭代对象,但不是迭代器,需要用iter()转换)
可以用iter()函数把可迭代对象转换成迭代器。
9.7 生成器和迭代器是什么关系?
回答思路:
生成器是迭代器的一种。
迭代器是一个更宽泛的概念——只要实现了__iter__和__next__方法的都是迭代器。迭代器可以有很多种实现方式,比如用类实现。
而生成器是用yield或者生成器表达式创建的特殊迭代器,它自动实现了迭代器协议,是Python提供的一种方便的创建迭代器的方式。
简单说:生成器一定是迭代器,但迭代器不一定是生成器。
9.8 生成器的应用场景有哪些?
回答思路:
-
大文件/大数据处理:逐行读取大文件,流式处理数据,避免内存溢出。
-
无限序列:表示无限的数据流,比如斐波那契数列、无限计数器等。
-
数据管道:把多个处理环节串联起来,形成流式数据处理管道。
-
解耦生产和消费:生产者和消费者独立编写,通过生成器连接。
-
协程实现:生成器是Python协程的基础,async/await底层就是生成器。
-
性能优化:在只需要遍历一次的场景下,用生成器代替列表可以节省内存。
回答的时候最好能结合自己的项目经历,举一两个实际的例子,这样更有说服力。
9.9 什么是yield from?
回答思路:
yield from是Python 3.3引入的语法,用来在生成器中调用另一个生成器(或者任何可迭代对象)。
它的作用主要有两个:
-
简化代码:可以把嵌套的生成器调用写得更简洁。比如
yield from sub_gen()代替for item in sub_gen(): yield item。 -
建立双向通道:自动在调用者和子生成器之间建立双向通信——send()、throw()、close()都会自动传递给子生成器,子生成器的返回值也能通过yield from获取。
yield from是实现生成器协程的关键,也是async/await的前身——await本质上就是专门用于异步的yield from。
9.10 生成器实现协程的原理是什么?
回答思路:
生成器之所以能实现协程,是因为它具备协程需要的所有特性:
-
可暂停可恢复:遇到yield暂停,调用next()恢复,这是协程的基本能力。
-
状态保存:暂停时保存所有局部变量和执行位置,恢复时继续用。
-
双向通信:通过yield返回值,通过send()接收值,协程之间可以传递数据。
-
异常传递:通过throw()可以向生成器抛异常,实现异常的双向传递。
早期的Python协程就是用生成器实现的。虽然现在有了专门的async/await语法,但底层原理还是一样的——都是基于生成器的暂停/恢复机制。
总结:生成器的学习路径与最佳实践
好了,讲到这里,这篇文章也差不多了。
不知道你看完之后,对生成器有没有一个更全面的认识?
说实话,生成器是Python中一个非常有特色的特性。它看起来简单,就一个yield关键字,但深入进去,你会发现它涉及迭代器、惰性求值、协程、异步编程等很多概念。
最后,我给大家总结一下生成器的学习路径和一些最佳实践。
第一步:掌握基础用法
首先,把生成器的基础用法搞明白:
-
生成器表达式:把列表推导式的[]改成()
-
生成器函数:def + yield
-
用for循环或者next()遍历生成器
-
理解惰性求值的概念
这是最基础的,也是日常工作中最常用的。
第二步:理解工作原理
然后,深入理解生成器的工作原理:
-
yield的执行流程:暂停、恢复、状态保存
-
迭代器协议:iter__和__next
-
可迭代对象、迭代器、生成器三者的关系
-
StopIteration异常
理解了原理,你就知道生成器为什么能省内存,为什么只能遍历一次,为什么不能随机访问。
第三步:学习高级用法
接着,学习生成器的高级用法:
-
send():给生成器传值
-
throw():给生成器抛异常
-
close():关闭生成器
-
yield from:生成器的嵌套调用
这些高级用法,在一些复杂场景下会用到,也是面试常考的。
第四步:了解生成器与协程的关系
然后,了解生成器和协程、异步编程的关系:
-
生成器为什么能实现协程
-
从yield到yield from再到async/await的演进
-
生成器协程和原生协程的区别
这部分属于进阶内容,了解了能让你对Python的理解更深入。
第五步:在实际项目中运用
最后,也是最重要的,在实际项目中多运用:
-
处理大文件的时候,想想能不能用生成器
-
处理大数据的时候,想想能不能用生成器管道
-
需要无限序列的时候,用生成器
-
不要为了用而用,小数据量、需要多次访问的场景,老老实实⽤列表
实践出真知,用得多了,自然就熟练了。
一些最佳实践
最后,分享一些生成器的最佳实践:
-
优先用生成器处理大数据:凡是数据量大、内存可能装不下的场景,优先考虑生成器。
-
小数据量用列表就好:不要为了炫技而用生成器,列表更简单直观。
-
生成器函数要写文档字符串:说明生成器会产生什么数据,参数是什么,方便别人使用。
-
合理命名:生成器函数的名字最好能体现"生成"的含义,比如
read_lines、generate_data等。 -
注意异常处理:生成器内部的异常要处理好,不要让异常随便抛给调用者。
-
资源要清理:如果生成器打开了文件、连接了数据库等资源,要确保在生成器关闭时正确释放(可以用finally块)。
-
不要在多线程间共享生成器:生成器不是线程安全的,每个线程用自己的。
写在最后
回到开头的故事。那次事件之后,我对生成器有了全新的认识。
以前我总觉得,生成器是个"高级玩意儿",平时工作中用不上。但那次之后我才发现,生成器其实是一个非常实用的工具,特别是在数据处理领域。
很多时候,我们写代码,只想着"怎么实现功能",却忽略了"怎么高效地实现功能"。结果就是,功能是实现了,但性能一塌糊涂,内存占用高得吓人。
生成器就是这样一个能帮你写出高效代码的工具。它不需要你学什么复杂的算法,也不需要你改架构,只需要把列表换成生成器,把append改成yield,就能节省几十上百倍的内存。
希望这篇文章能让你对生成器有一个新的认识。也希望你能在实际工作中,多想想能不能用生成器优化你的代码。
毕竟,写出既正确又高效的代码,才是我们程序员的追求嘛。
如果你觉得这篇文章对你有帮助,欢迎分享给更多的人。也欢迎你在评论区留言,说说你用生成器做过哪些有意思的事情,或者踩过哪些坑。
最后,祝大家都能写出优雅、高效的Python代码!
更多推荐





所有评论(0)