Python的列表推导式把我写崩了,原来圆括号和方括号不是一回事

讲个真事儿。

上个月我写一个数据处理脚本,要读一个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背后做的是这样几件事:

  1. 先创建一个空列表
  2. 开始循环,每次计算x**2
  3. 把计算结果一个一个塞进列表里
  4. 直到循环结束

这个过程看起来没问题对不对?问题就在第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()把它转成列表,但这样就失去了内存优势。

总结

方括号和圆括号的区别,核心就一句话:

方括号是"全部给我",圆括号是"要一个给一个"

方括号的列表推导式,简单直接,适合小数据。圆括号的生成器表达式,精打细算,适合大数据。

下次写代码的时候,多问自己一句:这些数据我真的需要全部存下来吗?如果答案是否定的,把方括号换成圆括号。

就这么简单。

相关推荐
梦在远山后44 分钟前
Python 中字符串常用写法
人工智能·后端
geovindu1 小时前
python:HandWriting Recognition using paddlepaddle
开发语言·后端·python·paddlepaddle
步行cgn1 小时前
为何以继承方式引入SpringBoot
java·spring boot·后端
Mr_hou1 小时前
左手.NET右手Node:一个后端开发的双修之路
后端
wno7041 小时前
Spring Boot配合Hibernate Validator参数校验
spring boot·后端·hibernate
astronautyi2 小时前
Go 运行时内存分配与 GC 位图深度剖析
开发语言·后端·golang
techdashen2 小时前
Go 中如何处理错误
开发语言·后端·golang
GoGeekBaird2 小时前
Agent 时代,你的生产环境,真的敢让它裸奔吗
后端·agent
IT_陈寒2 小时前
Redis Pipeline用错竟比不用还慢,这个坑我帮你踩过了
前端·人工智能·后端