Python 生成器:yield、迭代器,以及列表和字典的推导式
生成器是按需逐个产生值的对象,不会把全部数据保存在内存中;它通过包含 yield 关键字的函数或圆括号中的生成器表达式创建。列表、字典和集合推导式是用一行代码构建完整集合的简写,例如 [x * 2 for x in xs]、{k: v for k, v in pairs}、{x for x in xs}。
Python 中的生成器是一个对象,它按需逐个产生值,不会把整个序列保存在内存中。创建生成器有两种方式:包含关键字 yield 的函数,或圆括号中的生成器表达式。与之相关的还有列表、字典和集合的推导式(comprehension),它是用一行代码构建完整集合的简写形式。下面讲解 python yield 的工作原理,生成器与迭代器的区别,以及如何避免把推导式写成难以阅读的一长串代码。
迭代器:for 循环的基础
要理解生成器,先要了解迭代器。可迭代对象(iterable)是可以用循环遍历的对象:列表、字符串、字典、文件。迭代器是一个会记住当前位置的对象,每次调用 next() 就返回下一个元素;元素耗尽后,它会抛出 StopIteration。
nums = [10, 20, 30]
it = iter(nums) # 从列表获取迭代器
print(next(it)) # 10
print(next(it)) # 20
print(next(it)) # 30
next(it) # StopIteration
for 循环做的正是这件事:先调用 iter(),然后反复调用 next(),直到出现 StopIteration。你也可以用一个类实现自己的迭代器,只需定义 __iter__ 和 __next__ 方法:
class Countdown:
def __init__(self, start):
self.current = start
def __iter__(self):
return self
def __next__(self):
if self.current <= 0:
raise StopIteration
value = self.current
self.current -= 1
return value
print(list(Countdown(3))) # [3, 2, 1]
这样可以工作,但样板代码很多。生成器用三行就能完成同样的事情。
Python 生成器是什么,yield 如何工作
生成器函数是一个普通函数,只是函数体中包含 yield。调用它时并不会执行函数体,而是返回一个生成器对象。每次调用 next(),函数体就运行到下一个 yield,产出一个值,并把函数连同局部变量一起“冻结”。下一次调用 next() 会从同一位置继续。
def countdown(start):
while start > 0:
yield start
start -= 1
gen = countdown(3)
print(gen) # <generator object countdown at 0x...>
print(next(gen)) # 3
print(list(gen)) # [2, 1] — 从上次停下的地方继续
当函数运行到末尾(或遇到 return)时,生成器会自动抛出 StopIteration,for 循环随之正常结束。这就是 yield 与 return 的主要区别:return 结束函数并返回一个值,yield 则暂停函数,并且可以产出任意多个值。
生成器与迭代器的区别
任何生成器都是迭代器:它有 __iter__ 和 __next__。但并非任何迭代器都是生成器。区别在于创建方式和功能。
| 迭代器(类) | 生成器 | |
|---|---|---|
| 如何创建 | 带有 __iter__ 和 __next__ 的类 | 包含 yield 的函数,或 (x for x in ...) |
| 状态 | 手动保存在属性中 | 自动保存在局部变量中 |
| StopIteration | 需要自己抛出 | 函数结束时自动产生 |
| 其他功能 | 取决于你的实现 | send()、throw()、close() |
它们有一个共同的重要特性:只能遍历一次。对已耗尽的生成器再次执行 for 不会产出任何值——需要重新创建。
无限生成器与读取大文件
生成器不一定要有结尾。无限序列是安全的,只要你只取其中有限的部分:
from itertools import islice
def ids():
n = 1
while True:
yield f"ID-{n:04d}"
n += 1
print(list(islice(ids(), 3))) # ['ID-0001', 'ID-0002', 'ID-0003']
最实用的场景是逐行处理大文件。生成器可以串联成流水线,任何时候内存中只有一行,而不是整个文件:
def read_lines(path):
with open(path, encoding="utf-8") as f:
for line in f:
yield line.rstrip("\n")
def only_errors(lines):
for line in lines:
if "ERROR" in line:
yield line
for line in only_errors(read_lines("app.log")):
print(line)
yield from 与 send
yield from 会把另一个可迭代对象的所有值依次产出,省去嵌套循环:
def flatten(groups):
for g in groups:
yield from g
print(list(flatten([[1, 2], [3], [4, 5]]))) # [1, 2, 3, 4, 5]
方法 send() 可以把值传入生成器:它会成为 yield 表达式的结果。在第一次调用 send 之前,必须先用 next() 启动生成器。下面是一个计算平均值的例子:
def averager():
total, count = 0, 0
avg = None
while True:
value = yield avg
total += value
count += 1
avg = total / count
a = averager()
next(a) # 执行到第一个 yield
print(a.send(10)) # 10.0
print(a.send(20)) # 15.0
在实际代码中 send 很少用到,但面试时经常会被问到。
range 是生成器吗?
不是。range 是惰性序列:它同样不保存全部数字,但支持索引、len()、in 检查,并且可以遍历任意多次。生成器则不具备这些功能。
r = range(5)
print(r[2], len(r), 3 in r) # 2 5 True
print(list(r), list(r)) # 两次都是 [0, 1, 2, 3, 4]
列表推导式:list comprehension
列表推导式的形式是 [表达式 for x in 集合]。它创建一个新列表,不修改原列表。阅读顺序是从左到右:先写“放入什么”,再写“从哪里取”。
amounts = [100, 250, 400]
# 普通循环
with_markup = []
for a in amounts:
with_markup.append(a * 1.2)
# 同样的操作,用推导式
with_markup = [a * 1.2 for a in amounts]
print(with_markup) # [120.0, 300.0, 480.0]
虽然名字里有“列表生成器”,但结果是一个普通的已完成列表,而不是生成器:所有元素都是立即计算的。
带 if 和 if else 条件的列表推导式
这里最容易混淆。过滤条件 if 放在末尾,用于丢弃元素。选择条件(三元运算符)放在开头,把每个元素变成两个值中的一个,而不丢弃任何元素。
amounts = [100, 250, 400]
big = [a for a in amounts if a > 200] # [250, 400]
labels = ["多" if a > 200 else "少" for a in amounts]
# ['少', '多', '多']
# 过滤与选择一起使用
parity = ["偶" if n % 2 == 0 else "奇" for n in range(6) if n > 2]
# ['奇', '偶', '奇']
写成 [a for a in amounts if a > 200 else 0] 是语法错误:else 只能用于开头的条件表达式。
嵌套推导式与二维列表
两个 for 的书写顺序与嵌套循环相同,从外到内。这有助于在展开列表的列表时不搞错顺序:
groups = [[1, 2], [3], [4, 5]]
flat = [x for g in groups for x in g]
# 等价于:
# for g in groups:
# for x in g:
# flat.append(x)
print(flat) # [1, 2, 3, 4, 5]
二维列表(矩阵)可以用推导式套推导式来创建。经典的错误是对列表做乘法:各行会是同一个对象。
bad = [[0] * 3] * 2
bad[0][0] = 1
print(bad) # [[1, 0, 0], [1, 0, 0]] — 两行都变了
grid = [[0] * 3 for _ in range(2)]
grid[0][0] = 1
print(grid) # [[1, 0, 0], [0, 0, 0]]
# 转置
m = [[1, 2, 3], [4, 5, 6]]
print([[row[i] for row in m] for i in range(3)]) # [[1, 4], [2, 5], [3, 6]]
字典与集合推导式
同样的方法适用于其他集合。带冒号的花括号构建字典(dict comprehension),不带冒号的花括号构建集合(set comprehension)。
spent = [("餐饮", 500), ("打车", 300), ("餐饮", 200)]
categories = {cat for cat, _ in spent}
print(categories) # {'餐饮', '打车'} — 集合的顺序不保证
last = {cat: amount for cat, amount in spent}
print(last) # {'餐饮': 200, '打车': 300}
squares = {n: n ** 2 for n in range(4)} # {0: 0, 1: 1, 2: 4, 3: 9}
swapped = {v: k for k, v in squares.items()}
注意 last:键重复时以最后一个值为准,500 这笔金额丢失了。如果需要求和,推导式不合适——应该写循环,或使用 collections.Counter / defaultdict。
元组推导式?
Python 中没有单独的“元组推导式”。圆括号产生的是生成器表达式,要得到元组,需要把它传给 tuple():
t = tuple(n * 2 for n in range(3)) # (0, 2, 4)
生成器表达式:不保存,只产出
把方括号换成圆括号,就得到了生成器表达式。它不会预先计算任何东西,而是逐个产出值。对于一百万个元素,内存差别非常大:
import sys
as_list = [n for n in range(1_000_000)]
as_gen = (n for n in range(1_000_000))
print(sys.getsizeof(as_list)) # 约 8 MB
print(sys.getsizeof(as_gen)) # 几百字节
生成器表达式最常用于聚合函数的参数中。如果生成器是函数的唯一参数,可以省略多余的括号。any() 和 all() 在得到第一个结果后就会停止,不会遍历剩余部分。
spent = [("餐饮", 500), ("打车", 300), ("餐饮", 200)]
total = sum(a for _, a in spent) # 1000
has_big = any(a > 400 for _, a in spent) # True,找到 500 后就不再继续
biggest = max(spent, key=lambda p: p[1]) # ('餐饮', 500)
选择规则很简单:如果结果需要多次使用、需要索引或 len(),就用列表;如果只需要遍历一次并计算某个结果,就用生成器。
作用域:循环变量不会泄漏
这是即使是有经验的开发者也会踩的坑:
x = 10
squares = [x for x in range(3)]
print(x) # 10
推导式中的循环变量位于自己的作用域内,不会覆盖外部的同名变量(在 Python 3 中如此;在 Python 2 中并非如此)。普通的 for 循环结束后,x 会变成 2。从 3.12 版本开始(PEP 709),推导式被内联到外围函数中,运行更快,但变量可见性的规则保持不变。
3.15 中推导式内的 * 与 ** 解包(PEP 798)
在函数调用和列表字面量中,星号解包早就可以使用,但在推导式内部曾是 SyntaxError。在 Python 3.15 中这个限制被取消:*元素 表示“把它的所有元素都放在这里”。它适用于列表、集合和生成器,包括异步的情形。
# Python 3.15+
groups = [[1, 2], [3], [4, 5]]
flat = [*g for g in groups] # [1, 2, 3, 4, 5]
uniq = {*g for g in groups} # 集合
lazy = (*g for g in groups) # 惰性生成器
双星号对字典做同样的事情。这对于分层配置非常方便:“默认值 → 文件 → 命令行标志”,每一层都会覆盖前一层的键。
# Python 3.15+
layers = [{"debug": False, "port": 80}, {"port": 8080}, {"debug": True}]
config = {**layer for layer in layers}
# {'debug': True, 'port': 8080}
课程中提到两点注意事项。第一,不能把星号与不加括号的条件表达式混用:[*w if w else 0 for w in weeks] 是语法错误,条件需要加括号。第二,这种代码无法在 3.14 及更早版本中运行:如果库需要支持旧版本,请保留双重 for,或使用 itertools.chain.from_iterable。
异步生成器
如果 async def 函数中包含 yield,它就是异步生成器。遍历它要使用 async for,函数内部可以用 await 等待网络或磁盘。异步推导式也是存在的。
import asyncio
async def ticks(n):
for i in range(n):
await asyncio.sleep(0.1)
yield i
async def main():
result = [i async for i in ticks(3)]
print(result) # [0, 1, 2]
asyncio.run(main())
常见错误
- 三层嵌套的推导式。 两个
for、两个if和三元运算符挤在一行里,一周后就没人能看懂了。如果放不进一行,就写普通循环:PEP 8 把可读性放在简洁性之上。 - 重复遍历生成器。 第二次执行
sum(gen)会得到 0,因为生成器已经耗尽。 - 为了副作用使用推导式。
[print(x) for x in xs]会构建一个无用的None列表。执行操作应该用for循环。 - 期望生成器表达式可以取下标。
(x for x in xs)[0]会得到TypeError:生成器没有索引。
生成器练习题
- 编写生成器
fib(),无限产出斐波那契数,并用islice取前 10 个。 - 用一行代码得到长度大于三个字母的单词的字典:“单词 → 长度”。
- 不创建列表,计算一百万以内所有偶数平方的和。
- 编写生成器
chunks(seq, n),每次产出列表中的n个元素。
小结
迭代器通过 next() 逐个返回元素,而生成器是编写迭代器最简单的方式:带有 yield 的函数或圆括号中的表达式。列表、字典和集合推导式用一行代码构建完整集合:过滤条件 if 放在末尾,选择条件 if else 放在开头。需要节省内存或只处理一次数据流时,使用生成器;需要多次使用结果时,使用列表。推导式、生成器以及 Python 3.15 的新解包语法,都会在课程中结合支出记录器项目进行实践讲解。
常见问题
生成器函数中可以使用 return 吗?
可以。不带值的 return 只是结束生成器;带值的 return 会把值放入 StopIteration 异常的 value 属性中,使用 yield from 的表达式会得到这个值。
如何把生成器转换为列表?
把它传给 list():list(gen)。注意,转换之后生成器就耗尽了,而且所有值都会占用内存。
列表推导式和带 append 的循环哪个更快?
通常推导式稍快,因为它不需要在每次迭代中调用 append 方法。但选择时应该优先考虑可读性,性能差异很少是决定性的。
如何判断一个对象是否为生成器?
对对象使用 inspect.isgenerator(obj),对函数使用 inspect.isgeneratorfunction(f);判断对象是否为迭代器,可以用 isinstance(obj, collections.abc.Iterator)。
生成器表达式和列表推导式有什么区别?
方括号会立即在内存中构建整个列表;圆括号则创建一个生成器,按需计算元素,并且只能遍历一次。