
讲个真事儿。
去年我帮一个朋友处理他的爬虫数据。他从某个网站抓了大概50万条商品记录,存成了一个巨大的JSON文件,大小接近3GB。他的需求很简单:从里面筛选出价格大于100块的商品,导出成一个新的文件。
他写了一段代码:
import json
def load_data(path):
with open(path) as f:
return json.load(f)
def filter_products(products):
result = []
for p in products:
if p['price'] > 100:
result.append(p)
return result
data = load_data('products.json')
filtered = filter_products(data)
with open('filtered.json', 'w') as f:
json.dump(filtered, f)代码跑起来之后,他的电脑风扇开始狂转,内存占用从2GB一路飙到14GB,最后进程被系统杀掉。他以为是数据太大,机器扛不住,准备去租一台高配云服务器。
我看了他的代码,只改了两处。第一处,把load_data从json.load改成逐行读取;第二处,把filter_products从返回列表改成用yield返回。改完之后,同一台电脑,同样的数据,内存占用稳定在80MB左右,三分钟跑完。
他盯着屏幕看了半天,说了一句话:“就加了个yield,差别这么大?”
是的,差别就是这么大。return和yield看起来只差一个关键字,但它们代表的是两种完全不同的编程思路。
先看看我们最熟悉的return。
def get_numbers():
result = []
for i in range(1000000):
result.append(i * 2)
return result这段代码的执行过程是这样的:
resulti * 2,追加到列表里关键点:**return只能返回一次,返回之后函数就结束了。而且返回之前,所有结果必须全部计算完并存储在内存里。**
这就是为什么我朋友的代码会吃掉14GB内存:他把50万条记录全部读进内存,又把筛选结果全部存进另一个列表,两份数据同时存在,内存直接翻倍。
同样的需求,用yield写:
def get_numbers():
for i in range(1000000):
yield i * 2这段代码的执行过程完全不同:
next()或者用for循环遍历时,函数体才开始执行yield i * 2时,计算i * 2,把结果“吐”出来,然后暂停在这里yield关键点:**yield可以返回多次,每次返回一个值,然后暂停。函数的状态(局部变量、执行位置)被保留,下次从暂停处继续。**
内存里永远只有一个值(或者很少几个值)在流动,不会积压。
我想到一个比喻。
return像去餐厅吃饭。 你点了一份红烧肉,厨师在厨房里把所有菜都做完,装好盘,一次性端到你面前。你吃的时候,菜已经全在桌上了。如果你点的是100道菜,那厨师得先把100道菜全做好,厨房里堆满了盘子,才能开始上菜。菜越多,厨房越挤,最后可能连站的地方都没有。
yield像吃流水席。 厨师做好一道菜,端上来,你吃完,他再做下一道。厨房里永远只有一道菜在准备。你吃100道菜,厨房也不会被堆满。你什么时候想吃,他就什么时候做;你不吃,他就停着。
再换一个更贴近编程的比喻:
return像把整个视频下载到本地再看。 你得等下载完,才能开始播放。视频越大,等待越久,占用的硬盘空间越大。
yield像在线流媒体播放。 你点开就能看,看多少加载多少,不看的就不加载。视频再大,占用的内存也很小。
理解生成器,抓住这三个特性就够了:
第一,惰性求值。 生成器不会提前计算任何东西。你不问它要值,它就不干活。这叫“惰性”,也可以叫“按需”。
第二,状态保留。 每次yield之后,函数的执行状态(包括局部变量、循环位置)被保存下来。下次继续时,从上次停下的地方接着跑。这跟普通函数不一样——普通函数每次调用都是从第一行重新开始。
第三,一次性。 生成器只能遍历一次。遍历完了就空了,再遍历什么都得不到。因为它不存储数据,只存储“怎么算”的规则,遍历完规则就用完了。
def simple_gen():
yield 1
yield 2
yield 3
gen = simple_gen()
print(list(gen)) # [1, 2, 3]
print(list(gen)) # [] —— 已经空了想再用?重新创建一个生成器对象。
除了用yield定义生成器函数,Python还提供了生成器表达式,写法类似列表推导式,但用圆括号:
# 列表推导式:立即计算,返回列表
squares_list = [x**2 for x in range(1000000)]
# 生成器表达式:惰性计算,返回生成器
squares_gen = (x**2 for x in range(1000000))这两行代码在内存上的差距是巨大的。列表推导式会立即创建一个包含100万个元素的列表,占用几十MB内存。生成器表达式只创建一个生成器对象,占用不到200字节。
而且,当生成器表达式作为函数的唯一参数时,可以省略外层圆括号:
sum(x**2 for x in range(1000000))这比sum([x**2 for x in range(1000000)])内存效率高得多。
假设你有一个5GB的日志文件,要统计包含“ERROR”的行数。用return的写法:
def read_all_lines(path):
with open(path) as f:
return f.readlines() # 一次性把所有行读进内存
lines = read_all_lines('huge.log')
error_count = sum(1 for line in lines if 'ERROR' in line)readlines()会把整个文件读进内存,5GB的文件直接占5GB内存(加上Python字符串对象的开销,可能更多)。
用yield的写法:
def read_lines_lazy(path):
with open(path) as f:
for line in f:
yield line
error_count = sum(1 for line in read_lines_lazy('huge.log') if 'ERROR' in line)生成器逐行读取,每次只加载一行到内存。5GB的文件,内存占用可以控制在几MB以内。
其实,文件对象本身就是可迭代的,for line in f已经是在惰性读取了。所以最简单的方式是直接用文件对象,连生成器函数都不用写。但如果你需要在读取过程中做额外的处理(比如解析、过滤、转换),生成器函数就派上用场了。
生成器不只是省内存,它在很多场景下都能让代码更简洁、更清晰。
场景一:数据管道
你要对数据进行多步处理:读取→清洗→转换→过滤。每一步都可以写成一个生成器,然后像管道一样串起来:
def read_records(path):
with open(path) as f:
for line in f:
yield json.loads(line)
def clean_records(records):
for r in records:
if r.get('name') and r.get('price'):
yield r
def filter_expensive(records):
for r in records:
if r['price'] > 100:
yield r
# 管道串联
records = read_records('data.jsonl')
cleaned = clean_records(records)
expensive = filter_expensive(cleaned)
for r in expensive:
process(r)数据像水流一样从管道中流过,每一步只处理当前这一条记录。内存里永远只有一条记录在流动,不会积压。
场景二:无限序列
生成器可以表示无限序列,因为它不需要一次性生成所有值:
def fibonacci():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
fib = fibonacci()
for i in range(10):
print(next(fib))如果用return写斐波那契数列,你没法表示“无限”这个概念,因为列表不可能无限大。
场景三:状态机
生成器天然适合实现状态机,因为它的执行位置和局部变量都被保留了:
def traffic_light():
while True:
yield '红灯'
yield '绿灯'
yield '黄灯'
light = traffic_light()
print(next(light)) # 红灯
print(next(light)) # 绿灯
print(next(light)) # 黄灯
print(next(light)) # 红灯场景四:协程
生成器还可以用来实现协程,通过send()方法向生成器发送值:
def accumulator():
total = 0
while True:
value = yield total
if value is not None:
total += value
acc = accumulator()
next(acc) # 启动生成器,返回 0
print(acc.send(10)) # 10
print(acc.send(20)) # 30
print(acc.send(5)) # 35这个特性后来演化成了Python的async/await异步编程模型。
这三个概念经常被混淆,简单梳理一下:
__iter__()方法的对象。列表、元组、字符串、字典、文件对象都是可迭代对象。__iter__()和__next__()方法的对象。迭代器一定可迭代,但可迭代对象不一定是迭代器。yield或生成器表达式创建。它自动实现了__iter__()和__next__()。你不需要手动实现这些魔术方法,只要用yield,Python就帮你把生成器变成了一个标准的迭代器。
生成器不是万能的,有几个坑要注意:
坑一:生成器只能遍历一次
如果你需要多次遍历同一批数据,生成器就不合适。要么重新创建生成器,要么把数据转成列表。
坑二:生成器不能获取长度
len(gen)会报错,因为生成器不存储数据,它不知道自己有多少个元素。如果需要长度,用list(gen)转成列表,但这样就失去了内存优势。
坑三:生成器不能索引
gen[0]会报错。生成器只能从头到尾顺序访问,不能随机访问。
坑四:生成器中的异常会终止生成器
如果生成器函数内部抛出异常,生成器就废了,不能继续使用。
坑五:return在生成器中的特殊含义
在生成器函数中,return不会返回值给调用方,而是用来终止生成器。如果return后面带了值,Python会把它作为StopIteration异常的参数:
def gen():
yield 1
yield 2
return "done"
yield 3 # 永远不会执行
g = gen()
print(next(g)) # 1
print(next(g)) # 2
print(next(g)) # 抛出 StopIteration: done这个特性在实际开发中很少用到,但面试中经常被问到。
判断标准很简单:
用生成器的信号:
不用生成器的信号:
return和yield的区别,本质上是 “全部做完再给你”和“做一个给一个” 的区别。
return是批处理思维:把所有东西准备好,一次性交付。简单直接,但内存开销大。
yield是流式思维:需要的时候才算,算完一个给一个。内存友好,但只能顺序消费。
我那个朋友后来把整个数据处理流程都改成了生成器管道,从读文件、解析JSON、过滤、转换到写文件,全程流式处理。50万条数据,内存占用从14GB降到了80MB。他跟我说,感觉像换了一台电脑。
生成器不会让你的代码跑得更快,但它能让你的代码在同样的内存里处理大得多的数据。当你下次遇到内存爆掉的时候,先想想:这里能不能用yield?
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。