map、filter 与 reduce

用 map 转换、filter 筛选、reduce 归约可迭代对象,并正确处理惰性求值、空输入和多输入长度不一致。

难度 进阶 时长 标准深度约 14分钟
版本 Python 3.14
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) 是特殊形式。此时元素本身接受真值测试,所以 NoneFalse、数值零、空字符串和空容器都会被丢弃。它适合“只保留真值”的契约,不适合把 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)),每次请求一个输出时,管道会按需重复执行“取源元素、转换、判断”,直到找到一个保留项或输入耗尽。

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

惰性也会移动失败时机。转换函数即使会对第三个输入抛出异常,创建 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 的行为。

转换已付款订单

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

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")
A-100: 1990 cents
A-102: 800 cents

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

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

观察惰性管道

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

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))
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()。两个输入长度不同,因此默认模式只生成两个结果。

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):])
[2500, 2400]
[500]

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

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

用初始值定义归约

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

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)
{'paid': 3, 'refunded': 1}
{}

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

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

陷阱

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

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

filter(None, ...) 删除缺失值

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

忽略多输入长度差异

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

省略不该省略的初始值

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

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

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

用通用归约遮住具名操作

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

深入 选择合适的形式

选择合适的形式

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() 调用处冒出。即便如此,失败前的归约函数可能已经执行多次;若它修改外部状态,部分效果仍会保留。这也是让归约函数保持无副作用更容易推理的原因。

延伸阅读

检查点

4个问题 · 2 道输出预测题 · 1 道找错题

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?