列表推导式(list comprehension) 按顺序遍历可迭代对象,对通过筛选的元素求值,并立即生成一个新列表。
末尾的 if 负责丢弃元素,开头的 X if condition else Y 负责选择结果;混淆两者会造成语法错误或错误数据。
子句较少、每个输入最多产生一个结果时使用推导式;需要副作用、复杂分支或逐步调试时改用普通循环。
是什么,为什么存在
列表推导式是一种列表显示形式。
它把「遍历输入、可选筛选、计算输出、追加到新列表」写成一个表达式,基本形式是 [expression for item in iterable if condition]。
无论输入是不是列表,结果都是一个新的列表对象。
这里的输入只需是 可迭代对象(iterable) ,例如列表、元组、range、字典视图或生成器。
每个通过条件的元素都会让开头的表达式求值一次,结果按遍历顺序放入列表。
没有通过条件的元素不会进入结果,也不会求值开头的表达式。
它解决的是简单映射和筛选代码过于分散的问题。
普通循环需要先创建空列表,再写 for、条件和 append();推导式把同一条数据规则放在一处。
阅读时仍应能快速回答两个问题:输入来自哪里,什么值会进入结果。
你会在数据清洗、API 响应整形、测试夹具和参数组合中遇到它。 只要任务自然地表达为「从每个输入得到零个或一个输出」,推导式通常很合适。 如果一次输入可能追加多个不同结果,或循环体还要更新外部状态,普通循环往往更直白。
列表推导式只负责构造列表。 字典推导式、集合推导式和生成器表达式沿用相似的子句结构,但它们的结果类型、重复值处理和求值时机不同。 把这些形式都叫作「列表推导式」会掩盖实际行为差异。
工作原理
最左侧是结果表达式,之后至少有一个 for 子句,还可以跟零个或多个 for 或 if 子句。
执行顺序并不是从最左侧表达式开始,而是按照 for 和 if 从左到右展开。
只有执行路径到达最内层时,结果表达式才会求值并追加一次。
下面两段逻辑等价。推导式中的子句顺序,与把它展开成语句后从外层到内层的顺序相同:
[transform(item) for item in source if keep(item)]
对应的循环先遍历,再筛选,最后转换并追加:
for item in source → if keep(item) → result.append(transform(item))
这个顺序会影响正确性。 筛选条件可以阻止结果表达式运行,所以危险或昂贵的转换可以只对有效输入执行。 反过来,如果筛选和结果表达式都调用同一个函数,该函数会对保留的元素执行两次;它还可能有状态,从而让两次结果不一致。
多个 for 子句等价于嵌套循环。
[pair for left in lefts for right in rights] 先固定一个 left,再完整遍历 rights,随后才进入下一个 left。
后面的子句可以使用前面子句绑定的名称,调换顺序则可能改变结果顺序,甚至让名称尚未定义。
推导式末尾的 if 是过滤子句,没有 else。条件表达式放在结果位置,形式是 when_true if condition else when_false,因此可以让每个输入产生不同形态的输出。这两种 if 可以同时出现:先由末尾条件决定是否保留,再由开头条件决定保留后产生什么。
列表推导式会立即消费输入并构造完整结果。它适合之后需要索引、重复遍历或修改的结果。如果数据只会交给 sum()、any()、all() 等消费一次,生成器表达式通常能避免先构造中间列表。
从 for 子句向外阅读
面对多行推导式时,不要按视觉顺序从结果表达式猜执行过程。先找到第一个 for,再把后续子句按出现顺序缩进展开,最后把最左侧表达式看成最内层的 append() 参数。这个方法同时适用于列表、集合和字典推导式。
可以按下面的顺序检查一段推导式:
- 找到最左侧
for后的输入,确认它能提供哪些值。 - 记录迭代目标的解包形状,例如
for key, value in mapping.items()。 - 从左到右应用后续
for和if,确认每个名称已经绑定。 - 计算结果表达式,并确认它对每条到达此处的路径只运行一次。
- 根据括号判断结果容器,再检查调用方是否依赖顺序、重复值或重复消费。
在基本形式中,各部分的职责固定:
| 部分 | 示例 | 职责 |
|---|---|---|
| 结果表达式 | normalize(item) | 创建要追加的值 |
| 迭代子句 | for item in source | 依次绑定输入值 |
| 过滤子句 | if item.active | 决定本轮是否到达结果表达式 |
| 条件表达式 | x if condition else y | 为已保留输入选择结果 |
语法虽然允许多个连续的 if,但它们只是逐层筛选。[x for x in values if ready(x) if valid(x)] 与用 and 连接两个条件的保留规则相同,并按从左到右短路。若第二个条件依赖第一个条件已经排除某类值,拆成两个子句有时更容易看清这个前提。
用结果基数选择结构
一个输入恰好产生一个输出时,只需要转换表达式和一个 for。一个输入产生零个或一个输出时,再添加过滤子句。一个输入需要产生多个输出时,可以增加后续 for,但只有当嵌套关系本身简单而稳定时才值得压缩。
如果循环需要在不同分支追加不同数量的结果,列表推导式会变得别扭。分组、累计、提前退出和逐项容错都需要语句级控制流,这些任务不应硬塞进一个表达式。判断标准不是最终能否写成推导式,而是展开后是否仍是一条清楚的数据路径。
示例
下面四个示例逐步加入筛选、条件表达式、多个迭代子句和赋值表达式。每段程序都可以独立运行,输出来自本地 python3 执行结果。
转换并筛选
付款状态是筛选规则,收据文字是转换规则。未付款订单不会执行格式化表达式,也不会出现在新列表中。
orders = [
{"id": "A100", "total": 82.5, "paid": True},
{"id": "A101", "total": 19.0, "paid": False},
{"id": "A102", "total": 120.0, "paid": True},
]
receipts = [
f'{order["id"]}: ${order["total"]:.2f}'
for order in orders
if order["paid"]
]
print(receipts)['A100: $82.50', 'A102: $120.00']这段推导式按原订单顺序产生结果。它不会修改 orders,但结果中的元素不一定总与输入对象隔离;这里创建了新字符串,所以没有共享可变对象的问题。
把它展开成循环时,for order in orders 位于外层,if order["paid"] 位于内层,格式化和追加最后执行。遇到更复杂的推导式时,按这个方法反向展开,通常能迅速看清执行顺序。
条件表达式与过滤子句
这个例子先过滤 None 和负数,再对不低于 20 的价格打九折。开头的 if ... else ... 会为每个保留的价格选择一个结果,末尾的 if 则决定价格是否保留。
prices = [12, 0, None, 27, -3, 40]
normalized = [
round(price * 0.9, 2) if price >= 20 else float(price)
for price in prices
if price is not None and price >= 0
]
print(normalized)[12.0, 0.0, 24.3, 36.0]None 和 -3 被过滤,因此不会参与数值比较或转换。0 不能写成简单的 if price,因为真值筛选会同时丢掉有效的零值;这里明确检查缺失值和允许范围。
条件表达式总要有 else。若需求只是保留高价项,应写 [price for price in prices if price is not None and price >= 20];若每个有效价格都要保留,只是转换不同,条件表达式才是合适位置。
多个 for 子句
多个子句适合展平一层嵌套数据。这里先遍历部门,再遍历当前部门的成员,最后筛选启用成员;第二个 for 可以使用第一个 for 绑定的 department。
departments = [
{
"name": "sales",
"members": [
{"email": "[email protected]", "active": True},
{"email": "[email protected]", "active": False},
],
},
{
"name": "support",
"members": [
{"email": "[email protected]", "active": True},
],
},
]
addresses = [
f'{department["name"]}:{member["email"].lower()}'
for department in departments
for member in department["members"]
if member["active"]
]
print(addresses)['sales:[email protected]', 'support:[email protected]']输出顺序先由部门顺序决定,再由各部门内部的成员顺序决定。推导式没有自动排序,也不会把同值结果去重;如果这些是需求,应明确选择 sorted() 或集合,并考虑它们是否改变业务语义。
两层 for 已经接近一眼能读懂的上限。若还需要多个筛选条件、异常处理或中间日志,应展开为具名循环;缩短行数本身不是使用推导式的理由。
只计算一次
筛选需要转换后的值,结果也要使用该值时,重复调用解析函数容易浪费工作。 赋值表达式(assignment expression) := 可以在筛选位置保存本轮结果,但它的作用域规则需要明确理解。
def parse_score(raw):
try:
return int(raw)
except ValueError:
return None
raw_scores = ["18", "skip", " 27 ", "-3", "42"]
scores = [
score
for raw in raw_scores
if (score := parse_score(raw)) is not None and score >= 0
]
print(scores)
print(f"last parsed: {score}")[18, 27, 42]
last parsed: 42每个原始字符串只调用一次 parse_score()。条件先排除 None,短路求值确保只有解析成功后才比较 score >= 0;负数解析成功,但被范围条件过滤。
这里的 score 在推导式结束后仍然存在,因为推导式内的赋值表达式把目标绑定到外层作用域。这是有意规定的特殊规则,不适合当作临时变量不会泄漏的技巧。如果这个绑定让读者意外,普通循环或小型辅助函数会更清楚。
陷阱
把副作用藏进结果表达式
推导式不会把副作用变成批处理事务。中途调用失败时,前面的调用可能已经生效,而列表赋值尚未完成。需要回滚、重试或逐项记录错误时,显式循环更容易表达控制权。
把过滤写成条件表达式
先用一句话说明需求中的动词会有帮助:「过滤」对应末尾子句,「替换」或「标记」对应开头条件表达式。两者并用时,先确认被过滤的输入不会执行结果表达式。
在筛选和转换中重复工作
还要检查异常语义。解析函数抛出异常时,筛选子句并不会自动跳过该项;它会终止整个推导式。要逐项容错,应让辅助函数返回明确的结果类型,或在循环中使用窄范围的 try 块。
让嵌套顺序失去领域含义
不要把嵌套推导式与多个 for 混为一谈。[[transform(x) for x in row] for row in matrix] 保留行结构,而 [transform(x) for row in matrix for x in row] 会展平一层。括号形状不同,结果形状也不同。
为一次消费创建完整列表
生成器表达式也不是自动更好。它通常只能消费一次,错误可能推迟到迭代时才出现,而且保留其引用可能延长输入资源的生命周期。选择边界取决于结果如何被使用,而不是只看输入大小。
在推导式里创建延迟调用的函数
立刻调用的结果表达式不会遇到这个时间差,问题出在结果本身是稍后执行的函数。测试应在推导式完成后再调用全部回调,并使用至少两个不同输入;只测试一个元素无法暴露共享绑定。
这个陷阱来自闭包的延迟名称查找,不是列表复制问题。修复时不要盲目复制整个输入对象;先确定回调应该捕获对象引用,还是创建时的某个不可变字段值。
作用域与求值边界
推导式的迭代目标有独立的隐式作用域。若外层已有 item,执行 [item * 2 for item in values] 后,外层 item 的绑定不会被循环变量覆盖。这与普通 for item in values 不同;普通循环结束后,item 仍绑定到最后一个迭代值。
最左侧 for 的可迭代表达式是例外,它直接在外层作用域求值,然后交给推导式处理。后续的 for、所有过滤条件和结果表达式位于推导式的作用域语义中,因此可以依赖前面已经绑定的迭代目标。这个规则解释了为什么后续子句可以使用前面的名称,而外层却看不到那些迭代目标。
赋值表达式采用另一条特殊规则。在列表、集合、字典推导式或生成器表达式中,:= 的目标绑定到包含该推导式的作用域,而不是推导式的隐式作用域;已有 global 或 nonlocal 声明也会被遵守。因此,前例的 score 在推导式结束后仍能读取。
赋值目标不能与同一推导式中的任何迭代变量同名。[item := item + 1 for item in values] 会产生 SyntaxError,因为同一个名称不能既是推导式局部的迭代目标,又被指定为外层绑定。赋值表达式也不能出现在推导式任一 in 后的可迭代表达式中。
这些都是语言语义,不应依赖具体字节码形状。Python 可以改变实现方式,同时保留名称不泄漏和子句顺序等可观察行为。调试时优先展开源码和检查测试结果,不要根据某个 CPython 版本的反汇编推断可移植规则。
异常与部分求值
列表赋值只有在整个推导式成功后才发生,但结果表达式与筛选函数会逐项运行。中间抛出异常时,右侧正在构造的临时列表不会绑定到左侧名称;已经发生的外部副作用却不会自动撤销。这也是副作用不应藏在推导式里的更深层原因。
短路规则仍然适用。if parsed is not None and parsed >= 0 在第一项为假时不会计算第二项,因此能避免拿 None 与整数比较。多个连续过滤子句等价于用 and 逐层过滤,但拆成多个 if 并不会缓存重复的子表达式。
可变元素仍会共享
新列表不等于深拷贝。[record for record in records] 创建新的外层列表,但其中仍是原来的 record 对象;之后修改某个字典,会从两个列表中观察到变化。结果表达式创建新字符串、数字或新容器时,才会得到相应的新元素对象。
审查生成代码时,应分别问「外层容器是不是新的」和「内部对象是否共享」。需要复制记录时,显式写出复制策略,例如对浅层字典使用 record.copy();嵌套所有权更复杂时,应先定义清楚需要隔离哪些节点,再选择合适的复制方式。
用不变量测试推导式
推导式很短,但测试不能只覆盖一个普通输入。它把遍历、筛选和转换压在一起,边界错误常表现为少一项、顺序变化、重复调用或错误的嵌套形状。最有效的测试直接对应这些可观察行为。
- 空输入应产生空列表,并且结果表达式不被调用。
- 同时包含保留项与丢弃项的输入,应验证调用次数和输出顺序。
0、空字符串与None含义不同的领域,应分别覆盖,避免错误的真值筛选。- 嵌套输入应至少包含两个外层元素和不同长度的内层元素,以暴露子句顺序和展平错误。
如果转换函数可能失败,再增加一个位于输入中间的失败样本。确认异常类型与传播位置,同时检查失败前是否已经产生外部副作用。对使用 := 的代码,还应断言转换函数每个输入只调用一次。
属性测试也很适合纯推导式。例如,单纯筛选的结果长度不应超过输入长度,结果顺序应当是输入顺序的子序列。只有当这些性质确实属于需求时才断言,集合转换或显式排序会改变相应不变量。
解包目标与失败位置
for 后的目标可以使用序列解包。[price * quantity for price, quantity in lines] 要求每个输入项都能解包为恰好两个值;任何一项长度不符,都会在到达筛选和结果表达式之前抛出 ValueError。筛选子句不能用来挽救已经失败的解包。
遍历字典时,默认产出的是键。生成代码若写 [key for key, value in mapping],通常会尝试把每个键本身解包成两个值,而不是取得键值对。需要键和值时,应明确调用 mapping.items()。
zip() 可以为多个输入提供解包后的元组,但默认在最短输入耗尽时停止。若不同长度本来是数据错误,静默截断会隐藏缺失项;Python 3.14 中可以使用 zip(left, right, strict=True),让长度不一致抛出 ValueError。是否严格必须由数据契约决定。
解包失败、筛选失败和结果表达式失败发生在不同阶段。审查时为每个阶段准备一个失败样本,能确定异常是否出现在预期边界,也能发现生成代码把验证放得太晚。
输入迭代器的所有权
列表推导式会把输入迭代器从当前位置消费到耗尽。若调用方之后还要读取同一个迭代器,看到的将是剩余部分或空结果;推导式不会自动复制迭代器。对一次性数据流,谁负责消费必须在接口上说清楚。
中途抛出异常时,输入迭代器通常已经前进。重试整个推导式可能从中间继续,而不是重放原始数据。需要可重复重试时,应使用可重新创建的输入来源,或在明确接受内存成本后先物化一个稳定快照。
遍历期间修改源容器也会让行为难以判断。字典大小变化通常触发 RuntimeError,列表修改则可能跳过或重复处理元素。需要筛选掉元素时,构造新列表并在推导式完成后替换旧绑定;不要在结果表达式里改动正在遍历的容器。
这条所有权规则也适用于文件行、数据库游标和生成器。把它们交给推导式,意味着当前调用会立即推进输入;若资源必须在失败后关闭,应让上下文管理器包住整个消费过程。
相似语法,不同容器
列表推导式 [expression for ...] 会立即产生保留顺序且允许重复值的列表。集合推导式 {expression for ...} 会产生集合并去重,不应依赖其迭代或显示顺序。字典推导式 {key: value for ...} 需要键值表达式;遇到重复键时,后写入的值替代先前值。
生成器表达式 (expression for ...) 按需产出值,不会立即构造完整列表。它适合单次流式消费,但消费后不能像列表那样重新遍历同一批结果。是否替换列表推导式,必须从调用方的访问方式、错误时机和资源生命周期判断。
这些形式共享从左到右的 for 与 if 子句模型,却不共享容器语义。代码审查中仅把括号从 [] 改成 {} 或 (),可能改变重复值、顺序、求值时机和可重复消费性;这种改动应当视为行为变更。
4个问题 · 1 道输出预测题 · 1 道找错题