Python 基础体系 · 第 17/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 推导式:列表、集合、字典、生成器与作用域边界

推导式(comprehension)是一种把“遍历、筛选、变换、收集”写在一个表达式中的语法。它不是新的循环协议,也不是特殊的数据结构;它只是对已有可迭代对象执行一组按顺序嵌套的 forif 操作,再根据外层符号决定结果类型。

Python 3.14 的语言参考将推导式抽象为:

表达式 for 目标 in 可迭代对象 if 条件 ...

其中:

  • 目标:每次迭代接收的值,可以是解包目标,例如 xkey, value
  • 可迭代对象:能够被 iter() 转换为迭代器的对象;
  • 条件:布尔上下文中的表达式,结果为假时跳过当前项;
  • 表达式:条件通过后产生的结果元素。

多个 forif 按从左到右的顺序构成嵌套循环;只有抵达最内层、并通过所有条件后,才会计算结果表达式。(docs.python.org)


1. 推导式依赖什么:可迭代对象和迭代器

推导式的 for 并不要求输入必须是 list。字符串、元组、字典、文件对象、生成器,以及实现了 __iter__() 或旧式 __getitem__() 协议的对象,都可以作为输入。

从运行机制看:

for item in source:
    ...

大致会经历:

iterator = iter(source)

while True:
    try:
        item = next(iterator)
    except StopIteration:
        break

可迭代对象负责提供迭代器;迭代器通过反复调用 __next__() 产生值,耗尽后抛出 StopIteration。容器通常每次迭代都能产生新的迭代器,而迭代器本身一般只能单向消费一次。(docs.python.org)

因此,下面两种输入的行为不同:

values = [1, 2, 3]

print([x * 2 for x in values])
print([x * 2 for x in values])

输出:

[2, 4, 6]
[2, 4, 6]

values 是容器,两次遍历分别获得新的迭代器。

values = iter([1, 2, 3])

print([x * 2 for x in values])
print([x * 2 for x in values])

输出:

[2, 4, 6]
[]

第一次列表推导式已经耗尽了 values。第二次并不是推导式失效,而是输入迭代器已经没有元素。(docs.python.org)


2. 列表推导式:遍历结果立即物化

2.1 基本形式

列表推导式使用方括号:

[表达式 for 目标 in 可迭代对象]

例如:

numbers = [1, 2, 3, 4]

squares = [x * x for x in numbers]

print(squares)

输出:

[1, 4, 9, 16]

它对应的普通循环是:

squares = []

for x in numbers:
    squares.append(x * x)

列表推导式的结果是一个新的 list,所有符合条件的结果都会在推导式执行期间计算并放入列表。(docs.python.org)

2.2 条件筛选

if 位于 for 之后:

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

print(even_squares)

输出:

[0, 4, 16, 36, 64]

等价的循环是:

even_squares = []

for x in range(10):
    if x % 2 == 0:
        even_squares.append(x * x)

执行顺序是:

  1. range(10) 取出一个 x
  2. 判断 x % 2 == 0
  3. 条件为真时计算 x * x
  4. 将结果加入新列表。

被过滤掉的元素不会计算结果表达式。这一点在结果表达式成本较高时很重要:

result = [
    expensive_transform(item)
    for item in items
    if item.is_valid()
]

只有 is_valid() 为真的元素才会调用 expensive_transform()

2.3 多个 for 是嵌套,不是并行

下面的推导式:

pairs = [
    (x, y)
    for x in [1, 2, 3]
    for y in ["a", "b"]
]

print(pairs)

输出:

[(1, 'a'), (1, 'b'), (2, 'a'), (2, 'b'), (3, 'a'), (3, 'b')]

等价于:

pairs = []

for x in [1, 2, 3]:
    for y in ["a", "b"]:
        pairs.append((x, y))

第二个 for 会对第一个 for 的每个结果重新开始。因此,在没有过滤条件时,如果各输入长度分别是 n1,n2,,nkn_1, n_2, \ldots, n_k,结果数量是:

n1×n2××nkn_1 \times n_2 \times \cdots \times n_k

例如两个长度为 3 的输入会产生 9 个组合,而不是把两个序列按位置配对。(docs.python.org)

如果需要按位置配对,应使用 zip()

pairs = [(x, y) for x, y in zip([1, 2, 3], ["a", "b", "c"])]

print(pairs)

输出:

[(1, 'a'), (2, 'b'), (3, 'c')]

2.4 结果是元组时必须加括号

下面的代码无法解析:

[x, x * x for x in range(5)]

因为逗号让解析器无法判断 x, x * x 是结果表达式,还是推导式语法的一部分。

正确写法是:

[(x, x * x) for x in range(5)]

输出:

[(0, 0), (1, 1), (2, 4), (3, 9), (4, 16)]

规则不是“列表推导式不能返回元组”,而是“元组结果表达式必须明确用括号分组”。(docs.python.org)


3. 集合推导式:计算结果后去重

集合推导式使用花括号,但没有冒号:

{表达式 for 目标 in 可迭代对象 if 条件}

例如:

text = "abracadabra"

letters = {
    char
    for char in text
    if char not in "abc"
}

print(letters)

结果包含:

{'r', 'd'}

具体打印顺序不应依赖,因为 set 是无序集合。集合推导式的处理流程是:

  1. 从输入中取出一个元素;
  2. 通过条件筛选;
  3. 计算结果表达式;
  4. 将结果插入集合;
  5. 如果该结果已经存在,则不增加新的元素。

因此:

values = [1, 1, 2, 2, 3, 3]

result = {x * 10 for x in values}

print(result)

结果等价于:

{10, 20, 30}

集合推导式与列表推导式的核心差异不是“一个快、一个慢”,而是结果容器的语义不同:

  • list 保留元素顺序,并允许重复;
  • set 只保留唯一元素,不提供可依赖的输出顺序。

集合元素必须是可哈希对象。例如:

{tuple(row) for row in [[1, 2], [1, 2], [3, 4]]}

可以执行,因为元组在元素可哈希时也是可哈希的。

但下面的代码会失败:

{row for row in [[1, 2], [3, 4]]}

异常类似:

TypeError: unhashable type: 'list'

因为 list 可变,不能作为集合元素。

空集合也有一个常见语法边界:

type({})      # dict
type(set())   # set

{} 表示空字典,不表示空集合。(docs.python.org)


4. 字典推导式:同时计算键和值

字典推导式需要冒号分隔键和值:

{键表达式: 值表达式 for 目标 in 可迭代对象 if 条件}

例如:

squares = {
    x: x * x
    for x in range(1, 5)
}

print(squares)

输出:

{1: 1, 2: 4, 3: 9, 4: 16}

等价的循环是:

squares = {}

for x in range(1, 5):
    squares[x] = x * x

字典推导式的键必须是可哈希对象。值没有这个限制,可以是列表、字典或其他可变对象:

groups = {
    name: [name.upper()]
    for name in ["alice", "bob"]
}

print(groups)

输出:

{'alice': ['ALICE'], 'bob': ['BOB']}

4.1 重复键不会产生多个条目

items = ["apple", "apricot", "banana"]

result = {
    word[0]: word
    for word in items
}

print(result)

输出:

{'a': 'apricot', 'b': 'banana'}

处理过程是:

  1. apple 产生键 'a',写入 'apple'
  2. apricot 再次产生键 'a',覆盖原值;
  3. banana 产生键 'b',写入 'banana'

字典中的键必须唯一;同一个键再次出现时,后产生的值覆盖先前的值。字典推导式不会报告重复键冲突。(docs.python.org)

如果业务要求一个键对应多个值,应该显式聚合:

items = ["apple", "apricot", "banana"]

groups = {}

for word in items:
    groups.setdefault(word[0], []).append(word)

print(groups)

输出:

{'a': ['apple', 'apricot'], 'b': ['banana']}

这通常比试图用一个简单字典推导式表达“分组并追加”更清楚。

4.2 键先求值,值后求值

Python 3.8 起,字典推导式中键表达式的求值顺序早于值表达式:

result = {
    make_key(item): make_value(item)
    for item in items
}

每个元素处理时,先计算 make_key(item),再计算 make_value(item)。这是 Python 3.14 的语言规范保证。(docs.python.org)


5. 生成器表达式:同样的语法,完全不同的求值策略

生成器表达式(generator expression)使用圆括号:

(表达式 for 目标 in 可迭代对象 if 条件)

例如:

squares = (x * x for x in range(5))

print(type(squares))
print(list(squares))

输出类似:

<class 'generator'>
[0, 1, 4, 9, 16]

它不会立即构造包含全部结果的列表,而是返回一个生成器迭代器。每次调用 next() 或被 for 消费时,才计算下一个结果。(docs.python.org)

可以逐步观察它的状态:

numbers = (x * x for x in range(3))

print(next(numbers))
print(next(numbers))
print(next(numbers))

try:
    print(next(numbers))
except StopIteration:
    print("generator exhausted")

输出:

0
1
4
generator exhausted

生成器的状态变化是:

创建
  ↓
等待首次 next()
  ↓
计算并产生 0
  ↓
暂停
  ↓
计算并产生 1
  ↓
暂停
  ↓
计算并产生 4
  ↓
暂停
  ↓
输入耗尽,抛出 StopIteration

5.1 列表推导式和生成器表达式的差异

list_result = [transform(x) for x in source]
generator_result = (transform(x) for x in source)
特性 列表推导式 生成器表达式
结果 list 生成器迭代器
计算时机 创建时全部计算 消费时逐个计算
是否可重复遍历 可以 通常只能遍历一次
是否立即占用结果容器空间
适合无限输入 可以,但消费者也必须能够停止
是否支持索引和切片 支持列表操作 不支持

例如,只需要总和时,没有必要先创建中间列表:

total = sum(x * x for x in range(1_000_000))

这里 sum() 逐个消费生成器表达式。相反,如果后续需要多次遍历、索引或保留所有结果,列表更合适:

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

print(squares[3])
print(squares[:5])
print(sum(squares))

生成器表达式并不意味着“永远更省资源”。如果后续代码最终执行:

result = list(x * x for x in source)

它仍然会物化完整列表,只是多了一层生成器调度。选择依据应是数据流是否需要惰性,而不是仅仅看到括号不同就认为生成器一定更优。

5.2 生成器表达式并非所有部分都延迟

这是一个容易被忽略的边界:

values = (x * 2 for x in missing_source)

missing_source 所在的最左侧 for 的输入表达式会在生成器表达式创建时立即求值,因此这里会立刻抛出 NameError

同样:

values = (x * 2 for x in None)

会立即抛出:

TypeError: 'NoneType' object is not iterable

但结果表达式和后续的 forif 通常要等到首次取值时才求值:

values = (missing_name * 2 for x in range(3))

print("generator created")

print(list(values))

输出过程是:

generator created
NameError: name 'missing_name' is not defined

所以“生成器是惰性的”应精确理解为:最左侧输入需要先确定并转换为迭代器,其他计算通常延迟到消费阶段。(docs.python.org)


6. 推导式的精确执行顺序

考虑下面的列表推导式:

result = [
    f(x, y)
    for x in xs
    if p(x)
    for y in ys(x)
    if q(x, y)
]

它等价于:

result = []

for x in xs:
    if not p(x):
        continue

    for y in ys(x):
        if not q(x, y):
            continue

        result.append(f(x, y))

注意以下顺序:

  1. 先从 xs 取出 x
  2. 执行 p(x)
  3. 只有通过筛选,才计算 ys(x) 并遍历它;
  4. 对每个 y 执行 q(x, y)
  5. 只有通过第二个条件,才调用 f(x, y)
  6. f(x, y) 的结果放入目标容器。

因此,把多个条件合并到结果表达式中,可能改变异常发生时间或无效计算数量:

# 先过滤,再转换
[y / x for x in values if x != 0]

比下面这种写法安全:

[y / x if x != 0 else None for x in values]

后者并不是错误,但它产生了包含 None 的结果;两者表达的业务语义不同。

如果条件之间存在依赖,应保持与普通循环相同的从左到右顺序:

[
    item.value
    for item in items
    if item.enabled
    if item.value is not None
]

第二个 if 可以使用同一个元素,并且只在第一个条件通过后执行。


7. 作用域边界:循环变量不会泄漏

Python 2 中,列表推导式的循环变量可能泄漏到外层作用域;Python 3 以后,列表、集合、字典推导式以及生成器表达式都在一个隐式嵌套作用域中执行。

x = "outer"

result = [x * 2 for x in range(3)]

print(result)
print(x)

输出:

[0, 2, 4]
outer

推导式内部的 x 是迭代目标,属于推导式自己的隐式作用域;外部的 x 不会被覆盖。语言参考明确规定,除最左侧 for 的输入表达式外,推导式在独立的隐式嵌套作用域中执行,以避免目标名称泄漏。(docs.python.org)

7.1 最左侧输入表达式是边界例外

看下面的代码:

source = [1, 2, 3]

result = [
    x
    for x in source
]

source 这个名称在外层作用域中解析,然后其结果被传入推导式的隐式作用域。

更复杂的例子:

def make_values():
    source = [1, 2, 3]

    return [
        x + offset
        for x in source
        for offset in range(2)
    ]

这里:

  • source 是最左侧 for 的输入;
  • xoffset 是推导式作用域中的局部名称;
  • offset 只能在推导式内部使用;
  • 函数调用结束后,推导式不会把 xoffset 写入 make_values() 的局部变量表。

这解释了为什么推导式可以保护循环变量,却仍然能读取外层函数中的变量。

7.2 闭包和晚绑定仍然存在

推导式本身有作用域,并不意味着内部创建的函数自动复制当前值:

functions = [
    lambda: x
    for x in range(3)
]

print([fn() for fn in functions])

输出是:

[2, 2, 2]

三个 lambda 都捕获了推导式作用域中的同一个自由变量 x。等到调用函数时,推导式已经结束,x 的最终值是 2

如果希望每个函数保存当次值,应使用默认参数绑定:

functions = [
    lambda x=x: x
    for x in range(3)
]

print([fn() for fn in functions])

输出:

[0, 1, 2]

这里的关键不是推导式特殊,而是 Python 闭包对自由变量采用晚绑定:函数保存名称的查找关系,而不是在定义时自动复制对象值。

7.3 类作用域不是普通闭包作用域

推导式位于类体内时,不能像类体后续语句那样直接可靠地读取类命名空间中的名称:

class Config:
    prefix = "item"

    # 可能触发 NameError
    names = [prefix + str(i) for i in range(3)]

原因是类体和推导式之间不是普通的函数闭包关系。推导式拥有隐式嵌套作用域,而函数作用域不能通过普通自由变量机制访问类命名空间。

可行的方式是把值先放到模块级名称、函数局部变量,或者在推导式外完成计算:

class Config:
    prefix = "item"
    names = [prefix + str(i) for i in range(3)]

仍然不应依赖这种写法。更清晰的方案是:

_PREFIX = "item"

class Config:
    prefix = _PREFIX
    names = [_PREFIX + str(i) for i in range(3)]

这是作用域模型的限制,不是 Python 3.14 新增的推导式行为。


8. := 是推导式作用域中的特殊情况

赋值表达式使用 :=

(value := expression)

它既计算右侧表达式,也把结果绑定到名称。

例如:

values = [1, 2, 3, 4]

result = [
    doubled
    for value in values
    if (doubled := value * 2) > 4
]

print(result)
print(doubled)

输出:

[6, 8]
8

这里要区分两类名称:

  • valuefor 目标,属于推导式隐式作用域,不泄漏;
  • doubled 是赋值表达式目标,绑定到包含该推导式的外层作用域。

这是一个有意规定的特殊规则。赋值表达式出现在列表、集合、字典推导式或生成器表达式中时,目标绑定到包含作用域,并遵守该作用域中的 globalnonlocal 声明。(peps.python.org)

因此,下面的代码虽然合法,但会产生外部状态:

numbers = [1, 2, 3]

result = [
    y
    for x in numbers
    if (y := x * 10)
]

print(y)

如果只是为了复用一次昂贵计算,赋值表达式可以减少重复:

result = [
    normalized
    for item in items
    if (normalized := normalize(item)) is not None
]

但如果外部并不需要 normalized,普通循环往往更容易阅读,因为它明确展示了中间变量的生命周期。

赋值表达式不能随意放在推导式的输入部分。例如下面的形式会触发语法错误:

# 非法
[x for x in (source := get_source())]

推导式的可迭代输入位置对命名表达式有额外限制;这也是为了避免输入表达式和推导式隐式作用域之间出现难以解析的名称绑定关系。(peps.python.org)


9. 嵌套推导式:语义正确不等于可读性正确

矩阵转置可以写成嵌套列表推导式:

matrix = [
    [1, 2, 3],
    [4, 5, 6],
]

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

print(transposed)

输出:

[[1, 4], [2, 5], [3, 6]]

它等价于:

transposed = []

for index in range(3):
    column = []

    for row in matrix:
        column.append(row[index])

    transposed.append(column)

外层推导式先处理 index,内层推导式在每个 index 下重新遍历 matrix。Python 文档也使用该例说明嵌套列表推导式的执行方式。(docs.python.org)

但并非所有嵌套循环都适合压缩:

result = [
    transform(row, column)
    for row in rows
    if row.enabled
    for column in row.columns
    if column.is_valid()
]

当条件、异常处理、日志或副作用增多时,普通循环通常更适合,因为它允许为每个阶段命名并插入诊断信息。推导式适合表达“输入经过明确的纯变换后形成结果”;如果主要目的是修改外部状态,不应把副作用伪装成结果表达式:

# 不推荐:利用推导式执行副作用
[_logger.info(item) for item in items]

这会创建一个无意义的列表,而且读者必须从表达式中推断真实目的。应改为:

for item in items:
    _logger.info(item)

10. 异步推导式:异步迭代器上的同一套结构

async def 中,推导式可以使用 async for,也可以在适当位置使用 await

async def collect(async_source):
    return [
        item
        async for item in async_source
        if item.is_valid()
    ]

如果推导式包含 async for,或者在规定位置使用了 await,它就是异步推导式。异步推导式可能暂停所在协程,因此调用方必须在异步生命周期中消费它。Python 3.14 语言参考保留并明确了这一规则;异步推导式最早在 Python 3.6 引入,Python 3.11 又允许异步推导式嵌套在异步函数中的推导式内。(docs.python.org)

生成器表达式也有异步形式:

async def stream(async_source):
    return (
        item.value
        async for item in async_source
    )

这会返回异步生成器对象,需要使用 async for 消费:

async for value in stream(source):
    print(value)

不能把异步生成器当作普通同步迭代器传给 list() 或普通 for。同步迭代器通过 __next__()StopIteration 工作;异步迭代器通过 __anext__() 返回可等待对象,并以 StopAsyncIteration 表示结束。(docs.python.org)


11. 常见误解与故障表现

11.1 误以为生成器表达式创建时就执行全部计算

def transform(x):
    print("transform", x)
    return x * 2

values = (transform(x) for x in range(3))

print("created")
print(next(values))

输出顺序是:

created
transform 0
0

创建生成器时没有调用 transform();第一次 next() 才计算第一个结果。

但最左侧输入表达式仍然会立即求值:

values = (transform(x) for x in get_source())

get_source() 会在生成器表达式创建时执行。若它连接数据库、打开文件或抛出异常,异常发生点就在创建表达式的位置,而不是第一次 next()

11.2 误以为 if 可以替代 else

推导式中的尾部 if 是过滤条件:

[x for x in values if x > 0]

如果需要为每个元素选择两个结果,应使用条件表达式:

["positive" if x > 0 else "non-positive" for x in values]

这两个 if 的位置和语义不同:

  • for ... if condition:决定是否保留当前元素;
  • value_if_true if condition else value_if_false:决定当前元素产生什么值。

11.3 误以为字典推导式会保留重复键

result = {x % 2: x for x in range(5)}
print(result)

输出:

{0: 4, 1: 3}

01 多次出现,最终值分别是最后一次写入的 43。如果重复键代表数据错误,应在构造前显式检测,不能依赖字典推导式自动报警。

11.4 误以为集合推导式保留输入顺序

result = {x for x in [3, 1, 2]}

不要把 result 的打印顺序当作 [3, 1, 2]。如果既要去重又要保留首次出现顺序,可以使用字典键:

unique = list(dict.fromkeys([3, 1, 2]))

print(unique)

输出:

[3, 1, 2]

这里利用的是字典键唯一和插入顺序语义,而不是集合的顺序。

11.5 误以为推导式可以中途 break

推导式语法没有 breakcontinue 或普通语句块。要提前停止,应使用:

  • next()
  • any()
  • all()
  • itertools 中的组合;
  • 或显式 for 循环。

例如查找第一个满足条件的值:

first = next(
    (x for x in values if is_match(x)),
    None,
)

生成器表达式负责逐个产生候选值,next() 在拿到第一个结果后停止消费,不会遍历剩余输入。


12. 如何在四种推导式之间作出语义选择

可以先问结果需要表达什么:

# 保留顺序、允许重复
[x * 2 for x in values]
# 只关心唯一结果
{x * 2 for x in values}
# 需要键到值的映射
{item.id: item for item in items}
# 只需要逐个消费,不需要立即保存
(x * 2 for x in values)

这四种形式分别对应:

  • 列表推导式:批量构造有序结果;
  • 集合推导式:批量构造唯一值集合;
  • 字典推导式:批量构造键值映射;
  • 生成器表达式:构造延迟产生结果的迭代器。

推导式不是“把所有循环都改短”的工具。它最适合在以下条件同时成立时使用:

  1. 输入、筛选和变换关系可以从左到右直接读懂;
  2. 结果容器类型就是业务所需要的类型;
  3. 不依赖循环体中的副作用;
  4. 不需要 break、复杂异常处理或多个中间状态;
  5. 能够接受列表推导式的立即物化,或者明确需要生成器的惰性消费。

最终需要牢记的不是某种固定格式,而是三条边界:

  • 推导式的 for 消费可迭代对象,因此输入可能已经耗尽;
  • 列表、集合、字典推导式立即构造结果,生成器表达式通常延迟产生结果;
  • 推导式的循环目标位于隐式作用域中,而 := 的目标却有特殊的外层绑定规则。

理解这三条边界后,推导式就不再只是简写语法,而会成为一种可以精确推理求值顺序、内存行为、迭代状态和名称绑定的语言工具。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。