Image

前言:那场让我记忆犹新的线上事故

还记得两年前,我刚入职一家电商公司,负责数据处理相关的工作。

有一天,运营给了我一个需求:把过去三年的订单数据全部导出来,做一个用户购买行为分析。数据量不大,也就三千多万条记录,几百个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)——需要的时候才计算,不需要就不计算。

Image

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

首先,我们来对比一下yieldreturn的区别。

普通函数(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

发现了吗?生成器函数的执行是"断断续续"的:

  1. 调用生成器函数(gen_func()),不会执行函数体,只是创建一个生成器对象

  2. 第一次调用next(gen),函数开始执行,遇到第一个yield暂停,把yield后面的值返回

  3. 第二次调用next(gen),从上次暂停的地方继续执行,遇到第二个yield,再暂停,返回值

  4. 以此类推,直到函数执行完,抛出StopIteration异常

这就是生成器最核心的特性:可以暂停,也可以恢复。每次暂停的时候,函数的所有状态(局部变量、执行位置等)都会被保存下来,恢复的时候接着用。

Image

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中,迭代器协议包含两个方法:

  1. __iter__():返回迭代器自身

  2. __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 生成器的状态

生成器在生命周期中,有四种状态:

  1. GEN_CREATED:创建完成,等待开始执行

  2. GEN_RUNNING:正在执行(解释器在运行它)

  3. GEN_SUSPENDED:暂停在某个yield处

  4. 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

从结果可以看出:

  1. 内存方面:列表的内存占用和元素数量成正比,元素越多,占用越大;而生成器不管多少元素,都是112字节。差距是几万倍甚至几十万倍。

  2. 生成时间方面:列表需要一次性生成所有元素,所以时间和数量成正比;生成器几乎是瞬间创建完成,因为它什么都没干,只是记住了规则。

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 什么时候用生成器?什么时候用列表?

总结一下:

适合用生成器的场景:

  1. 数据量很大,内存装不下

  2. 只需要遍历一次

  3. 不需要随机访问

  4. 数据是流式的,不知道总共有多少

  5. 需要"流水线"式的处理

适合用列表的场景:

  1. 数据量不大,内存装得下

  2. 需要多次遍历

  3. 需要随机访问(通过下标)

  4. 需要修改、添加、删除元素

  5. 需要知道长度(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不仅能往外传值,还能往里收值!

我们来详细解释一下:

  1. 启动生成器:第一次调用生成器,必须用next()或者gen.send(None),让生成器执行到第一个yield处暂停。这时候,yield还没有收到值,所以不能直接send非None的值。

  2. send()传值:调用gen.send(value)的时候,会把value传给生成器,作为yield表达式的返回值,赋值给左边的变量。然后生成器继续执行,直到遇到下一个yield,把后面的值返回。

  3. 双向通信:这样一来,调用者和生成器之间就可以双向通信了——调用者可以给生成器传数据,生成器处理完再返回结果。

我们再看一个更实用的例子:一个可以动态调整步长的计数器。

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)

整个过程都是流式的,数据像流水一样经过各个处理环节,内存占用始终很小。

Image

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}元")

这种管道模式的好处是:

  1. 内存占用小:数据流式处理,不需要把所有数据都装内存里

  2. 解耦:每个环节都是独立的生成器,职责单一

  3. 可组合:可以灵活地组合、增删处理环节

  4. 易测试:每个环节都可以单独测试

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 fromasyncio有很深的渊源。

在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 fromasync 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的时候停下来,你得一步步跟着走,很容易晕。

调试建议:

  1. 先用小数据量测试,确认逻辑正确

  2. 多加日志,打印关键变量和执行位置

  3. pdb的话,可以用next命令一步步跟

  4. 复杂的生成器逻辑,可以先写成普通函数,确认没问题了再改成生成器

7.8 坑八:过度使用生成器

最后一个坑,也是很多"技术爱好者"容易犯的:为了用生成器而用生成器。

有些人学了生成器之后,觉得这是高级特性,很酷,于是不管什么场景都用生成器。

但其实,很多场景下,列表更合适。比如:

  • 数据量很小,用列表完全没问题

  • 需要多次遍历

  • 需要随机访问

  • 需要知道长度

这时候硬要用生成器,反而会让代码更复杂,还没什么收益。

记住:技术是为业务服务的,不是为了炫技。 该用什么就用什么,不要为了用高级特性而用。


第八章:从生成器到协程——生成器是异步编程的基石

前面我们多次提到,生成器是Python异步编程的基础。这一章我们就来详细聊聊这个话题。

8.1 生成器为什么能用来实现协程?

我们先回顾一下生成器的特性:

  1. 可以暂停执行(遇到yield)

  2. 可以恢复执行(调用next())

  3. 可以双向通信(send()传值,yield返回值)

  4. 可以传递异常(throw())

  5. 可以关闭(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引入了专门的asyncawait语法,让异步代码的写法更清晰、更直观。

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不就行了,为什么还要了解生成器?

因为:

  1. 理解原理有助于更好地使用:知道async/await的底层是生成器,你就能更好地理解异步编程的工作原理,遇到问题也更容易排查。

  2. 很多老代码还是生成器协程:一些老项目、老库,可能还在用生成器协程,你得能看懂。

  3. 生成器本身也很有用:即使不写异步代码,生成器在数据处理中也非常常用。

  4. 面试常考:很多面试会问生成器和协程的关系,考察你对Python底层的理解。

总之,了解生成器和协程的关系,能让你对Python的理解更深入一层。


第九章:面试中关于生成器的常见问题

回到我们开头的故事。其实,生成器不仅在实际工作中很有用,也是Python面试中的高频考点。

这一章,我整理了一些面试中常见的问题,以及回答思路。

9.1 什么是生成器?和列表有什么区别?

回答思路:

生成器是Python中的一种特殊迭代器,用yield关键字或者生成器表达式创建。它的特点是惰性求值——不会一次性生成所有元素,而是每次需要的时候才计算下一个。

和列表的区别:

  1. 内存占用:列表把所有元素都存在内存里,占用大;生成器只保存状态和生成规则,占用极小(几乎不随元素数量增长)。

  2. 求值方式:列表是立即求值,一次性生成所有元素;生成器是惰性求值,需要的时候才计算。

  3. 访问方式:列表可以随机访问(通过下标);生成器只能顺序访问,不能回头。

  4. 遍历次数:列表可以多次遍历;生成器只能遍历一次,遍历完就空了。

  5. 长度:列表可以用len()获取长度;生成器不能,因为不知道总共有多少元素。

简单说:列表是"全部拿在手里",生成器是"用一个拿一个"。

9.2 yield和return有什么区别?

回答思路:

  1. return:普通函数中的return,执行到return时,函数返回值并结束,后面的代码不会执行。下次调用函数会从头开始。

  2. yield:生成器函数中的yield,执行到yield时,函数会暂停,把yield后面的值返回给调用者,同时保存函数的所有状态(局部变量、执行位置等)。下次调用next()或者send()时,从暂停的地方继续执行。

  3. 返回值:return的返回值直接返回给调用者;yield的返回值通过next()返回,而生成器函数最终的return值会放在StopIteration异常里。

  4. 数量:一个函数可以有多个yield,但一般只有一个return(当然也可以有多个return,但每次执行只会走一个)。

9.3 生成器的执行流程是怎样的?

回答思路:

  1. 创建阶段:调用生成器函数,不会执行函数体,只是创建一个生成器对象。

  2. 启动阶段:第一次调用next()(或者send(None)),函数开始执行,从第一行开始,直到遇到第一个yield,暂停,返回yield后面的值。

  3. 恢复阶段:后续每次调用next(),函数从上次暂停的地方继续执行,直到遇到下一个yield,再暂停,返回值。

  4. 结束阶段:如果函数执行完了(遇到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 生成器有什么优缺点?

回答思路:

优点:

  1. 节省内存:惰性求值,不需要一次性把所有数据都放内存里,适合处理大数据。

  2. 提高效率:边生成边处理,省去了一次性生成的时间。

  3. 代码简洁:用yield写生成器,比自己实现迭代器简洁很多。

  4. 表示无限序列:可以表示无限的数据流,这是列表做不到的。

  5. 解耦:可以把生产和消费解耦,形成数据管道。

缺点:

  1. 只能遍历一次:遍历完就空了,不能重复使用。

  2. 不能随机访问:只能顺序访问,不能通过下标访问。

  3. 没有长度:不能用len()获取元素个数。

  4. 调试困难:执行流程断断续续,调试比普通函数麻烦。

  5. 有学习成本:yield的概念对新手来说不太好理解。

9.6 什么是迭代器?什么是可迭代对象?

回答思路:

可迭代对象(Iterable)

  • 凡是可以用for循环遍历的对象,都是可迭代对象

  • 实现了__iter__()方法,返回一个迭代器

  • 比如:列表、元组、字符串、字典、集合、生成器等

迭代器(Iterator)

  • 凡是可以用next()调用的对象,都是迭代器

  • 实现了__iter__()方法(返回自身)和__next__()方法(返回下一个元素,没有就抛StopIteration)

  • 比如:生成器、文件对象、iter(列表)等

它们的关系:

  • 迭代器一定是可迭代对象(因为有__iter__方法)

  • 可迭代对象不一定是迭代器(比如列表是可迭代对象,但不是迭代器,需要用iter()转换)

可以用iter()函数把可迭代对象转换成迭代器。

9.7 生成器和迭代器是什么关系?

回答思路:

生成器是迭代器的一种。

迭代器是一个更宽泛的概念——只要实现了__iter__和__next__方法的都是迭代器。迭代器可以有很多种实现方式,比如用类实现。

而生成器是用yield或者生成器表达式创建的特殊迭代器,它自动实现了迭代器协议,是Python提供的一种方便的创建迭代器的方式。

简单说:生成器一定是迭代器,但迭代器不一定是生成器。

9.8 生成器的应用场景有哪些?

回答思路:

  1. 大文件/大数据处理:逐行读取大文件,流式处理数据,避免内存溢出。

  2. 无限序列:表示无限的数据流,比如斐波那契数列、无限计数器等。

  3. 数据管道:把多个处理环节串联起来,形成流式数据处理管道。

  4. 解耦生产和消费:生产者和消费者独立编写,通过生成器连接。

  5. 协程实现:生成器是Python协程的基础,async/await底层就是生成器。

  6. 性能优化:在只需要遍历一次的场景下,用生成器代替列表可以节省内存。

回答的时候最好能结合自己的项目经历,举一两个实际的例子,这样更有说服力。

9.9 什么是yield from?

回答思路:

yield from是Python 3.3引入的语法,用来在生成器中调用另一个生成器(或者任何可迭代对象)。

它的作用主要有两个:

  1. 简化代码:可以把嵌套的生成器调用写得更简洁。比如yield from sub_gen()代替for item in sub_gen(): yield item

  2. 建立双向通道:自动在调用者和子生成器之间建立双向通信——send()、throw()、close()都会自动传递给子生成器,子生成器的返回值也能通过yield from获取。

yield from是实现生成器协程的关键,也是async/await的前身——await本质上就是专门用于异步的yield from。

9.10 生成器实现协程的原理是什么?

回答思路:

生成器之所以能实现协程,是因为它具备协程需要的所有特性:

  1. 可暂停可恢复:遇到yield暂停,调用next()恢复,这是协程的基本能力。

  2. 状态保存:暂停时保存所有局部变量和执行位置,恢复时继续用。

  3. 双向通信:通过yield返回值,通过send()接收值,协程之间可以传递数据。

  4. 异常传递:通过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的理解更深入。

第五步:在实际项目中运用

最后,也是最重要的,在实际项目中多运用:

  • 处理大文件的时候,想想能不能用生成器

  • 处理大数据的时候,想想能不能用生成器管道

  • 需要无限序列的时候,用生成器

  • 不要为了用而用,小数据量、需要多次访问的场景,老老实实⽤列表

实践出真知,用得多了,自然就熟练了。

一些最佳实践

最后,分享一些生成器的最佳实践:

  1. 优先用生成器处理大数据:凡是数据量大、内存可能装不下的场景,优先考虑生成器。

  2. 小数据量用列表就好:不要为了炫技而用生成器,列表更简单直观。

  3. 生成器函数要写文档字符串:说明生成器会产生什么数据,参数是什么,方便别人使用。

  4. 合理命名:生成器函数的名字最好能体现"生成"的含义,比如read_linesgenerate_data等。

  5. 注意异常处理:生成器内部的异常要处理好,不要让异常随便抛给调用者。

  6. 资源要清理:如果生成器打开了文件、连接了数据库等资源,要确保在生成器关闭时正确释放(可以用finally块)。

  7. 不要在多线程间共享生成器:生成器不是线程安全的,每个线程用自己的。

写在最后

回到开头的故事。那次事件之后,我对生成器有了全新的认识。

以前我总觉得,生成器是个"高级玩意儿",平时工作中用不上。但那次之后我才发现,生成器其实是一个非常实用的工具,特别是在数据处理领域。

很多时候,我们写代码,只想着"怎么实现功能",却忽略了"怎么高效地实现功能"。结果就是,功能是实现了,但性能一塌糊涂,内存占用高得吓人。

生成器就是这样一个能帮你写出高效代码的工具。它不需要你学什么复杂的算法,也不需要你改架构,只需要把列表换成生成器,把append改成yield,就能节省几十上百倍的内存。

希望这篇文章能让你对生成器有一个新的认识。也希望你能在实际工作中,多想想能不能用生成器优化你的代码。

毕竟,写出既正确又高效的代码,才是我们程序员的追求嘛。

如果你觉得这篇文章对你有帮助,欢迎分享给更多的人。也欢迎你在评论区留言,说说你用生成器做过哪些有意思的事情,或者踩过哪些坑。

最后,祝大家都能写出优雅、高效的Python代码!

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐