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

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

## 引言 这是第三章「流程控制」的第 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)的地基,请务必在本篇把它的「惰性」吃透。 ## 概念与原理 ### 从命令式到声明式:推导式在消灭什么 先看一段「标准」的逐元素处理代码: ```python squares = [] for x in range(10): squares.append(x * x) ``` 四行代码里,真正有价值的只有两个信息:**要遍历什么**(range(10))和**每个元素怎么变换**(x * x)。建空列表、调用 append、循环收尾这些都属于样板。推导式把这段压缩为: ```python 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 实测对比,把「惰性」落到具体数字上。 ## 操作与实现 先跑通最基本的列表推导式,并与手写循环对照。 ```python 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) ``` 输出: ```text [0, 1, 4, 9, 16, 25, 36, 49, 64, 81] True ``` 从结果看两者完全等价,但推导式把「告诉程序做什么」压缩成了「告诉程序结果长什么样」。接下来加过滤条件:if 子句位于 for 之后,只保留满足条件的元素。 ```python 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) ``` 输出: ```text [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 后面: ```python signed = [x if x % 2 == 0 else -x for x in range(6)] print(signed) ``` 输出: ```text [0, -1, 2, -3, 4, -5] ``` 语法要点:`[产式 if 条件 else 另一个值 for ...]` 与 `[产式 for ... if 条件]` 是完全不同的两种东西——前者对每个元素都产出(二选一),后者直接丢弃不合格者。写错位置是新手最高频的错误之一,易错点部分还会展开。 ### 嵌套:多个 for 的顺序就是循环嵌套的顺序 矩阵(二维列表)的展平是嵌套推导式的经典场景。多个 for 子句**从左到右**就是普通嵌套循环**从外到内**的顺序,读法与对应 for 循环完全一致: ```python 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) ``` 输出: ```text [1, 2, 3, 4, 5, 6, 7, 8, 9] [[1, 4, 7], [2, 5, 8], [3, 6, 9]] ``` 展平可等价展开为(每个代码块都自包含,可独立运行): ```python matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]] flat = [] for row in matrix: # 外层 for v in row: # 内层 flat.append(v) print(flat) ``` 输出: ```text [1, 2, 3, 4, 5, 6, 7, 8, 9] ``` 所以 `flat` 那行读作「对 matrix 的每一行 row,再对该行的每一个元素 v,产出 v」。转置 `transposed` 则是「推导式套推导式」:外层对列号 i 循环,内层把每一行的第 i 个元素收成一列。这个例子的 for 循环版本见易错点部分——两种写法放在一起,正好用来讨论可读性。 ### 字典推导式:从列表到字典 字典推导式产出键值对,最经典的用途是**把序列变成查找表**。比如把一个单词列表映射成长度表: ```python words = ["python", "list", "dict", "set", "tuple"] lengths = {w: len(w) for w in words} print(lengths) ``` 输出: ```text {'python': 6, 'list': 4, 'dict': 4, 'set': 3, 'tuple': 5} ``` `{w: len(w) for w in words}` 读作「对每个单词 w,产出键 w、值 len(w)」。「两个平行列表合成字典」也很常见,配合 zip 使用(zip 把两个序列逐对打包,第 5 章的字符串章节会正式介绍它): ```python 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) ``` 输出: ```text {'name': '林晚', 'age': 20, 'city': '北京'} {'python': 6, 'tuple': 5} ``` `long` 一行同时演示了字典推导式的过滤:只保留长度 ≥ 5 的单词。如果只是把两个平行序列合成字典,直接 `dict(zip(keys, values))` 更短——写推导式还是写 dict() 看个人口味,结果一致。 ### 集合推导式:去重是免费的 集合推导式与列表推导式写法几乎相同,只是外层换花括号,并且**自动去重**。处理一份有重复项的原始数据时非常有用: ```python 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)) ``` 输出: ```text ['a', 'b', 'c', 'd'] ['b', 'c'] ``` 两个细节:集合是无序容器,打印顺序不保证,若要稳定展示请先 sorted()(本例排序后得到 a/b/c/d 与 b/c);第二个推导式先过滤(只留长度大于 5 的 banana、cherry)再取首字母,去重后只剩 b、c。集合推导式要求产出的元素**可哈希**(hashable),不可哈希的对象——比如列表——会直接抛 TypeError,详见易错点。 ### 生成器表达式:惰性与内存实测 生成器表达式把方括号换成圆括号,其余语法完全一样。先感受「惰性 + 一次性」这两个性质: ```python 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) ``` 输出: ```text 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 生成器」的内存差异——这是两者关系里最直观的数字: ```python 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): ```text 8448728 200 59 ``` 解读:100 万个平方数的列表,仅指针数组就占约 8.4 MB(每个 int 对象本身还要 28 字节起,总量在 36 MB 量级);生成器对象固定 200 字节,与元素数量无关——它只存「配方」(数据源 range、产式 x * x、当前位置),元素是一个个现算的。最后一行演示「只计数不存数」的经典配方:统计 0~999 中 17 的倍数有多少个,结果为 59,全程没有产生任何列表。这就是「场景 + 量级」意义上的惰性优势:**当中间结果只需要被消费一次、且量很大时,生成器省的是 O(n) 的中间内存**。 ### 工程场景:日志解析 把前面几样合起来看一个真实场景:解析一份服务器日志(时间、级别、消息三块),提取所有 ERROR 消息,再统计出现过哪些级别。 ```python 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})) ``` 输出: ```text ['connection refused', 'db timeout', 'queue overflow'] ['ERROR', 'INFO', 'ERROR', 'WARN', 'ERROR'] ['ERROR', 'INFO', 'WARN'] ``` 解析思路:`split(" ", 3)` 按单个空格最多切三刀,得到 [时间, 级别, 剩余部分],[3] 即完整消息;`split()[2]` 取级别列。第一行同时用了过滤(if 级别是 ERROR)与变换(取消息),第二行用列表推导式取级别,第三行用集合推导式去重得到「出现过的级别」。一个提醒:这里每行被 split 了两次,对几万行日志完全够用;追求严谨可以在循环里先拆一次再复用——这是工程上「可读性优先」的取舍,不展开。 ### 可读性边界:什么时候退回 for 循环 推导式不是越短越好。业界普遍认可的边界大致是:**嵌套超过两层、或产式/条件复杂到需要注释才能读懂、或需要在循环里调试打点**——满足其一,就退回普通 for 循环。对比下面两种写法(选出预算内且评分达标的酒店,返回(城市, 酒店名)列表): ```python 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) ``` 输出: ```text [('北京', '松风客栈')] 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)}` 运行即报错: ```python 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' ``` 输出: ```text 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 节嵌套例子)改写为推导式版,并解释两种写法各自的适用场景。