# map、filter 与 reduce

Source: https://codewiki.com/zh/python/map-filter-reduce/

> - **what**: `map()` 转换每个元素，`filter()` 保留满足条件的元素，`functools.reduce()` 从左到右把元素合并成一个结果。
> - **trap**: `map()` 和 `filter()` 返回一次性迭代器；`filter(None, data)` 还会删除所有假值，不只是 `None`。
> - **fix**: 明确数据何时被消费，只在需要列表时调用 `list()`；为 `reduce()` 提供符合结果类型的初始值，多输入 `map()` 则检查长度契约。

## 是什么，为什么存在

`map()`、`filter()` 和 `reduce()` 都是高阶函数（higher-order function）：它们接收另一个函数，用这个函数描述对数据执行的操作。三者承担不同角色：`map()` 改变元素，`filter()` 决定元素是否留下，`reduce()` 把一串元素折叠成一个结果。它们处理的是可迭代对象（iterable），因此输入可以是列表、生成器、文件对象或其他实现迭代协议的对象。

当转换函数或判断函数已经存在时，这些工具能直接组合行为。例如，`map(str.strip, lines)` 比再写一个只调用 `strip()` 的循环更紧凑；`filter(is_valid, records)` 也会把判断规则集中在具名函数中。它们不是所有循环的替代品：若操作需要多处分支、异常恢复或多个副作用，普通 `for` 循环通常更清楚。

`map()` 与 `filter()` 返回迭代器（iterator），而不是列表。它们采用惰性求值（lazy evaluation）：创建对象时不会调用转换函数或判断函数，消费者请求下一个元素时才开始工作。这样可以逐项处理输入，但也意味着异常、日志和副作用发生在消费阶段。

`reduce()` 位于 `functools`，不是内置函数。它接收一个二元函数，把当前累加结果和下一个输入交给该函数，再把返回值用于下一轮。最终只返回一个值，因此适合表达确实具有单一累加状态、又没有更明确内置操作的归约。

在 Python 中，你也会经常用列表推导式（list comprehension）或生成器表达式表达相同工作。选择依据应是结果形状与可读性：已有可调用对象适合 `map()` 或 `filter()`，简单表达式适合推导式，具名内置归约如 `sum()`、`any()` 和 `max()` 通常比通用 `reduce()` 更直接。

## 工作原理

`map(function, iterable, *iterables)` 每轮从所有输入各取一个元素，再调用 `function`。只有一个输入时，函数接收一个实参；有三个输入时，函数就必须接收三个实参。Python 3.14 还提供关键字参数 `strict`，用于决定多输入长度不一致时是静默停止还是报错。

`filter(function, iterable)` 每轮从唯一的输入取一个元素，并对函数结果做真值测试（truth-value testing）。结果为真就原样产出该元素，结果为假就继续拉取输入。`filter()` 不返回判断函数的结果，也不会改变留下的元素。

`filter(None, iterable)` 是特殊形式。此时元素本身接受真值测试，所以 `None`、`False`、数值零、空字符串和空容器都会被丢弃。它适合“只保留真值”的契约，不适合把 `None` 当作唯一缺失标记的数据模型。

`reduce(function, iterable, initial)` 的运行方式可以写成状态转移：先确定累加器，然后依次计算 `accumulator = function(accumulator, item)`。如果提供 `initial`，它就是第一轮累加器，也是空输入的返回值；如果省略它，首个输入会成为累加器，空输入则抛出 `TypeError`。

三者的关键差异集中在消费方式与输出形状上：

| 操作 | 用户函数接收什么 | 返回什么 | 空输入 |
| --- | --- | --- | --- |
| `map()` | 每个输入各一个元素 | 惰性迭代器 | 产出为空 |
| `filter()` | 一个元素 | 保留下来的原元素组成的惰性迭代器 | 产出为空 |
| `reduce()` | 累加器和下一个元素 | 单个最终值 | 取决于是否提供初始值 |

### 拉取式执行

`map()` 和 `filter()` 由下游消费者驱动。调用 `next()`、进入 `for` 循环、构造 `list()`，或者把它们交给 `sum()`，都会拉取元素。若管道是 `filter(predicate, map(transform, source))`，每次请求一个输出时，管道会按需重复执行“取源元素、转换、判断”，直到找到一个保留项或输入耗尽。

这套机制不会缓存已经产出的结果。一个 `map` 或 `filter` 对象遍历完后，再次遍历只会看到剩余元素，通常就是空序列。若确实需要重复读取，应在所有权清楚的位置物化为列表，而不是让多个调用方争用同一个迭代器。

惰性也会移动失败时机。转换函数即使会对第三个输入抛出异常，创建 `map` 对象时仍然不会报错；消费前两个元素后，请求第三个元素才会看到异常。测试必须消费到目标分支，不能只断言迭代器成功创建。

### 多输入 `map()`

默认的多输入 `map()` 在最短输入耗尽时结束。这个行为与 `zip()` 的默认长度规则相似，适合“多余数据可以忽略”的明确契约。若长度差异代表数据损坏，Python 3.14 应使用 `strict=True`，并确保测试真正消费迭代器，因为长度错误也是惰性触发的。

`strict=True` 不会预先读取整个输入来比较长度。它仍逐项处理；只有某个输入先耗尽、而另一个输入还有元素时，才抛出 `ValueError`。因此，无限输入和一次性输入仍可参与 `map()`，但调用方必须拥有处理消费期异常的边界。

### 累加器契约

归约函数的返回类型必须能成为下一轮的左实参。若初始值是字典，那么归约函数每次都应返回代表新状态的字典；若第一次返回 `None`，下一轮就会收到 `None`。这正是把 `dict.update()` 直接塞进 lambda 经常失败的原因：该方法原地修改字典并返回 `None`。

初始值还定义了空输入语义和结果类型。计数通常从整数 `0` 开始，收集字段可能从空元组或字典开始。Python 3.14 允许写 `reduce(function, data, initial=seed)`；旧版本只接受第三个位置实参，因此兼容旧运行时的代码仍常写 `reduce(function, data, seed)`。

## 示例

下面四个示例依次展示转换与筛选、惰性消费、多输入配对和显式归约。所有输出都来自本地 Python 3.12.13；示例只使用同样适用于已验证目标 Python 3.14 的行为。

### 转换已付款订单

输入记录以分为单位保存金额，避免在这个示例中引入十进制舍入问题。判断函数先留下已付款订单，转换函数再生成展示字符串。

<!-- quick -->

```python
# file: paid_receipts.py
orders = [
    {"id": "A-100", "status": "paid", "cents": 1990},
    {"id": "A-101", "status": "pending", "cents": 1250},
    {"id": "A-102", "status": "paid", "cents": 800},
]


def is_paid(order):
    return order["status"] == "paid"


def to_receipt(order):
    return f'{order["id"]}: {order["cents"]} cents'


paid_orders = filter(is_paid, orders)
receipts = list(map(to_receipt, paid_orders))

print(*receipts, sep="\n")
```

```text
A-100: 1990 cents
A-102: 800 cents
```


<!-- /quick -->

`filter()` 传出的仍是原订单字典，只有 `map()` 把它变成字符串。`list()` 明确标出物化位置；如果下游只需要逐项写入文件，也可以直接迭代 `receipts` 对应的 `map` 对象，不必先构造列表。

这个顺序也表达了业务意图：待付款订单不会进入格式化函数。若转换本身负责校验并可能产生缺失标记，就应明确缺失标记的类型，而不是随手再套一层 `filter(None, ...)`。

### 观察惰性管道

转换函数与判断函数都打印调用轨迹。第一次 `next()` 只拉取到首个错误事件为止，后续 `list()` 才消费剩余输入。

```python
# file: lazy_events.py
event_lines = [
    "INFO:started",
    "ERROR:disk full",
    "WARNING:retrying",
    "ERROR:timeout",
]


def parse_event(line):
    print(f"parse {line}")
    level, message = line.split(":", 1)
    return {"level": level, "message": message}


def is_error(event):
    print(f'test {event["level"]}')
    return event["level"] == "ERROR"


errors = filter(is_error, map(parse_event, event_lines))
print("pipeline ready")
print(next(errors))
print(list(errors))
print(list(errors))
```

```text
pipeline ready
parse INFO:started
test INFO
parse ERROR:disk full
test ERROR
{'level': 'ERROR', 'message': 'disk full'}
parse WARNING:retrying
test WARNING
parse ERROR:timeout
test ERROR
[{'level': 'ERROR', 'message': 'timeout'}]
[]
```

创建管道后先打印 `pipeline ready`，证明此时两个函数都没有运行。第一个合格元素之前的输入仍必须处理，所以一次 `next()` 可以触发多次转换和判断。最后一次 `list(errors)` 得到空列表，因为同一个迭代器已经耗尽。

这种轨迹也说明日志顺序属于消费方控制。若生成代码把迭代器返回给另一个层级，转换异常和副作用就会跨越原函数边界发生，调用方必须知道自己接管了消费责任。

### 配对多个输入

`operator.mul` 已经是二元函数，可以直接交给 `map()`。两个输入长度不同，因此默认模式只生成两个结果。

```python
# file: line_totals.py
from operator import mul

unit_prices = [1250, 800, 500]
quantities = [2, 3]

line_totals = list(map(mul, unit_prices, quantities))

print(line_totals)
print(unit_prices[len(quantities):])
```

```text
[2500, 2400]
[500]
```

输出中的 `[500]` 是没有配对的输入，而不是 `map()` 的结果。若这代表漏传数量，在 Python 3.14 中应改用 `map(mul, unit_prices, quantities, strict=True)`；把结果交给 `list()` 时会抛出 `ValueError`，使数据不一致显式失败。

不要把这里的“多个输入并行取值”误解为并发执行。`map()` 在调用它的线程中逐项调用函数；若任务需要线程池、进程池或异步并发，应使用对应执行模型的 API。

### 用初始值定义归约

下面的归约把付款状态累积进新字典。初始空字典既处理空输入，也让第一轮左实参的类型与后续轮次一致。

```python
# file: status_counts.py
from functools import reduce

statuses = ["paid", "paid", "refunded", "paid"]


def add_status(counts, status):
    return {
        **counts,
        status: counts.get(status, 0) + 1,
    }


counts = reduce(add_status, statuses, {})
empty_counts = reduce(add_status, [], {})

print(counts)
print(empty_counts)
```

```text
{'paid': 3, 'refunded': 1}
{}
```

`add_status()` 返回下一轮需要的完整累加器，没有依赖外部可变状态。这个例子刻意展示通用归约；生产代码若需要复杂分组、多个字段或原地更新，具名 `for` 循环通常更容易检查与扩展。

若任务只是计算金额总和，应写 `sum(amounts, start=0)`，而不是 `reduce(lambda left, right: left + right, amounts, 0)`。具名操作同时传达意图与空输入规则，读者不必重新推导二元函数的含义。

## 陷阱

### 把惰性迭代器当成可重复容器

> **陷阱:** 调试代码先执行 `list(result)`，业务代码随后再迭代同一个 `map` 或 `filter` 对象，第二个消费者只能得到剩余元素。检查对象本身是否创建成功，也不会触发转换函数中的异常。

**修复方法：** 明确一个层级拥有消费权。需要重复读取时，在该边界只物化一次并共享列表；需要流式处理时，传递迭代器并测试部分消费、完整消费以及消费期异常。

### 用 `filter(None, ...)` 删除缺失值

> **陷阱:** `filter(None, values)` 删除所有假值，包括合法的 `0`、`False`、`""` 和空容器。生成的数据清洗代码常把“缺失”与“业务值为零”混成同一个条件。

**修复方法：** 只删除缺失标记时写出精确判断，例如 `filter(lambda value: value is not None, values)`。先定义字段是否允许空字符串或零，再决定真值测试是否符合契约。

### 忽略多输入长度差异

> **陷阱:** 默认 `map(function, left, right)` 在较短输入结束时停止，较长输入的尾部不会产生提示。若两列来自必须逐行对应的业务数据，这会把缺失行变成静默丢数。

**修复方法：** 在 Python 3.14 使用 `strict=True`，并消费管道以触发检查。若不等长是允许的，应明确补充值规则并考虑 `itertools.zip_longest()`，不要依赖读者猜测默认行为。

### 省略不该省略的初始值

> **陷阱:** 无初始值的 `reduce()` 在空输入上抛出 `TypeError`，并把首个元素直接当作累加器。若元素类型与结果类型不同，归约函数甚至可能在第二个元素到来时才暴露类型错误。

**修复方法：** 根据领域定义空输入结果，并提供同类型种子。没有合理单位值时，不要随意捏造默认值；可以先拒绝空输入，或者使用更能表达“可能没有结果”的接口。

### 让归约函数返回原地修改方法的结果

> **陷阱:** `reduce(lambda acc, item: acc.update(item), mappings, {})` 第一轮返回 `None`，下一轮就无法继续。类似问题也出现在返回 `list.append()` 的 lambda 中，因为这些原地修改方法按约定返回 `None`。

**修复方法：** 归约函数必须明确返回下一轮累加器。若设计本来就要反复修改一个容器，用普通循环写出修改与返回边界，通常比把副作用藏进 `reduce()` 更清晰。

### 用通用归约遮住具名操作

> **陷阱:** 用 `reduce()` 重写 `sum()`、`min()`、`max()`、`any()`、`all()` 或 `str.join()` 会迫使读者解析一个自定义二元函数。把列表通过 `left + right` 反复拼接，也会让数据移动行为藏在一行 lambda 中。

**修复方法：** 优先选择描述目标结果的内置函数。需要查看每一步累计值时用 `itertools.accumulate()`；只有累加状态确实自定义、而且二元转换仍容易说明时，才保留 `reduce()`。

<!-- deep -->

## 选择合适的形式

`map()` 与推导式都能表达逐项转换，但它们突出的信息不同。`map(normalize, records)` 把已经命名的转换函数放在显眼位置；`[record.name for record in records]` 则让简单表达式贴近迭代结构。若结果需要保持惰性，可把方括号换成生成器表达式，而不是为了惰性强行嵌套多个 lambda。

同样，`filter(is_active, users)` 适合复用具名判断函数，`[user for user in users if user.active]` 适合短小条件。把筛选和转换写进一个推导式，往往比 `map(lambda ..., filter(lambda ..., data))` 更容易按执行顺序阅读。这个选择是可读性与结果形状的选择，不应依赖没有在目标环境测量的速度断言。

若逻辑需要记录拒绝原因，`filter()` 的单一“保留或丢弃”结果通常不够。普通循环可以同时收集合格项与错误信息，也能在分支旁处理异常。不要为了保留函数式外观而让判断函数修改外部列表；那会把输出拆成显式迭代器和隐藏副作用两条通道。

归约也应先寻找领域名称。数值求和用 `sum()`，布尔存在性用 `any()`，全部满足用 `all()`，字符串拼接用 `separator.join(parts)`。这些函数定义了明确的返回形状，部分函数还具有专门的短路或空输入行为，不能靠机械替换为 `reduce()` 保持所有语义。

`itertools.accumulate()` 与 `reduce()` 使用相似的二元累计思路，但输出不同。`reduce()` 只给最终值，`accumulate()` 则惰性地产出中间值，适合余额轨迹、运行最大值或逐步状态。先问调用方要最终汇总还是整个轨迹，再选择接口。

### 转换、筛选与归约的边界

转换函数应让每个输入对应一个输出。若转换可能失败，可以在函数中抛出明确异常，也可以返回带状态的结果对象；不要让 `None` 同时代表解析失败和合法值。随后是否筛选失败项，应由能看到领域契约的层级决定。

判断函数的返回值会接受真值测试，不要求类型恰好是 `bool`。虽然返回非空字符串也能让 `filter()` 保留元素，但明确返回布尔表达式能让类型与意图更容易审查。对于定义了特殊真值规则的第三方对象，还应确认真值测试是否可能报错或产生歧义。

归约函数同时定义状态类型与状态转移。审查时可给它写一个小型类型关系：`(Accumulator, Item) -> Accumulator`。若实现的某条分支返回另一种类型，下一轮才可能失败；把所有分支都纳入测试比只检查最终示例更可靠。

### 管道 API 的所有权契约

函数若返回惰性迭代器，也同时把消费责任交给调用方。接口文档应说明返回值只能遍历一次、底层输入何时关闭，以及异常会在创建时还是迭代时发生。只写 `Iterable` 返回类型可能掩盖一次性语义；实现确实返回迭代器时，`Iterator` 往往更准确。

函数内部若把结果转为列表，就由该函数承担完整消费与异常边界。这样调用更简单，但返回前必须完成全部工作。选择迭代器还是容器不是局部语法偏好，而是 API 对时机、所有权和失败位置的承诺。

常见的返回形状可以这样区分：

| 返回形状 | 调用方得到什么 | 适合的契约 |
| --- | --- | --- |
| `Iterator[T]` | 一次性、按需产出的值 | 调用方拥有消费过程 |
| `Iterable[T]` | 可以取得迭代器的对象 | 是否可重复需另行说明 |
| `list[T]` | 已完整物化的结果 | 返回前完成处理 |
| 单个累加值 | 归约后的最终状态 | 调用处完成处理 |

不要返回依赖已经关闭资源的惰性对象。函数在 `with open(...)` 内创建 `map()`，离开上下文后再让调用方迭代，第一次读取就可能访问关闭的文件。要么在上下文内物化，要么把资源生命周期放进生成器并让生成器负责打开与关闭。

消费所有权也决定谁能重试。已经部分消耗的文件或生成器未必能从头开始，简单地再次调用 `list(iterator)` 只会从当前位置继续。若重试需要完整重放，应保留可重新创建输入的工厂或稳定数据源，而不是只保留当前迭代器。

审查一条管道时，可以按下面顺序记录边界：

1. 标出原始可迭代对象由谁创建、是否可重复迭代。
2. 标出每个 `map()` 和 `filter()` 只保存了哪些函数与输入引用。
3. 找到首次调用 `next()`、`list()`、循环或归约函数的位置。
4. 说明部分消费、异常和提前停止后，剩余输入归谁处理。

这份记录比只看最终类型更能发现错误。两个函数都可能声明返回 `Iterable[T]`，其中一个返回新列表，另一个却返回绑定打开游标的一次性迭代器；它们给调用方的生命周期义务完全不同。

### 测试边界输入

`map()` 和 `filter()` 至少应覆盖空输入、单元素输入以及会让用户函数失败的元素。惰性测试还要把创建与消费分开，先确认创建阶段没有执行，再确认 `next()` 和完整遍历分别触发哪些调用。这样才能验证实际时机，而不只是最终集合。

多输入 `map()` 还需要相等长度、左侧更短和右侧更短三种情况。默认模式应验证截断是有意契约；严格模式则应验证消费到边界时出现 `ValueError`。只测试两个同长度列表，无法证明代码对真实数据偏差的处理方式。

`reduce()` 应覆盖空输入、单元素输入和多个元素。使用初始值时，要断言空输入返回什么；不使用初始值时，要确认空输入失败是公开契约。若累加器是可变对象，再运行两次归约并检查两次结果是否意外共享状态。

测试数据应让转换前后与保留、删除的值明显不同。若所有数都是非零正数，`filter(None, ...)` 的误用不会暴露；若两个输入长度相同，默认截断也不会暴露。边界样本应针对语义风险设计，而不是只重复正常路径。

### 副作用与可观察顺序

Python 保持输入的迭代顺序，`map()` 和 `filter()` 也按该顺序调用用户函数。惰性并不表示乱序，只表示调用时间由消费者推进。若用户函数写日志、修改数据库或发送消息，消费次数与消费位置就会改变外部行为。

为了副作用而写 `list(map(send, messages))` 会构造一个没人使用的结果列表，也把真实目的藏进转换接口。普通循环能清楚表示“依次执行动作”，还能在每轮附近写重试与错误处理。`map()` 最适合有意义的返回值，不能替代任务调度器。

同一个迭代器被两个消费者交错调用时，元素会按调用顺序被分走，并不会为每个消费者复制一份。若两个组件都需要完整数据，应传入可重复容器，或为各自创建独立迭代器。`itertools.tee()` 有自己的缓存与所有权语义，使用前应先确认它符合输入规模和消费模式。

## Python 3.14 的边界语义

Python 3.14 为 `map()` 增加 `strict` 参数。默认 `False` 保持既有的最短输入语义；设置为 `True` 后，任一输入提前耗尽都会在迭代期间触发 `ValueError`。这是数据对齐契约，不是并发或类型检查开关。

严格模式最适合必须一一对应的数据，如数量与单价、列名与字段值。它无法证明每个位置在业务上对应正确，只能证明所有输入同时结束。仍需用标识符、模式校验或领域约束检查内容对齐。

Python 3.14 也允许把 `reduce()` 的初始值写成关键字参数 `initial=`。关键字能在调用较长时突出种子的意义，但若库同时支持旧版 Python，就要遵守声明的最低版本。本文的可运行示例使用第三个位置实参，因此在本地 Python 3.12.13 与目标 Python 3.14 上都成立。

省略初始值时，单元素输入会原样返回该元素，归约函数一次也不调用；空输入则失败。提供初始值后，即使输入为空，返回的也是初始值本身。这些分支会影响类型、对象标识和可变种子的共享方式，应分别测试。

不要把一个可变初始对象定义成模块常量，再让多次归约原地修改它。这样不同调用会共享历史状态，问题来自对象所有权而不是 `reduce()` 自身。每次调用创建新的种子，或者让归约函数返回新状态，可以让边界更清楚。

### 消费期错误

`map(strict=True)` 的长度错误与普通转换错误一样，都可能在已经产出若干元素后出现。若调用方边迭代边写外部系统，失败前的写入可能已经发生。需要全有或全无语义时，应在业务边界增加校验或事务，而不是认为严格长度检查会回滚此前动作。

`map()` 和 `filter()` 不会自动包裹用户函数异常。原始异常会从当前消费操作冒出，堆栈同时包含消费者与转换函数。错误处理应放在知道能否跳过、重试或终止的层级，而不是用宽泛异常把坏数据悄悄过滤掉。

`reduce()` 是立即返回最终值的调用，因此用户函数异常会直接从 `reduce()` 调用处冒出。即便如此，失败前的归约函数可能已经执行多次；若它修改外部状态，部分效果仍会保留。这也是让归约函数保持无副作用更容易推理的原因。

<!-- /deep -->

[检查点: python/map-filter-reduce](https://codewiki.com/zh/python/map-filter-reduce/#checkpoint)

## 延伸阅读

- [Python 3.14 内置函数：`map()`](https://docs.python.org/3.14/library/functions.html#map)
- [Python 3.14 内置函数：`filter()`](https://docs.python.org/3.14/library/functions.html#filter)
- [Python 3.14 `functools.reduce()`](https://docs.python.org/3.14/library/functools.html#functools.reduce)
- [Python 3.14 函数式编程指南](https://docs.python.org/3.14/howto/functional.html)
- [Python 3.14 `itertools.accumulate()`](https://docs.python.org/3.14/library/itertools.html#itertools.accumulate)
