
去年公司接了一个日志分析的项目。每天产生几十GB的Nginx日志,需要统计每个IP的访问次数和状态码分布。
我当时的方案很简单:
def load_logs(file_path):
with open(file_path, 'r') as f:
lines = f.readlines()
return lines
logs = load_logs("access.log")
for line in logs:
parse_and_count(line)代码跑起来之后,服务器内存直接飙到95%。几十GB的日志文件,readlines()一次性全部读到内存里——我的16GB服务器根本扛不住。
同事跑过来看了一眼:“你没用生成器?”
我一脸茫然:“生成器是什么?”
那天下午,我把yield和return的区别彻底研究了一遍。今天把这些东西讲清楚,希望你下次遇到类似问题别像我一样慌。
先搞清楚return是怎么回事。这玩意儿几乎所有编程语言都有,大家都很熟。
def get_numbers():
result = []
for i in range(5):
result.append(i)
return result
nums = get_numbers()
print(nums) # [0, 1, 2, 3, 4]return的逻辑很简单:函数执行到return,把所有结果打包好,一次性交给你,然后函数结束。
打个比方:你去食堂打饭。return版本的食堂师傅是这样工作的——你先排队等着,师傅把100份盒饭全部做完,码整齐了,然后一次性端出来给你。你才能开始吃。
这个模式有个问题:如果要做10000份盒饭,你得等很久,而且师傅一次性端出来需要很大的台面(内存)来放。
体现在代码上就是:return必须先把所有结果存到一个中间变量里(比如上面代码里的result列表),等全部算完了再返回。数据量小的时候没问题,数据量大了——就像我那几十GB的日志——内存直接爆炸。
yield就不一样了。
def get_numbers():
for i in range(5):
yield i
nums = get_numbers()
print(next(nums)) # 0
print(next(nums)) # 1
print(next(nums)) # 2第一次调用next(nums),函数开始执行,遇到yield i就停下来,把i返回给你。第二次调用next(nums),函数从上次停下的地方继续执行,而不是从头开始。
还是食堂打饭的比喻:yield版本的师傅是这样工作的——你做一份盒饭,我吃一份。师傅不用把所有盒饭都做完才给我,我不用等。
体现在代码上:yield不需要一个中间列表来存所有结果,它每次只生成一个值,用完就扔。
回到我那个日志文件的例子,用yield改造一下:
def load_logs(file_path):
with open(file_path, 'r') as f:
for line in f: # 文件对象本身支持逐行迭代
yield line.strip() # 每次只返回一行
for line in load_logs("access.log"):
parse_and_count(line) # 逐行处理,内存里永远只有一行改完之后,内存占用从95%降到了不到5%。因为生成器不会把整个文件读到内存里,而是每次只读一行,处理完再读下一行。
return和yield最本质的区别就一句话:
return结束函数,yield暂停函数。
看这段代码:
def demo_return():
print("第1步")
return "结果1"
print("第2步") # 永远不会执行
return "结果2" # 永远不会执行
def demo_yield():
print("第1步")
yield "结果1"
print("第2步")
yield "结果2"
print("第3步")调用demo_return(),打印“第1步”,返回“结果1”,函数结束。后面的代码全都不执行。
调用demo_yield(),返回的是一个生成器对象,函数体本身不会立即执行。只有当你调用next()的时候,它才会开始跑:
gen = demo_yield()
print(next(gen)) # 打印"第1步",返回"结果1",暂停
print(next(gen)) # 打印"第2步",返回"结果2",暂停
print(next(gen)) # 打印"第3步",然后函数结束,抛出StopIterationyield把函数的执行变成了可暂停、可恢复的。每次yield都保存了当前的所有状态——局部变量的值、执行到了哪一行——下次继续的时候全部恢复。
能。但有坑。
在生成器函数里用return,它的作用是终止生成器,而不是返回一个值给调用者。
def my_generator():
yield 1
yield 2
return "结束了" # 这个值不会直接返回给调用者
yield 3 # 这行永远不会执行
gen = my_generator()
print(next(gen)) # 1
print(next(gen)) # 2
print(next(gen)) # 抛出 StopIteration: 结束了在Python 3.3及以上版本中,生成器里的return返回值会被包装进StopIteration异常里。你可以通过捕获异常来拿到这个值,但正常迭代的时候是拿不到的。
所以我的建议是:别在生成器里混用return,除非你明确知道自己在做什么。想让生成器终止,让函数自然结束就好了。
搞清楚区别之后,关键是知道什么时候用哪个。
用return的场景:
用yield的场景:
有个数据可以直观感受一下:生成100万个平方数,用列表存需要大约8MB内存,用生成器只需要大约112字节——差距是几万倍。
坑一:生成器只能迭代一次
gen = (x for x in range(5))
print(list(gen)) # [0, 1, 2, 3, 4]
print(list(gen)) # [] —— 空了!生成器像是一个单向的迭代器,用完就没了。想再用就得重新创建。
坑二:生成器不是快,是省内存
很多人以为生成器比普通函数快。实际上恰恰相反——由于每次yield都要暂停和恢复上下文,生成器通常比普通函数慢一点。
生成器的优势是内存效率,不是执行速度。在处理大数据的时候,内存效率比速度重要得多——内存爆了程序直接崩,慢一点至少还能跑完。
坑三:生成器表达式和列表推导式长得像
# 列表推导式——立即生成所有数据
squares_list = [x**2 for x in range(10)] # 方括号
# 生成器表达式——惰性求值
squares_gen = (x**2 for x in range(10)) # 圆括号两者只差一个括号,但行为天差地别。用错了内存直接翻车。
一句话记住区别:**return是“做完了一起给”,yield是“做一点给一点”**。
return:一次性返回,函数结束,占用内存大yield:逐个返回值,函数暂停,占用内存小用我那个日志分析的例子来总结:
return**:把整本《战争与和平》从头到尾抄一遍,然后把抄完的厚厚一沓纸给你yield**:你读一页,我抄一页,你读完了我也抄完了,但你手上永远只有一页纸现在我写代码但凡遇到要处理大量数据,第一反应就是:“能不能用生成器?”想清楚再动手,少让服务器崩溃几次。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。