Python 列表推导式和生成器表达式有什么区别?
简化版
列表推导式用 [],会立即生成完整列表;生成器表达式用 (),不会一次性生成所有结果,而是按需惰性产出。数据量小、需要多次遍历时用列表推导式;数据量大、只迭代一次时生成器表达式更省内存。
详细版
列表推导式:
squares = [x * x for x in range(5)]
print(squares) # [0, 1, 4, 9, 16]
生成器表达式:
squares = (x * x for x in range(5))
print(squares) # <generator object ...>
print(list(squares)) # [0, 1, 4, 9, 16]
区别:
| 对比项 | 列表推导式 | 生成器表达式 |
|---|---|---|
| 语法 | [...] | (...) |
| 计算方式 | 立即计算 | 惰性计算 |
| 返回值 | list | generator |
| 内存 | 一次性保存所有结果 | 按需产生,更省内存 |
| 是否可重复遍历 | 可以 | 通常只能消费一次 |
例子:
total = sum(x * x for x in range(10_000_000))
这里用生成器表达式,不需要先创建一个巨大列表。
完整版教学
一、先抓住本质:一个造结果,一个造流水线
列表推导式的目标是“现在就把结果列表造出来”,所以表达式执行结束后,内存里已经有了完整的 list。生成器表达式的目标是“记住怎么算,下次要值时再算”,所以它返回的是一个 generator 迭代器,里面保存了当前位置和计算逻辑。这个差异会直接影响内存占用、是否能重复遍历、异常出现时机和调试方式。
nums = range(5)
lst = [x * x for x in nums]
gen = (x * x for x in nums)
print(lst) # [0, 1, 4, 9, 16]
print(next(gen)) # 0
print(next(gen)) # 1
可以把列表推导式想成一次性把 5 个盒子摆在桌面上;生成器表达式则像一个水龙头,每拧一次才流出一个值。假设每个元素最终约占 28 字节,100 万个整数光元素对象就可能是几十 MB 级别,还没算列表指针数组;而生成器本身只保存迭代状态,峰值内存通常小得多。
二、列表推导式为什么适合构造可复用结果
result = []
for x in range(5):
if x % 2 == 0:
result.append(x * x)
列表推导式:
result = [x * x for x in range(5) if x % 2 == 0]
它简洁、表达力强,适合把一个可迭代对象转换成列表。这里的重点不是“写得短”,而是结果确实需要作为列表继续使用:比如要取长度、按下标访问、排序、多次遍历、传给只接受列表的旧接口。因为结果已经在内存中,len(result) 是 O(1),result[2] 可以直接下标访问。
names = ["tom", "jerry", "spike"]
upper_names = [name.upper() for name in names]
print(len(upper_names)) # 3
print(upper_names[1]) # JERRY
但不要为了“显得 Pythonic”把推导式写成谜语。如果出现两层以上嵌套、复杂条件、或者中间还要记录日志,普通 for 循环往往更清楚。面试里说“列表推导式可读性更好”要加前提:逻辑简单、目标是构造列表。
三、生成器表达式为什么适合流式处理
生成器表达式不会立刻计算全部元素:
gen = (x * x for x in range(3))
print(next(gen)) # 0
print(next(gen)) # 1
print(next(gen)) # 4
它像一条流水线,需要一个值时才算一个值。sum(x * x for x in range(10_000_000)) 不会先创建一个包含 1000 万个平方数的列表,而是每次取一个 x * x 加到累计值里。若写成 sum([x * x for x in range(10_000_000)]),中间列表会先占一大块内存,然后再被 sum 消费,内存峰值明显更高。
| 场景 | 更适合 | 原因 |
|---|---|---|
| 只求总和、最大值、是否存在 | 生成器表达式 | 消费一次即可,不必保存全部 |
| 结果要排序、切片、复用 | 列表推导式 | 需要完整序列 |
| 逐行处理大日志 | 生成器表达式 | 文件流式读取,降低峰值内存 |
| 小列表转换并展示 | 列表推导式 | 简洁且成本可控 |
例如:
with open("access.log", encoding="utf-8") as f:
error_lines = (line for line in f if "ERROR" in line)
for line in error_lines:
print(line)
这里不会一次性把所有错误日志读入内存。
四、生成器为什么通常只能消费一次
生成器是有状态的迭代器,被遍历后就前进了。
gen = (x for x in range(3))
print(list(gen)) # [0, 1, 2]
print(list(gen)) # []
第二次为空,因为第一次已经消费完。生成器内部有一个“当前位置”,每次 next() 都向前走一步,走到尽头后抛出 StopIteration,后续遍历就没有元素了。这个特性很适合流式处理,但如果你打算重复使用结果,就应该提前转成列表。
流程可以这样理解:
创建 gen -> next 得到 0 -> next 得到 1 -> next 得到 2 -> StopIteration
↑ ↑ ↑
状态前进 状态前进 状态前进到末尾
如果确实要多次遍历,可以写 nums = list(gen) 把结果存下来,或者重新创建一个新的生成器表达式。不要把同一个生成器传给两个消费方,否则第一个消费方可能已经把数据“吃完”。
五、语法细节和异常时机也不同
当生成器表达式作为函数唯一参数时,外层括号可以省略:
sum((x * x for x in range(10)))
sum(x * x for x in range(10))
两者等价,第二种更常见。
但如果还有其他参数,生成器表达式自己的括号不能省:
print(*(x for x in range(3)), sep=",")
还有一个细节:列表推导式会立即执行内部表达式,所以异常会在创建列表时出现;生成器表达式通常在真正迭代时才执行内部表达式。这个差异会影响调试和资源释放。
lst = [10 // x for x in [2, 1, 0]] # 这里立刻 ZeroDivisionError
gen = (10 // x for x in [2, 1, 0]) # 创建 gen 时还不报错
next(gen) # 5
next(gen) # 10
next(gen) # 这里才 ZeroDivisionError
易错点:生成器表达式“创建成功”不代表里面的逻辑已经跑过;错误、数据库访问、文件读取等副作用,往往会推迟到消费阶段才发生。
六、推导式不要滥用副作用
不推荐这样写:
[print(x) for x in range(3)]
列表推导式的目的是构造列表,而不是为了执行副作用。上面会创建一个 [None, None, None] 的临时列表,语义不清楚。需要打印时直接用循环:
for x in range(3):
print(x)
如果推导式只是为了副作用,还会制造无意义的中间结果。比如 [print(x) for x in range(3)] 实际得到 [None, None, None],只是没人用这个列表。面试时可以说:推导式适合表达“映射 + 过滤 + 构造结果”,副作用明显的逻辑用普通循环,更利于维护。
七、常见误区与追问
- 误区:生成器表达式一定比列表推导式快。 它主要省内存,速度要看场景;小数据量下列表推导式可能因为连续内存和一次性执行更直接,反而不慢。
- 误区:圆括号表达式都是生成器。
(x)只是普通表达式,(x for x in data)才是生成器表达式;单元素元组还要写(x,)。 - 误区:生成器可以像列表一样随便取下标。 generator 没有随机访问能力,想取第 100 个元素只能迭代过去,或者先转成列表。
- 追问:为什么
sum(x*x for x in nums)外层括号能省? 因为生成器表达式是函数唯一实参时,语法允许省略它自己的括号;有其他参数时不能省。 - 追问:生成器表达式会捕获变量吗? 会按闭包和迭代规则保存计算所需环境,循环变量在表达式自己的局部作用域中,不会像 Python 2 的列表推导式那样泄漏到外层。
- 追问:列表推导式能替代
map/filter吗? 很多场景可以,而且可读性更好;但如果已有现成函数且要惰性流水线,map、filter或生成器表达式也合适。
八、加强记忆
把这题记成“方括号是成品仓库,圆括号是生产流水线”:列表推导式一次性生产完整列表,适合复用、下标访问和排序;生成器表达式只保存生产规则和当前位置,适合大数据、流式处理和只消费一次。回答时再补上两个边界:生成器通常不能重复遍历,异常和副作用可能推迟到消费阶段发生。