Swift 与 Objective-C

[Swift 中级 #5] Swift 高阶函数实战整理,深入 lazy 序列

map·filter·compactMap·flatMap·reduce 五个函数的区别,取决于闭包返回什么。本文整理了与 for 循环的选择标准、链式调用产生的中间数组,以及 lazy 序列解决的问题。

7 分钟阅读
[Swift 中级 #5] Swift 高阶函数实战整理,深入 lazy 序列 封面图

map 和 filter 大家都会用,问题在后面。compactMap 和 flatMap 名字相近,容易混淆;reduce 的签名又比较难,让人倾向于避开。链式调用一长,就会不由自主地产生性能上的疑问:“这到底创建了多少次新数组?”

中级系列第 5 篇是高阶函数实战整理,涵盖五个函数的准确区别、与 for 循环的选择标准,以及 lazy 序列解决的问题。这是一篇建立在闭包篇基础上的实战内容。

五个函数的准确定位 — 签名告诉你的事

高阶函数(higher-order function)是接收函数作为参数的函数。数组处理的五个主要函数,可以根据闭包的签名来区分,更准确地说,就是看“一个元素会变成什么”。

map:元素 → 新元素。数量保持不变。 输入 n 个元素,就会输出 n 个元素。它只做转换,不做筛选。

filter:元素 → Bool。数量可能减少,但元素本身不变。 它只保留符合条件的元素,不进行转换。

compactMap:元素 → 可选值。丢弃 nil 并解包后返回。 它是 map 和 filter 的特殊组合,适用于可能转换失败的操作。

let inputs = ["1", "2", "三", "4"]
let numbers = inputs.compactMap { Int($0) }  // [1, 2, 4]

Int(“三”) 的结果是 nil,compactMap 会将其筛掉。如果是 map { Int($0) },就会得到 [Int?],从而不得不在每个使用处重复处理可选值。compactMap 是把可选值篇的原则“在边界处解包可选值”应用到集合上的实现。

flatMap:元素 → 数组。展开一层嵌套。 当一个元素会展开成多个元素时使用。如果像 sentences.flatMap { $0.split(separator: " ") }一样从句子数组创建单词数组,使用 map 会得到 [[단어]]这样的嵌套结果,而 flatMap 会展开一层。它与 compactMap 的区别可以这样记:闭包返回可选值时用 compactMap,返回数组(序列)时用 flatMap。

reduce:整个集合 → 一个值。 计算总和、求最大值、创建字典等所有将多个值折叠为一个值的操作,都可以用它表示。reduce(0, +)用于求和,第一个参数是初始值,第二个参数表示“如何将目前的结果与下一个元素合并”。记住一个性能技巧:累积数组或字典时使用 reduce(into:)。普通 reduce 每一步都会复制累积值,而 into 版本会持续修改同一个值,在大量数据下差异很大。

for 循环 vs 高阶函数 — 可读性的真相

“用高阶函数代替 for 循环才是 Swift 的方式”这句话一半正确,一半危险。下面整理判断标准。

高阶函数真正的优势不在于短,而在于它声明了意图。看到 filter,读者从签名层面就能知道“这里是在筛选,元素不会改变”。而 for 循环必须读完整个循环体才能得出这个结论。这正是第 1 篇提到的表达力:“让意图原样呈现在代码中”。

不过,这项优势只有在每个步骤都很简单时才能保持。如果闭包中有三层条件判断,还混入修改外部变量、调用 print 等副作用,“既然是 filter 就是在筛选”的预期就会落空,代码反而比 for 更难读。这违反了最小惊讶原则。判断标准如下。

  • 转换、筛选、聚合的简单组合 → 高阶函数。
  • 每个步骤都需要复杂分支、副作用或中途退出(break) → for 循环。高阶函数没有合适的 break 对应工具,因此“在第一个满足条件的元素处停止”可以使用 first(where:),但更复杂的提前退出逻辑用循环更自然。
  • 需要索引的操作 → 也可以使用搭配 enumerated() 的高阶函数,但变复杂后就用循环。

有一种反模式需要明确指出:在 forEach 中向外部数组 append。var result: [Int] = []; items.forEach { result.append($0 * 2) }这是手动重新实现 map 一行就能完成的工作,却只增加了可变状态。forEach 适合对每个元素产生副作用,例如发送通知;如果目的是生成某个结果,就应该使用 map 系列函数。

对比会堆积中间数组的链式调用和让元素逐个通过的 lazy 管道的示意图
链式调用会在每个步骤创建中间数组,而 lazy 会让元素逐个通过

链式调用的成本 — 中间数组这个隐藏的访客

串联高阶函数看起来很优雅,但要记住每个步骤都会创建一个新数组。

let result = products
    .filter { $0.inStock }      // 中间数组 1
    .map { $0.price }           // 中间数组 2
    .prefix(5)                  // 最终

如果有 100 万个元素,filter 可能创建一个最多包含 100 万个元素的数组,map 又会创建一个。即使只需要前 5 个元素,也要完整遍历两次并进行两次分配。

这个问题的标准解法是 lazy。像 products.lazy.filter{...}.map{...}.prefix(5)这样加入 lazy 后,管道的性质就变了。各个步骤不再立即执行并创建数组,而是生成一个只记录“要做什么”的惰性序列;到最终消费时,元素才会逐个通过整个管道。凑够 5 个元素后遍历就会停止,因此 100 万个元素只需处理前面的一小部分。也不会有中间数组。

那是不是应该始终使用 lazy?并不是。lazy 不保存结果,所以消费同一个惰性序列两次,也会计算两次。由于闭包是按引用保存、稍后执行的结构,也可能产生与 escaping 相关的限制。标准很简单:数据量大且只消费一部分时(例如组合使用 prefix 和 first(where:)),lazy 才能发挥优势;如果要消费全部数据并保存为数组,直接执行通常更好。还要注意,这种优化其实是 lazy var 属性所对应问题的集合版本:“延迟计算是否有收益?”理解这一点就能把两个概念联系起来。

实战组合配方

下面整理几个常用组合,作为实战配方。

创建字典。 使用 Dictionary(uniqueKeysWithValues: users.map { ($0.id, $0) }) 创建 ID 查询表。如果可能存在重复键,Dictionary(grouping: orders, by: { $0.customerID })就是分组版本。

安全地传递可选值。 将服务器响应中的字符串 ID 数组转换为 URL 数组:ids.compactMap { URL(string: $0) }。失败项会被静默筛除,类型也会确定为 [URL]

排序组合。 除了 items.sorted { $0.priority > $1.priority },也值得了解使用 KeyPath 的 items.sorted(using: KeyPathComparator(\.priority, order: .reverse))。KeyPath 会在后文单独介绍。

聚合。 购物车总额是 cart.reduce(0) { $0 + $1.price * Double($1.quantity) }。不过,简单求和时,拆成 cart.map(\.subtotal).reduce(0, +)通常更易读。reduce 闭包变复杂时,就是应该拆分的信号。

开发者阅读高阶函数与 for 循环分岔路口标牌的插图
简单转换使用高阶函数,分支、副作用和提前退出使用循环

总结

  • 判断标准是闭包返回什么。返回新元素就是 map,返回 Bool 就是 filter,返回可选值就是 compactMap,返回数组就是 flatMap,将全部内容折叠成一个值就是 reduce(累积集合使用 reduce(into:))。
  • 高阶函数的优势在于声明意图。闭包变复杂或混入副作用后,这项优势就会消失,此时应回到 for 循环。
  • 链式调用会在每个步骤创建中间数组。只消费大型数据的一部分时,用 lazy 创建惰性管道;消费全部数据时,保持立即执行。
  • 如果你正在用 forEach 创建数组,那就是应该改用 map 系列函数的地方。

下一篇讨论所有与 JSON 斗争的 iOS 开发者都会遇到的主题:Codable 深入解析。内容涵盖 CodingKeys、嵌套结构、日期策略,以及“因为一个字段导致整体解码失败”这一问题的解决方法。

延伸阅读