首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >Python的生成器把我内存救活了,原来return和yield差距这么大

Python的生成器把我内存救活了,原来return和yield差距这么大

原创
作者头像
风一样的男子
发布2026-09-11 14:18:43
发布2026-09-11 14:18:43
250
举报
文章被收录于专栏:编程教程编程教程

讲个真事儿。

去年我帮一个朋友处理他的爬虫数据。他从某个网站抓了大概50万条商品记录,存成了一个巨大的JSON文件,大小接近3GB。他的需求很简单:从里面筛选出价格大于100块的商品,导出成一个新的文件。

他写了一段代码:

代码语言:javascript
复制
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_datajson.load改成逐行读取;第二处,把filter_products从返回列表改成用yield返回。改完之后,同一台电脑,同样的数据,内存占用稳定在80MB左右,三分钟跑完。

他盯着屏幕看了半天,说了一句话:“就加了个yield,差别这么大?”

是的,差别就是这么大。return和yield看起来只差一个关键字,但它们代表的是两种完全不同的编程思路。

return 到底做了什么

先看看我们最熟悉的return

代码语言:javascript
复制
def get_numbers():
    result = []
    for i in range(1000000):
        result.append(i * 2)
    return result

这段代码的执行过程是这样的:

  1. 函数被调用
  2. 创建一个空列表result
  3. 循环100万次,每次计算i * 2,追加到列表里
  4. 循环结束后,返回这个包含100万个元素的列表
  5. 函数结束,函数内部的所有局部变量被销毁,但返回的列表被外部变量引用,继续存在于内存中

关键点:**return只能返回一次,返回之后函数就结束了。而且返回之前,所有结果必须全部计算完并存储在内存里。**

这就是为什么我朋友的代码会吃掉14GB内存:他把50万条记录全部读进内存,又把筛选结果全部存进另一个列表,两份数据同时存在,内存直接翻倍。

yield 到底做了什么

同样的需求,用yield写:

代码语言:javascript
复制
def get_numbers():
    for i in range(1000000):
        yield i * 2

这段代码的执行过程完全不同:

  1. 函数被调用,但函数体不会立即执行
  2. 返回一个生成器对象
  3. 当你第一次调用next()或者用for循环遍历时,函数体才开始执行
  4. 执行到yield i * 2时,计算i * 2,把结果“吐”出来,然后暂停在这里
  5. 下一次要值的时候,从暂停的地方继续执行,直到遇到下一个yield
  6. 循环结束后,函数正常返回,生成器停止

关键点:**yield可以返回多次,每次返回一个值,然后暂停。函数的状态(局部变量、执行位置)被保留,下次从暂停处继续。**

内存里永远只有一个值(或者很少几个值)在流动,不会积压。

用生活场景理解 return 和 yield

我想到一个比喻。

return像去餐厅吃饭。 你点了一份红烧肉,厨师在厨房里把所有菜都做完,装好盘,一次性端到你面前。你吃的时候,菜已经全在桌上了。如果你点的是100道菜,那厨师得先把100道菜全做好,厨房里堆满了盘子,才能开始上菜。菜越多,厨房越挤,最后可能连站的地方都没有。

yield像吃流水席。 厨师做好一道菜,端上来,你吃完,他再做下一道。厨房里永远只有一道菜在准备。你吃100道菜,厨房也不会被堆满。你什么时候想吃,他就什么时候做;你不吃,他就停着。

再换一个更贴近编程的比喻:

return像把整个视频下载到本地再看。 你得等下载完,才能开始播放。视频越大,等待越久,占用的硬盘空间越大。

yield像在线流媒体播放。 你点开就能看,看多少加载多少,不看的就不加载。视频再大,占用的内存也很小。

生成器的三个核心特性

理解生成器,抓住这三个特性就够了:

第一,惰性求值。 生成器不会提前计算任何东西。你不问它要值,它就不干活。这叫“惰性”,也可以叫“按需”。

第二,状态保留。 每次yield之后,函数的执行状态(包括局部变量、循环位置)被保存下来。下次继续时,从上次停下的地方接着跑。这跟普通函数不一样——普通函数每次调用都是从第一行重新开始。

第三,一次性。 生成器只能遍历一次。遍历完了就空了,再遍历什么都得不到。因为它不存储数据,只存储“怎么算”的规则,遍历完规则就用完了。

代码语言:javascript
复制
def simple_gen():
    yield 1
    yield 2
    yield 3

gen = simple_gen()
print(list(gen))  # [1, 2, 3]
print(list(gen))  # [] —— 已经空了

想再用?重新创建一个生成器对象。

生成器表达式:圆括号的威力

除了用yield定义生成器函数,Python还提供了生成器表达式,写法类似列表推导式,但用圆括号:

代码语言:javascript
复制
# 列表推导式:立即计算,返回列表
squares_list = [x**2 for x in range(1000000)]

# 生成器表达式:惰性计算,返回生成器
squares_gen = (x**2 for x in range(1000000))

这两行代码在内存上的差距是巨大的。列表推导式会立即创建一个包含100万个元素的列表,占用几十MB内存。生成器表达式只创建一个生成器对象,占用不到200字节。

而且,当生成器表达式作为函数的唯一参数时,可以省略外层圆括号:

代码语言:javascript
复制
sum(x**2 for x in range(1000000))

这比sum([x**2 for x in range(1000000)])内存效率高得多。

一个实际的例子:读大文件

假设你有一个5GB的日志文件,要统计包含“ERROR”的行数。用return的写法:

代码语言:javascript
复制
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的写法:

代码语言:javascript
复制
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已经是在惰性读取了。所以最简单的方式是直接用文件对象,连生成器函数都不用写。但如果你需要在读取过程中做额外的处理(比如解析、过滤、转换),生成器函数就派上用场了。

生成器的实际应用场景

生成器不只是省内存,它在很多场景下都能让代码更简洁、更清晰。

场景一:数据管道

你要对数据进行多步处理:读取→清洗→转换→过滤。每一步都可以写成一个生成器,然后像管道一样串起来:

代码语言:javascript
复制
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)

数据像水流一样从管道中流过,每一步只处理当前这一条记录。内存里永远只有一条记录在流动,不会积压。

场景二:无限序列

生成器可以表示无限序列,因为它不需要一次性生成所有值:

代码语言:javascript
复制
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写斐波那契数列,你没法表示“无限”这个概念,因为列表不可能无限大。

场景三:状态机

生成器天然适合实现状态机,因为它的执行位置和局部变量都被保留了:

代码语言:javascript
复制
def traffic_light():
    while True:
        yield '红灯'
        yield '绿灯'
        yield '黄灯'

light = traffic_light()
print(next(light))  # 红灯
print(next(light))  # 绿灯
print(next(light))  # 黄灯
print(next(light))  # 红灯

场景四:协程

生成器还可以用来实现协程,通过send()方法向生成器发送值:

代码语言:javascript
复制
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异步编程模型。

生成器 vs 迭代器 vs 可迭代对象

这三个概念经常被混淆,简单梳理一下:

  • 可迭代对象:实现了__iter__()方法的对象。列表、元组、字符串、字典、文件对象都是可迭代对象。
  • 迭代器:实现了__iter__()__next__()方法的对象。迭代器一定可迭代,但可迭代对象不一定是迭代器。
  • 生成器:一种特殊的迭代器,用yield或生成器表达式创建。它自动实现了__iter__()__next__()

你不需要手动实现这些魔术方法,只要用yield,Python就帮你把生成器变成了一个标准的迭代器。

生成器的性能陷阱

生成器不是万能的,有几个坑要注意:

坑一:生成器只能遍历一次

如果你需要多次遍历同一批数据,生成器就不合适。要么重新创建生成器,要么把数据转成列表。

坑二:生成器不能获取长度

len(gen)会报错,因为生成器不存储数据,它不知道自己有多少个元素。如果需要长度,用list(gen)转成列表,但这样就失去了内存优势。

坑三:生成器不能索引

gen[0]会报错。生成器只能从头到尾顺序访问,不能随机访问。

坑四:生成器中的异常会终止生成器

如果生成器函数内部抛出异常,生成器就废了,不能继续使用。

坑五:return在生成器中的特殊含义

在生成器函数中,return不会返回值给调用方,而是用来终止生成器。如果return后面带了值,Python会把它作为StopIteration异常的参数:

代码语言:javascript
复制
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

这个特性在实际开发中很少用到,但面试中经常被问到。

什么时候该用生成器

判断标准很简单:

用生成器的信号:

  • 数据量大,内存吃紧
  • 只需要遍历一次
  • 处理可以分成多个步骤,每步只关注当前元素
  • 需要表示无限序列
  • 需要实现数据管道

不用生成器的信号:

  • 需要多次遍历
  • 需要随机访问(索引、切片)
  • 需要知道长度
  • 数据量小,列表更方便

总结

returnyield的区别,本质上是 “全部做完再给你”和“做一个给一个” 的区别。

return是批处理思维:把所有东西准备好,一次性交付。简单直接,但内存开销大。

yield是流式思维:需要的时候才算,算完一个给一个。内存友好,但只能顺序消费。

我那个朋友后来把整个数据处理流程都改成了生成器管道,从读文件、解析JSON、过滤、转换到写文件,全程流式处理。50万条数据,内存占用从14GB降到了80MB。他跟我说,感觉像换了一台电脑。

生成器不会让你的代码跑得更快,但它能让你的代码在同样的内存里处理大得多的数据。当你下次遇到内存爆掉的时候,先想想:这里能不能用yield

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • return 到底做了什么
  • yield 到底做了什么
  • 用生活场景理解 return 和 yield
  • 生成器的三个核心特性
  • 生成器表达式:圆括号的威力
  • 一个实际的例子:读大文件
  • 生成器的实际应用场景
  • 生成器 vs 迭代器 vs 可迭代对象
  • 生成器的性能陷阱
  • 什么时候该用生成器
  • 总结
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档