上个月,同事重构一个数据导出脚本。原来的逻辑很直白:从数据库分批读数据,拼成一个列表,最后统一写出到文件。数据量小的时候没问题,后来单次导出涨到几十万条,内存直接飙到两个 G,服务器开始报警。
有人建议他改成生成器------"生成器省内存,边生成边处理"。他听完觉得有道理,改动也很简单:把函数末尾的 return results 删掉,换成在循环里逐个 yield。
改完之后调用方的代码基本没动。结果一跑,全炸了。
日志里打印出来的是 <generator object process_all at 0x7f...>,不是数据;下游一个 len(data) 直接抛 TypeError;另一个地方的 data[0] 也报 'generator' object is not subscriptable。最诡异的是,他加在函数第一行的 print("开始处理") 一直没打出来。他以为函数没被调用,加了好几个断点,结果断点一个都没命中。
他跑来问我:"这函数是不是根本没执行?为什么连 print 都没有?"
我说:"你调用它的时候,它确实没执行。"
这就是 yield 和 return 最根本的区别:return 是立即结束函数并交出结果,yield 是暂停函数并交出结果,函数本身变成了一个生成器工厂。你以为你改的是返回值的形式,实际上你改的是整个函数的执行模型。
函数里只要出现 yield,它就不再是普通函数
Python 有一个很硬性的规则:只要函数体里出现了 yield,这个函数就不再是普通函数,而是一个生成器函数。
这个判断是在编译期完成的,和运行时走哪条分支无关。哪怕你的 yield 写在一个永远不会执行的 if False: 里,这个函数也已经是生成器函数了。
python
def f():
if False:
yield
return 42
print(f())
# <generator object f at 0x...>
调用 f() 不会执行 return 42,也不会执行任何函数体。它只是创建了一个生成器对象,然后把函数体挂在这个对象上,等你去迭代它的时候才真正开始执行。
这就是为什么同事的 print("开始处理") 没有打出来------调用生成器函数不等于执行函数。真正执行发生在你第一次对它做 next()、for 循环、list() 或者 send() 的时候。
生成器函数和普通函数的调用语义完全不同:
- 普通函数:调用即执行,执行到
return结束,返回一个值。 - 生成器函数:调用只创建生成器对象,不执行函数体;迭代时才执行,执行到
yield暂停,交出值,下次迭代从暂停处继续。
很多人把 yield 理解成"返回一个值,但函数还能继续",这个理解只对了一半。更准确的说法是:yield 把函数的执行状态冻结在那一行,包括局部变量、指令指针、调用栈,全部保留。下次恢复时,从冻结的地方接着跑。函数没有"结束",只是"暂停"了。
生成器只能消费一次
同事遇到的第二个问题更隐蔽:他有一段代码先把生成器转成列表打印了一遍,然后又用 for 循环遍历了一次,发现第二次是空的。
scss
data = process_all()
print(list(data)) # 有数据
print(list(data)) # []
这不是 bug,是生成器的设计。生成器是一次性迭代器,它内部只有一个游标。第一次迭代时游标从头走到尾,迭代结束后游标停在末尾。第二次迭代时,游标已经在末尾了,没有任何元素可产出。
你可以把它想象成一支牙膏。挤一次,牙膏出来;再挤,没了。它不会自己长回去。
return 返回的列表不一样。列表是一个容器,里面有完整的元素。你遍历一次、两次、十次,每次都从头开始,因为每次遍历都是创建一个新的迭代器,指向同一个容器。
生成器本身既是容器又是迭代器。它没有"重新开始"的概念。用完就是空了。
这个特性在真实项目里非常容易踩坑。比如你写了一个返回生成器的函数,调用方先 len(list(data)) 检查数量,然后再遍历处理,第二次就什么都没有了。或者你在调试时打印了一下,正式逻辑再跑就空了。
解决办法只有一个:如果调用方需要多次遍历,就不要返回生成器,返回列表。或者在函数内部把生成器转成列表再返回。省内存和可重复使用,很多时候只能二选一。
生成器不支持 len、索引、切片
同事遇到的第三个问题是 len(data) 报错。这也是必然的。
len() 要求对象实现 __len__ 方法,生成器没有。索引 data[0] 要求实现 __getitem__,生成器也没有。切片 data[1:5] 同样不支持。
原因很简单:生成器是惰性的,它不知道总共有多少元素,也不知道第 5 个元素是什么,除非你从头迭代到那里。它没有"长度"这个概念,因为它可能无限长。
arduino
def infinite():
n = 0
while True:
yield n
n += 1
gen = infinite()
# len(gen) 会报错,因为它永远不会结束
即使不是无限的,生成器也不预先计算长度。它只在被要求产出时才计算下一个值。所以任何需要"知道总数"或"随机访问"的操作,生成器都不支持。
如果你确实需要这些操作,就在函数里返回列表。或者调用方用 list(gen) 转一下,但要清楚这会把所有元素加载到内存里,生成器省内存的意义就没了。
异常在迭代时才抛出
还有一个更隐蔽的差异:普通函数里抛异常,调用处立刻就能捕获。生成器函数里抛异常,只有迭代到那一步才会抛。
python
def gen():
print("start")
yield 1
raise ValueError("boom")
yield 2
g = gen() # 不执行,不报错
print(next(g)) # 打印 start,输出 1
print(next(g)) # 抛出 ValueError
调用 gen() 的时候什么都不会发生。异常被推迟到第二次 next() 才触发。如果你的代码里有一层 try/except 包着 gen() 的调用,但迭代发生在别的地方,这个异常就不会被你捕获。
更麻烦的是,如果迭代过程中途停止了,异常永远不会被触发。比如:
ini
g = gen()
first = next(g)
# 不再迭代了
# ValueError 永远不会出现
这在资源清理场景里特别危险。比如你在生成器里打开了一个文件,在 yield 之后关闭它。如果调用方没有把生成器迭代完,close 那段代码永远不会执行,文件句柄就泄漏了。
正确做法是用 try/finally 包住 yield:
arduino
def read_lines(path):
f = open(path)
try:
for line in f:
yield line
finally:
f.close()
这样即使调用方中途放弃迭代,生成器被垃圾回收时 finally 也会执行,文件能正常关闭。或者用 with 语句,效果一样。
return 在生成器里意味着什么
有一个容易被忽略的细节:生成器函数里也可以写 return,但它的含义和普通函数完全不同。
在生成器里,return value 不会把 value 返回给调用方。它做的是结束生成器 ,并把这个值放进 StopIteration 异常里。
python
def gen():
yield 1
yield 2
return "done"
g = gen()
print(next(g)) # 1
print(next(g)) # 2
print(next(g)) # 抛出 StopIteration: done
return 的值不会出现在正常的迭代结果里。for 循环会自动捕获 StopIteration,所以你只能拿到 1 和 2,拿不到 "done"。想拿到这个值,得手动用 next() 并捕获 StopIteration:
python
try:
while True:
print(next(g))
except StopIteration as e:
print(e.value) # done
这个机制在设计协程和 yield from 的时候有用,但在日常数据处理里基本用不上。大部分人写 return 只是想在某个条件下提前结束生成器,这时候 return 后面不跟值就行了,效果和 return None 一样,都是停止产出。
什么时候该用 yield,什么时候该用 return
判断标准其实不复杂,问自己三个问题:
第一,数据量是不是大到不能一次性放进内存?
如果是,用 yield。比如读取一个 10G 的日志文件,逐行处理,生成器是唯一合理的选择。如果把所有行读进列表,内存直接爆掉。
第二,调用方是不是只需要遍历一次?
如果调用方需要多次遍历、需要 len()、需要索引、需要排序,那生成器就不合适。这些操作要么不支持,要么会把生成器耗尽。返回列表更省心。
第三,数据是不是流式的、逐步产生的?
如果数据本身是逐步到达的,比如网络流、传感器数据、数据库游标,生成器天然契合。数据来一个产一个,不需要等全部到齐。
三个问题里只要有一个答案是"是",就倾向于用生成器。如果调用方明确需要容器语义,那就老老实实返回列表,别为了省内存强行改成生成器,最后调用方到处踩坑。
一个真实场景
我见过一个 API 分页拉取的函数,最初是这样写的:
kotlin
def fetch_all_users():
users = []
page = 1
while True:
resp = requests.get(f"/api/users?page={page}")
data = resp.json()
if not data["items"]:
break
users.extend(data["items"])
page += 1
return users
后来用户量涨到几十万,这个函数返回的列表占了几百兆内存。有人把它改成生成器:
kotlin
def fetch_all_users():
page = 1
while True:
resp = requests.get(f"/api/users?page={page}")
data = resp.json()
if not data["items"]:
break
for user in data["items"]:
yield user
page += 1
内存问题解决了。但调用方有一段代码是这样的:
scss
users = fetch_all_users()
print(f"共 {len(users)} 个用户")
for u in users:
process(u)
len(users) 直接报错。改成 sum(1 for _ in users) 之后,生成器被耗尽了,后面的 for 循环一个用户都拿不到。
最后调用方改成了:
scss
count = 0
for u in fetch_all_users():
count += 1
process(u)
print(f"共 {count} 个用户")
一次遍历,同时计数和处理。这是生成器正确的使用方式:只迭代一次,边迭代边处理,不要在中间做任何需要"回头"的操作。
最后
yield 和 return 的区别,本质上是暂停 和结束 的区别,是惰性 和立即 的区别,是一次性游标 和可重复容器的区别。
把 return 改成 yield 不是语法上的等价替换,而是把函数的整个契约变了。调用方如果不知道这个变化,就会踩到"不执行""只能用一次""没有 len""异常延迟"这一连串坑。
生成器是好东西,但它不是万能的省内存工具。用之前先想清楚:调用方需要的是"一个可以反复查看的列表",还是"一条只能走一次的流"。答案不同,写法就不同。