Python的生成器把我坑惨了,原来yield和return的区别这么大

简介: 本文深入剖析Python中`yield`与`return`的本质区别:`yield`使函数变为生成器——调用不执行、仅创建惰性迭代器;生成器一次性消费、不支持`len`/索引、异常延迟抛出。明确适用场景:大数据流式处理用`yield`,需多次遍历或随机访问则用`return`列表。

上个月,同事重构一个数据导出脚本。原来的逻辑很直白:从数据库分批读数据,拼成一个列表,最后统一写出到文件。数据量小的时候没问题,后来单次导出涨到几十万条,内存直接飙到两个 G,服务器开始报警。

有人建议他改成生成器——“生成器省内存,边生成边处理”。他听完觉得有道理,改动也很简单:把函数末尾的 return results 删掉,换成在循环里逐个 yield

改完之后调用方的代码基本没动。结果一跑,全炸了。

日志里打印出来的是 <generator object process_all at 0x7f...>,不是数据;下游一个 len(data) 直接抛 TypeError;另一个地方的 data[0] 也报 'generator' object is not subscriptable。最诡异的是,他加在函数第一行的 print("开始处理") 一直没打出来。他以为函数没被调用,加了好几个断点,结果断点一个都没命中。

他跑来问我:“这函数是不是根本没执行?为什么连 print 都没有?”

我说:“你调用它的时候,它确实没执行。”

这就是 yieldreturn 最根本的区别:**return 是立即结束函数并交出结果,yield 是暂停函数并交出结果,函数本身变成了一个生成器工厂**。你以为你改的是返回值的形式,实际上你改的是整个函数的执行模型。

代理 IP 使用小技巧 让你的数据抓取效率翻倍 (89).png

函数里只要出现 yield,它就不再是普通函数

Python 有一个很硬性的规则:只要函数体里出现了 yield,这个函数就不再是普通函数,而是一个生成器函数

这个判断是在编译期完成的,和运行时走哪条分支无关。哪怕你的 yield 写在一个永远不会执行的 if False: 里,这个函数也已经是生成器函数了。

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 循环遍历了一次,发现第二次是空的。

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 个元素是什么,除非你从头迭代到那里。它没有“长度”这个概念,因为它可能无限长。

def infinite():
   n = 0
   while True:
       yield n
       n += 1

gen = infinite()
# len(gen) 会报错,因为它永远不会结束

即使不是无限的,生成器也不预先计算长度。它只在被要求产出时才计算下一个值。所以任何需要“知道总数”或“随机访问”的操作,生成器都不支持。

如果你确实需要这些操作,就在函数里返回列表。或者调用方用 list(gen) 转一下,但要清楚这会把所有元素加载到内存里,生成器省内存的意义就没了。

异常在迭代时才抛出

还有一个更隐蔽的差异:普通函数里抛异常,调用处立刻就能捕获。生成器函数里抛异常,只有迭代到那一步才会抛。

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() 的调用,但迭代发生在别的地方,这个异常就不会被你捕获。

更麻烦的是,如果迭代过程中途停止了,异常永远不会被触发。比如:

g = gen()
first = next(g)
# 不再迭代了
# ValueError 永远不会出现

这在资源清理场景里特别危险。比如你在生成器里打开了一个文件,在 yield 之后关闭它。如果调用方没有把生成器迭代完,close 那段代码永远不会执行,文件句柄就泄漏了。

正确做法是用 try/finally 包住 yield

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 异常里。

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

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 分页拉取的函数,最初是这样写的:

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

后来用户量涨到几十万,这个函数返回的列表占了几百兆内存。有人把它改成生成器:

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

内存问题解决了。但调用方有一段代码是这样的:

users = fetch_all_users()
print(f"共 {len(users)} 个用户")
for u in users:
   process(u)

len(users) 直接报错。改成 sum(1 for _ in users) 之后,生成器被耗尽了,后面的 for 循环一个用户都拿不到。

最后调用方改成了:

count = 0
for u in fetch_all_users():
   count += 1
   process(u)
print(f"共 {count} 个用户")

一次遍历,同时计数和处理。这是生成器正确的使用方式:只迭代一次,边迭代边处理,不要在中间做任何需要“回头”的操作

最后

yieldreturn 的区别,本质上是暂停结束的区别,是惰性立即的区别,是一次性游标可重复容器的区别。

return 改成 yield 不是语法上的等价替换,而是把函数的整个契约变了。调用方如果不知道这个变化,就会踩到“不执行”“只能用一次”“没有 len”“异常延迟”这一连串坑。

生成器是好东西,但它不是万能的省内存工具。用之前先想清楚:调用方需要的是“一个可以反复查看的列表”,还是“一条只能走一次的流”。答案不同,写法就不同。

目录
相关文章
|
2月前
|
数据采集 存储 前端开发
某乎爬虫进阶:爬取问题+回答+用户信息,构建知识图谱数据源
本文详解如何爬取知乎问题、回答及用户信息构建知识图谱:突破SSR渲染与反爬限制,采用API直调+Cookie认证+UA池+隧道代理方案;设计三表实体模型(问题/回答/用户),支持关系抽取与Neo4j存储,兼顾合规性与实用性。(239字)
245 2
|
3月前
|
数据采集 Web App开发 JavaScript
房源信息采集:链家/贝壳等房产网站的反爬策略应对方案
本文详解链家/贝壳房产数据采集的反爬困境与实战方案:针对IP封禁、滑块验证、JS动态渲染及“幽灵房”假数据等难题,提出OpenClaw驱动真实浏览器+站大爷高匿隧道代理+请求频率与指纹伪装三重防护策略,兼顾稳定性与合规性。(239字)
570 0
|
2月前
|
数据采集 人工智能 自然语言处理
自动化比价系统:从采集到数据清洗,全链路打通教程
本文详解淘宝/京东/拼多多三平台自动化比价系统全链路:用OpenClaw自然语言采集、站大爷隧道代理防封(24小时成功率98.2%+)、AI智能清洗价格(统一格式、核销优惠、去重校验),自动生成可决策的比价报告与飞书预警,真正实现“采得稳、洗得准、用得上”。
298 1
|
3月前
|
存储 人工智能 NoSQL
知识库构建:将采集到的数据存入向量数据库,打造企业私域知识库
本文手把手教你用OpenClaw构建企业AI知识库:解决PDF难检索、AI不懂业务、数据用完即弃等痛点。详解Builtin(开箱即用)、LanceDB(永久记忆)和阿里云Tablestore/Hologres(团队协作)三套向量数据库方案,含配置模板、命令及避坑指南,全程本地化、数据私有。(239字)
387 0
|
3月前
|
人工智能 网络协议 Linux
禁用 IPv6:为什么关闭 IPv6 能提升 AI API 的稳定性
本文解析OpenClaw在Linux服务器上API调用不稳定的原因:IPv6双栈环境下DNS解析优先尝试IPv6,但国内IPv6路由、DNS及代理支持不完善,导致超时或失败。建议禁用IPv6,强制走更成熟的IPv4链路,并提供sysctl、GRUB、Docker等一键配置方案。(239字)
440 0
|
17小时前
|
弹性计算 小程序 关系型数据库
阿里云服务器折扣优惠怎么拿?企业采购前先分清渠道、合同和续费
阿里云服务器折扣主要有三类:官网活动价、授权服务中心渠道价、长期合约价。企业选折扣需确认三点:是否写入合同、账号是否归属自己、续费能否延续,避免“低价首购、高价续费”陷阱。
|
17小时前
|
存储 JSON 前端开发
把整个工作簿存成一个文件:SpreadJS 中 ssjson 的导入与导出
SpreadJS 提供原生 ssjson 格式,通过 `toJSON`/`fromJSON` 一键保存/恢复完整工作簿(含公式、样式、冻结窗格等),避免刷新丢失或模板重建。纯文本、可 diff、易存储,支持草稿暂存、模板分发、审批归档等场景,前端几十行代码即可实现可靠持久化。
|
12小时前
|
人工智能 JavaScript 前端开发
读一份 JavaScript 页面脚本,看看停止和卸载做了什么
本文剖析一份JavaScript页面脚本,聚焦“停止”与“卸载”功能设计:停止仅中断后续填充、保留已填答案;卸载则清理面板、监听器及网络劫持,但不回滚数据。代码体现任务状态管理(running/stopped/removed)、安全卸载(防多脚本冲突)及页面自适应逻辑,适用于教学与工程实践参考。(239字)
|
17小时前
|
数据采集 供应链 搜索推荐
2026年电商选品趋势:API驱动下的智能化与个性化
本文解析2026年电商选品新范式:以API为数据底座,实现选品链路的实时化、自动化与个性化。涵盖数据采集、分析建模、决策输出、执行反馈四大节点,提供工程落地路径、代码示例及避坑指南,助力技术团队构建最小可行智能选品系统。(239字)
|
20小时前
|
XML JSON 监控
接口名称 jd.item_get 是京东宙斯开放平台提供的核心商品数据接口
接口名称 jd.item_get 是京东宙斯开放平台提供的核心商品数据接口

热门文章

最新文章