AI 技术博客
返回首页
Python 基础 23 分钟阅读

推导式:列表、字典、集合与生成器表达式

Python推导式生成器惰性求值可读性

引言

这是第三章「流程控制」的第 7 篇。前面六篇已经把分支(if/elif/else、match)与循环(while、for/range、break/continue)全部讲完,你已经能用命令式写法解决几乎所有「逐元素处理」的问题。但你会发现一个规律:这类代码里有一半是样板——先建一个空列表,再写 for 循环,循环体里 append,循环结束后才能看到结果。本篇要消灭的就是这部分样板。

推导式(comprehension)是 Python 提供的一套「循环 + 收集」的表达式写法:把「建容器 → 遍历 → 变换 → 过滤 → 收集」压缩成一行。它取材于数学里的集合构造记号(set-builder notation),在真实工程中出现的频率极高——日志解析、配置转换、数据清洗,处处都是它的身影。本篇先讲清为什么要有推导式(原理),再逐一实现列表、字典、集合三类推导式与生成器表达式(实现),最后讨论一个关键问题:什么时候应该退回去写普通 for 循环(可读性边界)。

阅读前提:for 循环与 range(第 4 篇)、if 的真值测试与过滤(第 1、2 篇)。顺带说明,本篇会用到条件表达式 x if 条件 else y,它相当于把 if/else 压进一个表达式,此处第一次正式使用,下文会现场解释。本篇的四个主题在后续课程里会反复出现,尤其是生成器表达式——它是数据管道(课程 02、06)的地基,请务必在本篇把它的「惰性」吃透。

概念与原理

从命令式到声明式:推导式在消灭什么

先看一段「标准」的逐元素处理代码:

squares = []
for x in range(10):
    squares.append(x * x)

四行代码里,真正有价值的只有两个信息:要遍历什么(range(10))和每个元素怎么变换(x * x)。建空列表、调用 append、循环收尾这些都属于样板。推导式把这段压缩为:

squares = [x * x for x in range(10)]

这种写法是「声明式」(declarative)的:你描述的是一份结果的配方,而不是一步步的操作步骤。它和数学集合构造记号 {x² | x ∈ S} 一一对应——方括号里开头是「产出表达式」x * x,接着是 for 子句说明从哪个数据源取变量,可选 if 子句描述过滤条件。语法骨架为:

[产式 for 变量 in 数据源 if 条件]

其中 for 子句可以有多个(对应嵌套循环),if 子句可以有多个(多个过滤条件依次叠放)。这个骨架理解透了,四种推导式就只剩「换括号」的区别。

四种括号,四种结果类型

推导式的语法骨架相同,决定产物类型的只是外层括号:

  • 方括号 [产式 for ...]:产生列表(list),立刻算完所有元素;
  • 单值花括号 {产式 for ...}:产生集合(set),自动去重;
  • 键值对花括号 {键: 值 for ...}:产生字典(dict);
  • 圆括号 (产式 for ...):产生生成器对象(generator),注意它不是「元组推导式」——元组根本不存在推导式语法,圆括号包裹的是一个惰性对象,下文单独讲。

这套语法是分年代长出来的:列表推导式来自 PEP 202(2000 年,随 Python 2.0 发布);生成器表达式来自 PEP 289(2003 年,随 Python 2.4 发布);字典推导式来自 PEP 274(随 Python 2.7 / 3.0 发布);集合推导式与集合字面量 {1, 2, 3} 同期进入 Python 2.7 / 3.0。理解历史有助于记住一个语法细节:{} 是空字典,空集合必须写 set()——因为空花括号的语义在集合语法出现前就被字典占用了。

实现机制与性能:不只是语法糖

推导式不是一个「展开成 for 循环」的语法糖这么简单。在 CPython 3.11 及更早版本里,每个推导式都会被编译成一个隐藏的嵌套函数对象(调试器里能看到 <listcomp> 之类的名字),循环实际跑在另一个函数帧里。Python 3.12 的 PEP 709 改变了这一点:顶层(非嵌套在另一个推导式里的)推导式被直接内联编译进当前函数帧,不再创建隐藏函数——性能上更接近手写循环。用反汇编可以验证:3.12 里推导式循环的字节码老老实实出现在当前函数的指令序列中(用 LOAD_FAST_AND_CLEAR / STORE_FAST 保存并恢复循环变量的旧值,从而保持「循环变量不泄漏」的语义)。

性能上,推导式 vs 手写 append 循环是同一数量级的。本机 CPython 3.12.10 实测,对 100 万个元素求平方、重复 3 轮取平均:for 循环约 0.29 秒,推导式约 0.27 秒,差距约 6%——在「简单元素计算」场景下两者没有质变。因此选择推导式的主要理由是可读性与表达力,而不是性能;遇到复杂到读不懂的推导式,换成循环写法性能也几乎无损。

惰性求值:列表 vs 生成器

列表推导式会立刻把结果全部算出来放在内存里;生成器表达式不同,它创建的对象本身不持有任何结果元素,而是记录了一份「配方」,等你逐个索取时才现场计算下一个元素——这种「需要时才计算」的机制称为惰性求值(lazy evaluation)。两个直接后果:

  • 内存:列表占用的内存随元素数量线性增长,生成器的内存开销则与元素数量无关,恒定为一个固定大小;
  • 一次性:生成器是「单向的水管」,迭代完就干了,不能回头再取一遍。

这两个性质合起来,使生成器表达式特别适合「算一下、消费掉」的场景——比如把结果喂给 sum、max 这类聚合函数,或作为管道的中间环节(课程 02 的数据流设计会大量使用)。下面用 sys.getsizeof 实测对比,把「惰性」落到具体数字上。

操作与实现

先跑通最基本的列表推导式,并与手写循环对照。

squares = [x * x for x in range(10)]
print(squares)

squares_loop = []
for x in range(10):
    squares_loop.append(x * x)
print(squares == squares_loop)

输出:

[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
True

从结果看两者完全等价,但推导式把「告诉程序做什么」压缩成了「告诉程序结果长什么样」。接下来加过滤条件:if 子句位于 for 之后,只保留满足条件的元素。

even_squares = [x * x for x in range(10) if x % 2 == 0]
odd_squares = [x * x for x in range(10) if x % 2 == 1]
print(even_squares)
print(odd_squares)

输出:

[0, 4, 16, 36, 64]
[1, 9, 25, 49, 81]

注意这里 x % 2 的值要么 0 要么 1,直接写 if x % 2 也能过滤出奇数——因为真值测试里 0 为假、1 为真(第 2 章布尔篇讲过)。但显式写 == 0 / == 1 语义更清楚,推荐保持显式。

过滤还有一种变体:如果「不符合条件」时你要的是另一个值而不是「跳过」,就把 if/else 写进产出表达式本身。此时用的是条件表达式(ternary),位置在产式里,不在 for 后面:

signed = [x if x % 2 == 0 else -x for x in range(6)]
print(signed)

输出:

[0, -1, 2, -3, 4, -5]

语法要点:[产式 if 条件 else 另一个值 for ...] 与 [产式 for ... if 条件] 是完全不同的两种东西——前者对每个元素都产出(二选一),后者直接丢弃不合格者。写错位置是新手最高频的错误之一,易错点部分还会展开。

嵌套:多个 for 的顺序就是循环嵌套的顺序

矩阵(二维列表)的展平是嵌套推导式的经典场景。多个 for 子句从左到右就是普通嵌套循环从外到内的顺序,读法与对应 for 循环完全一致:

matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]

flat = [v for row in matrix for v in row]
print(flat)

transposed = [[row[i] for row in matrix] for i in range(3)]
print(transposed)

输出:

[1, 2, 3, 4, 5, 6, 7, 8, 9]
[[1, 4, 7], [2, 5, 8], [3, 6, 9]]

展平可等价展开为(每个代码块都自包含,可独立运行):

matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
flat = []
for row in matrix:        # 外层
    for v in row:         # 内层
        flat.append(v)
print(flat)

输出:

[1, 2, 3, 4, 5, 6, 7, 8, 9]

所以 flat 那行读作「对 matrix 的每一行 row,再对该行的每一个元素 v,产出 v」。转置 transposed 则是「推导式套推导式」:外层对列号 i 循环,内层把每一行的第 i 个元素收成一列。这个例子的 for 循环版本见易错点部分——两种写法放在一起,正好用来讨论可读性。

字典推导式:从列表到字典

字典推导式产出键值对,最经典的用途是把序列变成查找表。比如把一个单词列表映射成长度表:

words = ["python", "list", "dict", "set", "tuple"]
lengths = {w: len(w) for w in words}
print(lengths)

输出:

{'python': 6, 'list': 4, 'dict': 4, 'set': 3, 'tuple': 5}

{w: len(w) for w in words} 读作「对每个单词 w,产出键 w、值 len(w)」。「两个平行列表合成字典」也很常见,配合 zip 使用(zip 把两个序列逐对打包,第 5 章的字符串章节会正式介绍它):

words = ["python", "list", "dict", "set", "tuple"]
keys = ["name", "age", "city"]
values = ["林晚", 20, "北京"]
info = {k: v for k, v in zip(keys, values)}
print(info)

long = {w: len(w) for w in words if len(w) >= 5}
print(long)

输出:

{'name': '林晚', 'age': 20, 'city': '北京'}
{'python': 6, 'tuple': 5}

long 一行同时演示了字典推导式的过滤:只保留长度 ≥ 5 的单词。如果只是把两个平行序列合成字典,直接 dict(zip(keys, values)) 更短——写推导式还是写 dict() 看个人口味,结果一致。

集合推导式:去重是免费的

集合推导式与列表推导式写法几乎相同,只是外层换花括号,并且自动去重。处理一份有重复项的原始数据时非常有用:

words2 = ["apple", "banana", "cherry", "apple", "date", "cherry"]
initials = {w[0] for w in words2}
print(sorted(initials))

long_initials = {w[0] for w in words2 if len(w) > 5}
print(sorted(long_initials))

输出:

['a', 'b', 'c', 'd']
['b', 'c']

两个细节:集合是无序容器,打印顺序不保证,若要稳定展示请先 sorted()(本例排序后得到 a/b/c/d 与 b/c);第二个推导式先过滤(只留长度大于 5 的 banana、cherry)再取首字母,去重后只剩 b、c。集合推导式要求产出的元素可哈希(hashable),不可哈希的对象——比如列表——会直接抛 TypeError,详见易错点。

生成器表达式:惰性与内存实测

生成器表达式把方括号换成圆括号,其余语法完全一样。先感受「惰性 + 一次性」这两个性质:

gen = (x * x for x in range(5))
print(type(gen).__name__)
print(next(gen))          # 逐元素索取第一个
print(list(gen))          # 把剩下的全部取出来
print(list(gen))          # 已经空了,第二次取不到任何东西

total = sum(x * x for x in range(5))
print(total)

输出:

generator
0
[1, 4, 9, 16]
[]
30

要点:next(gen) 索取一个元素;生成器被 list() 消费完后,再 list() 一次得到空列表——它不可回头。sum(x * x for x in range(5)) 是生成器表达式最常见的用法:直接作为函数参数时可以省略外层圆括号,sum 边取边累加,全程没有产生中间列表。第 2 章布尔篇统计及格人数用的 sum(s >= 60 for s in scores) 正是同一个套路。

现在用 sys.getsizeof 实测「列表 vs 生成器」的内存差异——这是两者关系里最直观的数字:

import sys

big_list = [x * x for x in range(1_000_000)]
big_gen = (x * x for x in range(1_000_000))

print(sys.getsizeof(big_list))   # 列表对象本身的字节数
print(sys.getsizeof(big_gen))    # 生成器对象的字节数

print(sum(1 for x in range(1000) if x % 17 == 0))

输出(本机 CPython 3.12.10):

8448728
200
59

解读:100 万个平方数的列表,仅指针数组就占约 8.4 MB(每个 int 对象本身还要 28 字节起,总量在 36 MB 量级);生成器对象固定 200 字节,与元素数量无关——它只存「配方」(数据源 range、产式 x * x、当前位置),元素是一个个现算的。最后一行演示「只计数不存数」的经典配方:统计 0~999 中 17 的倍数有多少个,结果为 59,全程没有产生任何列表。这就是「场景 + 量级」意义上的惰性优势:当中间结果只需要被消费一次、且量很大时,生成器省的是 O(n) 的中间内存。

工程场景:日志解析

把前面几样合起来看一个真实场景:解析一份服务器日志(时间、级别、消息三块),提取所有 ERROR 消息,再统计出现过哪些级别。

logs = [
    "2026-08-31 10:00:01 ERROR connection refused",
    "2026-08-31 10:00:05 INFO user login",
    "2026-08-31 10:00:07 ERROR db timeout",
    "2026-08-31 10:00:09 WARN disk usage 92%",
    "2026-08-31 10:00:11 ERROR queue overflow",
]

errors = [line.split(" ", 3)[3] for line in logs if line.split()[2] == "ERROR"]
print(errors)

levels = [line.split()[2] for line in logs]
print(levels)
print(sorted({lv for lv in levels}))

输出:

['connection refused', 'db timeout', 'queue overflow']
['ERROR', 'INFO', 'ERROR', 'WARN', 'ERROR']
['ERROR', 'INFO', 'WARN']

解析思路:split(" ", 3) 按单个空格最多切三刀,得到 [时间, 级别, 剩余部分],[3] 即完整消息;split()[2] 取级别列。第一行同时用了过滤(if 级别是 ERROR)与变换(取消息),第二行用列表推导式取级别,第三行用集合推导式去重得到「出现过的级别」。一个提醒:这里每行被 split 了两次,对几万行日志完全够用;追求严谨可以在循环里先拆一次再复用——这是工程上「可读性优先」的取舍,不展开。

可读性边界:什么时候退回 for 循环

推导式不是越短越好。业界普遍认可的边界大致是:嵌套超过两层、或产式/条件复杂到需要注释才能读懂、或需要在循环里调试打点——满足其一,就退回普通 for 循环。对比下面两种写法(选出预算内且评分达标的酒店,返回(城市, 酒店名)列表):

data = [
    {"city": "北京", "hotels": [
        {"name": "松风客栈", "price": 380, "rating": 4.5},
        {"name": "云台宾馆", "price": 520, "rating": 4.8},
    ]},
    {"city": "上海", "hotels": [
        {"name": "外滩驿站", "price": 600, "rating": 4.2},
    ]},
]
budget = 500

candidates = [
    (c["city"], h["name"])
    for c in data
    for h in c["hotels"]
    if h["price"] <= budget and h["rating"] >= 4.5
]
print(candidates)

candidates_loop = []
for c in data:
    for h in c["hotels"]:
        if h["price"] <= budget and h["rating"] >= 4.5:
            candidates_loop.append((c["city"], h["name"]))
print(candidates_loop == candidates)

输出:

[('北京', '松风客栈')]
True

两条守则:其一,推导式可以换行——把每个 for/if 子句各占一行(PEP 8 允许),可读性立刻上一个台阶,上面 candidates 就是这么写的;其二,循环版本里可以随便打印中间量、打断点、做多步处理,推导式里做不到(推导式内部不能 print 调试,除非用副作用函数这种邪道)。所以判断标准不是「能不能写成推导式」,而是「写成推导式后还容不容易读、容不容易改」。上面这个例子两层嵌套带一个复合条件,两种写法都可接受;三层以上的嵌套、或者产式一眼看不懂的场景,直接选 for 循环。

易错点与陷阱

陷阱一:嵌套 for 的顺序写反

[v for row in matrix for v in row] 的顺序是「先外层后内层」,与展开的 for 循环一致。新手常见的错误是把内层变量放在外层 for 前面,比如 [v for v in row for row in matrix]——执行时 row 尚未定义,直接 NameError;就算碰巧不报错,产出顺序也会错乱。记口诀:写出来的顺序就是缩进嵌套的顺序,不确定时先写展开的 for 循环版再浓缩。

陷阱二:过滤 if 与三目表达式的位置搞混

[x if cond else y for ...] 与 [... for ... if cond] 一字之差、语义天差地别:前者对每个元素都产出(条件成立给 x,否则给 y),后者只产出满足条件的元素(不满足的直接跳过)。判断方法:看 if 跟在谁后面。if 跟在产式后面、且产式里成对出现 else,是三目;if 跟在 for 后面,是过滤。还有第三个变体经常被拿来面试:[x if cond else y for ... if cond2]——三目和过滤可以同时存在,逐段读即可。

陷阱三:生成器是一次性的,且恒为真

生成器只能迭代一遍,迭代完就空了;可它本身「恒为真」——空生成器的布尔值也是 True,因为真值测试(第 2 章)的对象是生成器对象本身,而不是它将来会产的元素的多少。于是 if gen: 想判断「生成器有没有元素」永远成立,这是经典的隐蔽 bug;想要判断内容,得老老实实消费它(比如 list 出来看)。另外,生成器被 sum、any、in 等操作消费时同样是一次性的,用完即空。

陷阱四:集合推导式要求元素可哈希

集合和字典键都要求元素可哈希。列表不可哈希,所以 {[1, 2] for _ in range(1)} 运行即报错:

empty = (x for x in range(0))
print(bool(empty))          # True:空生成器也是真

try:
    {[1, 2] for _ in range(1)}
except TypeError as e:
    print("TypeError:", e)  # unhashable type: 'list'

输出:

True
TypeError: unhashable type: 'list'

同理,字典推导式的键也不能是列表。想要「以列表为元素的集合」,改用元组(可哈希)或者把列表转成字符串/frozenset 再放进去。

陷阱五:推导式里不能 break / continue

break 和 continue 是循环语句的权限,推导式内部是表达式语境,写 break 或 continue 直接 SyntaxError(‘break’ outside loop)。也就是说:凡是想「遇到某个元素提前结束」的逻辑,推导式表达不了,必须退回 for 循环——这也是上一节可读性边界的一条硬规则。另外提醒一点,推导式的循环变量不泄漏(Python 3 全系如此,3.12 内联后依旧):[i for i in range(3)] 执行完,外层作用域里没有 i 这个名字,也不可能顺带修改同名全局变量的值,放心用。

小结

推导式把「建容器 + 遍历 + 变换 + 过滤 + 收集」压缩成一行表达式,是 Python 从命令式走向声明式的第一步:方括号得列表、花括号得集合或字典、圆括号得惰性生成器,语法骨架都是 [产式 for 变量 in 数据源 if 条件]。集合与字典推导式免费获得去重与去键冲突之外的查表能力;生成器表达式惰性求值、内存恒定、只能消费一次,适合「取一个用一个是好的、量大只过一次」的场景,实测 100 万元素列表占 8.4 MB 指针数组、生成器恒为 200 字节。嵌套推导式的 for 顺序与手写循环完全一致,过滤 if 与三目表达式位置不同语义不同。当嵌套超过两层、表达式读不懂或需要调试时,退回普通 for 循环是专业选择而非退步。下一篇进入实战:用 FizzBuzz 与猜数字游戏把分支、循环、推导式组合成完整程序。

练习与思考题

  1. 不运行代码,写出以下三个推导式的结果,再运行验证:[x for x in range(20) if x % 3 == 0 and x % 4 == 0]、{x % 5 for x in range(12)}、{x: x ** 2 for x in range(1, 6)}。
  2. 列表 nums = [3, -1, 4, -2, 0, 5],用一条推导式把负数替换为 0、正数保留(提示:三目表达式),再想一想:如果想「跳过负数」,写法有什么不同?
  3. 用生成器表达式统计字符串 s = "abracadabra" 中元音字母(a/e/i/o/u)的个数,要求不创建任何中间列表。
  4. 思考题:sum(x * x for x in range(10)) 和 sum([x * x for x in range(10)]) 结果相同,但内存行为有什么本质区别?在 n 很大的场景下(比如 range(10**8)),哪种写法会卡死内存?
  5. 挑战题:把「转置」的 for 循环版(见陷阱一的说明与第 2 节嵌套例子)改写为推导式版,并解释两种写法各自的适用场景。