讲个真事儿。
上个月我写一个数据处理脚本,要读一个2.8GB的日志文件,过滤出所有包含"ERROR"的行。我心想,用列表推导式多优雅啊,一行代码搞定:
arduino
lines = [line for line in open('user_logs.txt') if 'error' in line]
就这一行。优雅,简洁,Pythonic。
然后服务器内存就爆了。运维同事在群里发了一张截图,内存使用率95%,问我跑什么任务了。我盯着屏幕上那行代码,不敢相信问题出在这里。2.8GB的文件,这行代码直接给我干出了4.2GB的内存占用。服务器开始疯狂swap,磁盘IO飙到100%,整个服务都跟着抖动。
一个小时后,我把这行代码改成了这样:
arduino
lines = (line for line in open('user_logs.txt') if 'error' in line)
就换了个括号。方括号变成了圆括号。内存占用从4.2GB降到了不到50MB。
那天我彻底搞明白了一件事:列表推导式的方括号和生成器表达式的圆括号,看起来就差一个符号,实际上是天壤之别。
列表推导式到底干了什么?
先来看看列表推导式的工作原理。
当你写下这行代码时:
ini
squares = [x**2 for x in range(10000000)]
Python背后做的是这样几件事:
- 先创建一个空列表
- 开始循环,每次计算
x**2 - 把计算结果一个一个塞进列表里
- 直到循环结束
这个过程看起来没问题对不对?问题就在第3步------所有计算结果都被存进了内存。
1000万个整数,每个整数在Python里是一个对象,占用28个字节,再加上列表本身的空间------280MB就这样没了。
这就是列表推导式的"原罪":它一次性把所有结果全部生成并存储。数据量小的时候,这根本不是问题。但数据量一大,它就像一台内存抽水机,把可用内存榨得干干净净。
回到那个日志文件的例子。为什么2.8GB的文件会吃掉4.2GB内存?因为Python把每一行都拆成了独立的字符串对象,每个字符串都有额外的对象头开销(大约49个字节)。文件里的原始字节变成了Python对象,内存占用直接膨胀了好几倍。
如果用普通for循环加append,结果是一样的:
scss
lines = []
for line in open('log.txt'):
if 'ERROR' in line:
lines.append(line)
内存占用依然是4.2GB。列表推导式只是写法更简洁,底层的本质没变------都是把结果全部装进列表。
生成器表达式做了什么?
再看生成器表达式:
ini
squares = (x**2 for x in range(10000000))
这次Python没有创建列表,没有预计算任何平方值,没有往内存里塞任何东西。它只做了一件事:创建了一个生成器对象。
这个生成器对象大概只占100个字节左右的内存。不管你要处理100万个元素还是100亿个元素,它占用的内存几乎不变。
为什么?因为生成器根本不存储数据,它只存储一个"配方"------怎么计算下一个值。你问它要一个值,它就计算一个给你;你不问,它就不算。这就是所谓的 "惰性求值" 。
打个比方:
- 列表推导式像你去超市买菜,把所有菜都买回来堆在冰箱里,要用的时候直接从冰箱拿。冰箱够大还好,冰箱不够大就塞爆了。
- 生成器表达式像你楼下的菜店,做菜的时候才下楼买一棵葱,吃完再做下一道菜再下楼买。冰箱里永远只有当下要用的那一点东西。
看起来一样,用起来完全不一样
咱们来做个对比实验。
内存占用
python
import sys
big_list = [x for x in range(1000000)]
big_gen = (x for x in range(1000000))
print("列表内存:", sys.getsizeof(big_list)) # 约 8MB
print("生成器内存:", sys.getsizeof(big_gen)) # 约 100 字节
100万个元素,列表占了8MB,生成器只占了100个字节。差了8万倍。
能不能反复用
列表可以反复遍历,想遍历几次就遍历几次:
scss
lst = [x for x in range(3)]
print([x for x in lst]) # [0, 1, 2]
print([x for x in lst]) # [0, 1, 2] ------ 还能用
生成器是一次性的,遍历完就没了:
scss
gen = (x for x in range(3))
print([x for x in gen]) # [0, 1, 2]
print([x for x in gen]) # [] ------ 空了
因为生成器不存储数据,它像一巻电影胶片,只能从头播到尾,播完就没了。想再看一遍?重新创建一个生成器。
能不能用索引
列表支持索引,想取第几个就取第几个:
ini
lst = [x for x in range(10)]
print(lst[5]) # 5 ------ 随便取
生成器不支持索引:
ini
gen = (x for x in range(10))
print(gen[5]) # TypeError: 'generator' object is not subscriptable
因为生成器根本没把所有数据存下来,它只知道"下一步怎么算",不知道第5个是什么。
一个让你彻底理解的例子
来看一个经典的场景。假设你要计算1到1000万个数的平方和:
python
# 列表推导式版本
sum([x*x for x in range(10000000)])
这段代码的问题在哪?你可能以为问题出在range(10000000)。其实不是。在Python 3中,range()返回的是一个惰性的range对象,它像一个"取号机",你叫一个号它才吐一个号,本身几乎不占内存。
真正的问题出在方括号上 。[x*x for x in range(10000000)]会强制把所有平方结果全部提前计算出来,塞进一个巨大的列表里,然后再交给sum()去求和。
而改成圆括号之后:
python
sum(x*x for x in range(10000000))
sum()每需要一个值,生成器就计算一个值给它,算完就扔掉,永远不积压。内存占用从几百MB降到了几乎为零。
而且,把生成器表达式作为单参数函数调用时,可以省略外层的圆括号 。所以sum(x*x for x in range(...))是合法的,不需要写成sum((x*x for x in range(...)))。
那到底该用哪个?
记住一条简单的判断标准:
数据量小,或者需要反复使用、随机访问------用列表推导式(方括号) 。
配置列表、小数据集过滤、需要多次遍历的场景,列表推导式更合适。而且列表推导式的底层是用C实现的,执行速度通常比普通for循环快20%到30%。
数据量大,或者只需要遍历一次------用生成器表达式(圆括号) 。
处理百万级以上的数据、读取大文件、流式数据处理,生成器表达式是绝对的内存救星。
| 对比项 | 列表推导式 [] |
生成器表达式 () |
|---|---|---|
| 返回类型 | 列表 | 生成器对象 |
| 计算时机 | 立即全部计算 | 惰性计算,需要时才算 |
| 内存占用 | 与元素数量成正比 | 固定大小,约100字节 |
| 可迭代次数 | 可反复遍历 | 只能遍历一次 |
| 索引支持 | 支持 | 不支持 |
| 适用场景 | 小数据集、需反复使用 | 大数据集、流式处理 |
还有几个坑,顺便说一下
坑一:别以为圆括号是元组推导式
很多人第一次看到(x for x in range(10)),会以为这是"元组推导式"。不是的,Python没有元组推导式。圆括号括起来的这个玩意儿是生成器表达式,返回的是生成器对象,不是元组。
想生成元组?用tuple(x for x in range(10))。
坑二:生成器表达式里面的for语句,错误是延迟报的
列表推导式在定义的时候就会执行所有检查:
python
[x*y for x in range(3) for z in range(5)] # 立即报错:y没定义
生成器表达式不一样,它只在第一个for语句执行时做检查,后面的for语句要等到你真正开始迭代的时候才检查:
python
g = (x*y for x in range(3) for z in range(5)) # 不报错
next(g) # 这时候才报错:y没定义
这个特性有时候会让人困惑,明明写了语法错误,程序却没立刻报错,等跑了好一会儿才炸。
坑三:别在需要列表的地方用生成器
生成器不支持索引、不支持切片、不支持len()。如果你接下来要对数据做下标访问、多次遍历,老老实实用列表推导式。非要用生成器的话,可以用list()把它转成列表,但这样就失去了内存优势。
总结
方括号和圆括号的区别,核心就一句话:
方括号是"全部给我",圆括号是"要一个给一个" 。
方括号的列表推导式,简单直接,适合小数据。圆括号的生成器表达式,精打细算,适合大数据。
下次写代码的时候,多问自己一句:这些数据我真的需要全部存下来吗?如果答案是否定的,把方括号换成圆括号。
就这么简单。