Swift语言底层原理剖析-Array系列-高阶函数

现代高级语言中,一般都会对集合类型提供高阶函数来简化开发者的代码,大幅提高代码逻辑的可读性。Swift也是如此,像在Swift的数组中的filterforEachmapcompactMapflatMap, reduce等。接下来让我们一个一个来看他内部源码,剖析实现逻辑。

filter

filter函数的作用类似一个元素过滤器,传入一个过滤规则的闭包,返回满足过滤条件的数组

在我们解决调试环境的时候已经得知filter函数会调用内部_filter函数,不过如果我们去搜filter方法会发现好多地方实现了这个方法,而且实现方式还不太一样。我们还是一步一步来。

通过断点,我们首先会进入如下代码:

swift 复制代码
extension _ArrayProtocol {
  // Since RangeReplaceableCollection now has a version of filter that is less
  // efficient, we should make the default implementation coming from Sequence
  // preferred.
  @inlinable
  public __consuming func filter(
    _ isIncluded: (Element) throws -> Bool
  ) rethrows -> [Element] {
    return try _filter(isIncluded)
  }
}

先不着急着进_filter,看看注释,大致意思是说由于 RangeReplaceableCollection 现在有一个效率较低的过滤器版本,我们应该首选来自 Sequence 的默认实现。盲猜感觉是_ArrayProtocol的父协议,父协议的_filter实现的不好,然后要用Sequence 的默认实现,也就是我们调用的_filterSequence的默认实现。这个时候我们step into_filter方法可以看到如下代码:

swift 复制代码
extension Sequence {
  @inlinable
  public __consuming func filter(
    _ isIncluded: (Element) throws -> Bool
  ) rethrows -> [Element] {
    return try _filter(isIncluded)
  }
  
  public func _filter(
        _ isIncluded: (Element) throws -> Bool
      ) rethrows -> [Element] {
        // 定义一个连续数组`result`,用于存储过滤后的元素
        var result = ContiguousArray<Element>()
        // 使用`makeIterator()`方法创建一个迭代器,用于访问集合中的每一个元素
        var iterator = self.makeIterator()
        // 使用`while let`结构,通过调用迭代器的`next()`方法在集合中不断取下一个元素
        while let element = iterator.next() {
          // 如果闭包`isIncluded`返回`true`,那么就将这个元素添加到`result`数组中
          if try isIncluded(element) {
            result.append(element)
          }
        }
        // 将连续数组`result`转换为数组并返回
        return Array(result)
  }
}

_filter本身的逻辑不复杂,笔者也加了注释,创建一个ContiguousArray数组,然后通过迭代器逐个迭代匹配,能够匹配到就放到ContiguousArray中,最后将ContiguousArray转成Array返回。

这边主要还是剖析其他内容,首先至少说明_ArrayProtocol协议继承自Sequence,不然咱们进不到这个方法.另外我们看到Sequence里有_ArrayProtocol一模一样的filter方法,先不纠结,我们来看看_ArrayProtocol的定义

kotlin 复制代码
@usableFromInline
internal protocol _ArrayProtocol
  : RangeReplaceableCollection, ExpressibleByArrayLiteral
where Indices == Range<Int> {
  ...
}

没看到Sequence,倒是看到了之前注释里说的有个效率较低的filter版本的RangeReplaceableCollection。没关系,一路往上找我们可以看到如下定义:

swift 复制代码
public protocol RangeReplaceableCollection<Element>: Collection
where SubSequence: RangeReplaceableCollection {
 ...
}
public protocol Collection<Element>: Sequence {
}

所以这些协议的继承关系是:

Sequence -> Collection -> RangeReplaceableCollection -> _ArrayProtocol

所以注释的意思其实是RangeReplaceableCollection重写了filter,但是效率低,所以我要继续重写调用效率高的Sequence里的filter逻辑。

那么,我们来看看效率低的和效率高的方法有什么区别

swift 复制代码
 extension RangeReplaceableCollection {
  //效率低的
  @inlinable
  @available(swift, introduced: 4.0)
  public __consuming func filter(
    _ isIncluded: (Element) throws -> Bool
  ) rethrows -> Self {
    var result = Self()
    for element in self where try isIncluded(element) {
      result.append(element)
    }
    return result
  }
}

extension Sequence {
  //效率高的
  public func _filter(
        _ isIncluded: (Element) throws -> Bool
      ) rethrows -> [Element] {
        // 定义一个连续数组`result`,用于存储过滤后的元素
        var result = ContiguousArray<Element>()
        // 使用`makeIterator()`方法创建一个迭代器,用于访问集合中的每一个元素
        var iterator = self.makeIterator()
        // 使用`while let`结构,通过调用迭代器的`next()`方法在集合中不断取下一个元素
        while let element = iterator.next() {
          // 如果闭包`isIncluded`返回`true`,那么就将这个元素添加到`result`数组中
          if try isIncluded(element) {
            result.append(element)
          }
        }
        // 将连续数组`result`转换为数组并返回
        return Array(result)
  }
}

这边主要的区别就是一个是用ContiguousArray连续数组来提高性能,ContiguousArray可以更好的利用CPU缓存,尤其在处理大量数据时会更加明显,内部细节咱们可以后续探究,暂时先不展开。另外一个是用迭代器来替代for-in循环,目前了解到的信息(From GPT4.0)for-in底层实际上也是使用 iterator 来实现的,这边估计对性能应该没啥影响,这个实现应该在编译阶段就处理了。

  • 笔者认为filter设计的比较好的点
  • isIncludedthrows异常,通过if tryrethrows将异常回抛给调用层处理
  • 使用ContiguousArray连续数组来提高性能
  • 笔者还有疑惑的点

forEach

forEach函数的作用就像他的函数名字定义,就是一个遍历,每个元素会按顺序逐个调用传入的闭包

less 复制代码
  @_semantics("sequence.forEach")
  @inlinable
  public func forEach(
    _ body: (Element) throws -> Void
  ) rethrows {
    for element in self {
      try body(element)
    }
  }

这个原理相当简单了,就是for循环,然后每次循环中去调用传递进来的闭包。

  • 设计的比较好的点
  • filter一样的异常回抛机制

map

map是Swift 集合类型的函数,用于将集合的每一个元素通过某个函数进行转换,然后返回一个新的包含已转换元素的集合

以下是 code for debug:

ini 复制代码
let numbers = [1, 2, 3, 4, 5]
let squaredNumbers = numbers.map { $0 * $0 }
print(squaredNumbers) // 输出: [1, 4, 9, 16, 25]

当我们断点调试进入map方法时可以看到如下代码(笔者加了注释):

swift 复制代码
  @inlinable
  public func map<T>(
    _ transform: (Element) throws -> T
  ) rethrows -> [T] {
    // 获取集合元素的数量
    let n = self.count
    // 如果元素数量为0,则直接返回一个空数组
    if n == 0 {
      return []
    }
    // 初始化一个连续数组结果,用于储存转换后的元素
    var result = ContiguousArray<T>()
    // 因为总数已知,预先分配内存空间,以优化性能
    result.reserveCapacity(n)
    // 获取集合的起始索引,这边是array,其实就是0。如果是一些数组切片的类这里就不是0了
    var i = self.startIndex
    // 遍历集合中的每个元素
    for _ in 0..<n {
      // 应用转换函数并将结果添加到结果数组
      result.append(try transform(self[i]))
      // 更新索引
      formIndex(after: &i)
    }
    // 检查索引是否已经到达集合的末端
    _expectEnd(of: self, is: i)
    // 返回结果数组,将其从ContiguousArray转化为标准数组
    return Array(result)
  }

代码逻辑不难理解,首先是边界检查,然后开辟一个ContiguousArray用来储存转换后的元素,因为总数已知,所以通过reserveCapacity提前分配好内存空间。然后就是通过索引去遍历整个数组,遍历中去调用transform闭包做元素mapping后append到ContiguousArray实例中,最后转换成Array标准数组输出。

另外,我们可以发现,函数巧妙的定义了ElementT,让我们返回的元素类型可以和之前的不一样。

关键点在这里:

php 复制代码
    // 获取集合的起始索引,这边是array,其实就是0。如果是一些数组切片的类这里就不是0了
    var i = self.startIndex
    // 遍历集合中的每个元素
    for _ in 0..<n {
      // 应用转换函数并将结果添加到结果数组
      result.append(try transform(self[i]))
      // 更新索引
      formIndex(after: &i)
    }
    // 检查索引是否已经到达集合的末端
    _expectEnd(of: self, is: i)

为什么要搞这么复杂,我直接这样不行吗?

css 复制代码
    for i in 0..<n {
      result.append(try transform(self[i]))
    }

后来发现,map方法是定义在Collection协议中的默认实现,还真不行,因为实现这个协议的除了Array,还有字典(Dictionary),集合(Set),有子集范围的数组(Array Slices)DictionarySet都是无序了,内部使用哈希映射来访问元素,并不是从0开始。数组切片就更好理解了,相当于一个数组的子集,更不会从0开始。

但是理论上应该用for element in self 是可以的,后面讲到的compactMap用的也是这个,个人感觉这样更清晰。

  • 设计的比较好的点
  • 函数巧妙的定义了ElementT,让我们返回的元素类型可以和之前的不一样
  • 定义在了Collection协议中,并且使用通用的index遍历方式适配各种Collection类型

compactMap

compactMap同样用于将集合的元素进行转换,但它和map的区别是会自动移除转换结果为 nil 的元素。即,它不仅可以转换数组元素,还会过滤掉转换结果为 nil 的元素,返回不含 nil 的新数组。

以下是code for debug:

javascript 复制代码
let stringArray: [String] = ["1", "2", "three", "4", "five"]
let intArray: [Int] = stringArray.compactMap { Int($0) }
print(intArray)  // 输出: [1, 2, 4]

当我们断点调试进入compactMap方法的时候可以看到:

swift 复制代码
extension Sequence {
  @inlinable 
  public func compactMap<ElementOfResult>(
    _ transform: (Element) throws -> ElementOfResult?
  ) rethrows -> [ElementOfResult] {
    return try _compactMap(transform)
  }

  @inlinable
  @inline(__always)
  public func _compactMap<ElementOfResult>(
    _ transform: (Element) throws -> ElementOfResult?
  ) rethrows -> [ElementOfResult] {
    // 创建一个空数组 result,此数组用于存储经过转换并成功解包的元素。
    var result: [ElementOfResult] = []
    // 使用 for-in 循环遍历 Sequence 中的每一个元素。
    for element in self {
      // 尝试用闭包 transform 来转换每一个元素,
      // 如果转换成功并且结果不为 nil,则将解包后的结果添加到结果数组中。
      if let newElement = try transform(element) {
        result.append(newElement)
      }
    }
    // 返回结果数组。
    return result
  }
}

可以看到compactMap调用了内部的_compactMap方法,正在的实现在_compactMap中。最关键的是这里:

javascript 复制代码
if let newElement = try transform(element) {
    result.append(newElement)
}

增加了对newElement的判断,空的话就不会append了。

  • 设计的比较好的点
  • compactMap的遍历采用了for element in self 的方式,个人感觉比map的遍历要简洁易读。

flatMap

在 Swift 4.1 之前,flatMap 有两个版本,一种用于消除嵌套(平铺数组),另一种用于移除 nil。但在 Swift 4.1 之后,处理 nil 的那个版本被 compactMap 取代了。现在 flatMap 主要用于处理嵌套的数组。

对于Swift历史不了解的可能比较懵,其实我们上面看的compactMap就是从flatMap里分化出来的,分化的点就是传递的闭包函数会不会返回空,会就走compactMap,不会就走flatMap平铺数组能力。

分化点的设计相当巧妙,让我们一起来看下,这里先埋个伏笔。

我们把之前compactMap的测试代码直接替换成调用flatMap:

bash 复制代码
let stringArray: [String] = ["1", "2", "three", "4", "five"]
let intArray: [Int] = stringArray.flatMap { Int($0) } //compactMap{Int($0)}
print(intArray)  // 输出: [1, 2, 4]

然后断点调试进入flatMap方法:

swift 复制代码
extension Sequence {
  @available(swift, deprecated: 4.1/*, obsoleted: 5.1 */, renamed: "compactMap(_:)",
    message: "Please use compactMap(_:) for the case where closure returns an optional value")
  public func flatMap<ElementOfResult>(
    _ transform: (Element) throws -> ElementOfResult?
  ) rethrows -> [ElementOfResult] {
    return try _compactMap(transform)
  }
}

很简单直接左手转右手调用了compactMap一样调用的_compactMap方法。从定义上看apple更希望这种情况让我们直接调用compactMap(_:)方法。

好接下去我们注释掉刚刚的测试代码,添加下面的测试代码:

bash 复制代码
//let stringArray: [String] = ["1", "2", "three", "4", "five"]
//let intArray: [Int] = stringArray.flatMap { Int($0) }
//print(intArray)  // 输出: [1, 2, 4]

let nestedArray = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
let flatArray = nestedArray.flatMap { $0 }
print(flatArray) // 输出: [1, 2, 3, 4, 5, 6, 7, 8, 9]

当我们再次断点调试进来的时候,会发现我们进了另外一个方法

swift 复制代码
extension Sequence {
  @inlinable
  public func flatMap<SegmentOfResult: Sequence>(
    _ transform: (Element) throws -> SegmentOfResult
  ) rethrows -> [SegmentOfResult.Element] {
    var result: [SegmentOfResult.Element] = []
    for element in self {
      result.append(contentsOf: try transform(element))
    }
    return result
  }
}

方法体实现不难理解,就是遍历后通过调用append(contentsOf:)来实现平铺的能力。但是为什么会进这里呢?

其实仔细看实现会发现,方法定义是有区别的:

java 复制代码
_ transform: (Element) throws -> ElementOfResult?
_ transform: (Element) throws -> SegmentOfResult

对,区别就是传递的闭包会不会返回nil,如果编译器判断会返回nil,那么编译阶段就会确定调用上面的方法,否则就调用平铺的方法。的确很容易混淆,难怪apple要分离出compactMap

  • 设计的比较好的点
  • 巧妙的运用了闭包的返回值是否会nil,将接口方法的两个实现分离。不过个人认为这样违背了设计上的单一职责的原则,apple后续做分离是正确的。

reduce

reduce 常用于将所有元素组合成一个值。它使用一个初始的累加值和一个闭包作为参数。闭包接受两个参数,一个是之前调用的结果(对于第一次调用则是初始值),另一个是集合中的元素。返回的结果会在下次调用这个闭包时作为输入参数

code for debug

bash 复制代码
let numbers = [1, 2, 3, 4, 5]
let sum = numbers.reduce(0, { (total, num) in
    return total + num
})
print(sum) // 输出 15

当我们断点进入reduce方法的时候可以看到如下代码:

swift 复制代码
extension Sequence {
  @inlinable
  public func reduce<Result>(
    _ initialResult: Result,
    _ nextPartialResult:
      (_ partialResult: Result, Element) throws -> Result
  ) rethrows -> Result {
    // 初始化累加器为 initialResult
    var accumulator = initialResult
    // 使用 for-in 循环来遍历序列中的每一项
    for element in self {
      // 使用闭包 nextPartialResult 来处理每一个元素,更新accumulator的值
      accumulator = try nextPartialResult(accumulator, element)
    }
    // 返回累加器的结果,也就是把所有元素reduce之后的结果
    return accumulator
  }
}

逻辑并不复杂,通过持续的传入accumulator给累加器来达到累加的目的。

总结一下

Array系列的高阶函数其实是Collection的高阶函数,同样适合与Dictionary,Set等其他集合类型。整体的设计也比较巧妙,用到了很多Swift特有的Protocol特性,对我们日后设计Swift代码也会有一些启发。另外,了解了高阶函数的原理对于我们对高阶函数的理解会更深刻,合理使用后续能简化我们日常的代码。

相关推荐
大熊猫侯佩16 小时前
由一个 SwiftData “诡异”运行时崩溃而引发的钩深索隐(五)
swiftui·swift·apple watch
大熊猫侯佩2 天前
由一个 SwiftData “诡异”运行时崩溃而引发的钩深索隐(三)
数据库·swiftui·swift
大熊猫侯佩2 天前
由一个 SwiftData “诡异”运行时崩溃而引发的钩深索隐(二)
数据库·swiftui·swift
大熊猫侯佩2 天前
用异步序列优雅的监听 SwiftData 2.0 中历史追踪记录(History Trace)的变化
数据库·swiftui·swift
大熊猫侯佩2 天前
由一个 SwiftData “诡异”运行时崩溃而引发的钩深索隐(一)
数据库·swiftui·swift
season_zhu2 天前
iOS开发:关于日志框架
ios·架构·swift
大熊猫侯佩3 天前
SwiftUI 中如何花样玩转 SF Symbols 符号动画和过渡特效
swiftui·swift·apple
大熊猫侯佩3 天前
SwiftData 共享数据库在 App 中的改变无法被 Widgets 感知的原因和解决
swiftui·swift·apple
大熊猫侯佩3 天前
使用令牌(Token)进一步优化 SwiftData 2.0 中历史记录追踪(History Trace)的使用
数据库·swift·apple
大熊猫侯佩3 天前
SwiftUI 在 iOS 18 中的 ForEach 点击手势逻辑发生改变的解决
swiftui·swift·apple