【问题标题】:Does joined() or flatMap(_:) perform better in Swift 3?join() 或 flatMap(_:) 在 Swift 3 中表现更好吗?
【发布时间】:2016-12-31 02:38:07
【问题描述】:

我很好奇joined().flatMap(_:)在展平多维数组时的性能特点:

let array = [[1,2,3],[4,5,6],[7,8,9]]
let j = Array(array.joined())
let f = array.flatMap{$0}

它们都将嵌套的array 扁平化为[1, 2, 3, 4, 5, 6, 7, 8, 9]。我应该更喜欢其中一个来提高性能吗?另外,有没有更易读的方法来编写调用?

【问题讨论】:

  • joined 与旧的flatten 相同。所以这里没有什么新鲜事。你没有新的选择可做。如果这个问题之前没有困扰您,为什么现在要困扰您?
  • @matt 我个人一直在使用flatMap,但从未使用过flatten。我看到 joined 已添加到 Swift 更改日志中,我想知道我是否应该使用它而不是 flatMap。然后我看到我必须将加入的调用包装在 Array 初始化程序中。我怀疑这会增加第二轮计算,因此效率会降低。当我写这个问题时,我知道joined 替换了flatten。也就是说,感谢您解释为什么有两个 close 票。我已经改写了这个问题,以消除加入的实施是新的暗示。

标签: arrays swift functional-programming swift3


【解决方案1】:

TL;博士

当仅涉及展平二维数组时(未应用任何转换或分隔符,请参阅 @dfri's answer 了解有关该方面的更多信息),array.flatMap{$0}Array(array.joined()) 在概念上是相同的并且具有相似的性能。


flatMap(_:)joined() 之间的主要区别(注意这不是一个新方法,它有just been renamed from flatten())是joined() 总是延迟应用(对于数组,它返回一个特殊的@987654323 @)。

因此,就性能而言,在您只想迭代平展序列的一部分(不应用任何转换)的情况下,使用 joined() 而不是 flatMap(_:) 是有意义的。例如:

let array2D = [[2, 3], [8, 10], [9, 5], [4, 8]]

if array2D.joined().contains(8) {
    print("contains 8")
} else {
    print("doesn't contain 8")
}

因为joined() 是延迟应用的,而contains(_:) 将在找到匹配项时停止迭代,所以只有前两个内部数组必须“展平”才能从二维数组中找到元素8。虽然,作为@dfri correctly notes below,您也可以通过使用LazySequence/LazyCollection 懒惰地应用flatMap(_:)——可以通过lazy 属性创建。这对于懒惰地应用变换和展平给定的 2D 序列是理想的。

在完全迭代joined() 的情况下,它在概念上与使用flatMap{$0} 没有区别。因此,这些都是展平二维数组的有效(并且概念上相同)的方法:

array2D.joined().map{$0}

Array(array2D.joined())

array2D.flatMap{$0}

就性能而言,flatMap(_:) is documented 的时间复杂度为:

O(m + n),其中m是这个序列的长度,n是结果的长度

这是因为its implementation 很简单:

  public func flatMap<SegmentOfResult : Sequence>(
    _ transform: (${GElement}) throws -> SegmentOfResult
  ) rethrows -> [SegmentOfResult.${GElement}] {
    var result: [SegmentOfResult.${GElement}] = []
    for element in self {
      result.append(contentsOf: try transform(element))
    }
    return result
  }
}

由于append(contentsOf:) 的时间复杂度为 O(n),其中 n 是要追加的序列的长度,我们得到 O(m + n) 的整体时间复杂度,其中 m 将是总长度附加所有序列,n是二维序列的长度。

对于joined(),没有记录时间复杂性,因为它是延迟应用的。但是,要考虑的主要源代码位是 the implementation of FlattenIterator,它用于迭代 2D 序列的展平内容(这将在使用 map(_:)Array(_:) 初始化程序与 joined() 时发生)。

  public mutating func next() -> Base.Element.Iterator.Element? {
    repeat {
      if _fastPath(_inner != nil) {
        let ret = _inner!.next()
        if _fastPath(ret != nil) {
          return ret
        }
      }
      let s = _base.next()
      if _slowPath(s == nil) {
        return nil
      }
      _inner = s!.makeIterator()
    }
    while true
  } 

这里_base 是基本二维序列,_inner 是来自内部序列之一的当前迭代器,_fastPath_slowPath 是对编译器的提示,以帮助进行分支预测。

假设我正确地解释了这段代码并迭代了整个序列,这也有 O(m + n) 的时间复杂度,其中 m 是序列的长度,n 是结果的长度.这是因为它通过每个外部迭代器和每个内部迭代器来获取展平的元素。

因此,就性能而言,Array(array.joined())array.flatMap{$0} 都具有相同的时间复杂度。

如果我们在调试版本 (Swift 3.1) 中运行快速基准测试:

import QuartzCore

func benchmark(repeatCount:Int = 1, name:String? = nil, closure:() -> ()) {
    let d = CACurrentMediaTime()
    for _ in 0..<repeatCount {
        closure()
    }
    let d1 = CACurrentMediaTime()-d
    print("Benchmark of \(name ?? "closure") took \(d1) seconds")
}

let arr = [[Int]](repeating: [Int](repeating: 0, count: 1000), count: 1000)

benchmark {
    _ = arr.flatMap{$0} // 0.00744s
}

benchmark {
    _ = Array(arr.joined()) // 0.525s
}

benchmark {
    _ = arr.joined().map{$0} // 1.421s
}

flatMap(_:) 似乎是最快的。我怀疑joined() 变慢可能是由于FlattenIterator 中发生的分支(尽管编译器的提示最小化了这个成本)——尽管为什么map(_:) 这么慢,我不太确定。肯定有兴趣知道其他人是否对此了解更多。

但是,在优化的构建中,编译器能够优化掉这个巨大的性能差异;为所有三个选项提供相当的速度,尽管flatMap(_:) 仍然是最快的几分之一秒:

let arr = [[Int]](repeating: [Int](repeating: 0, count: 10000), count: 1000)

benchmark {
    let result = arr.flatMap{$0} // 0.0910s
    print(result.count)
}

benchmark {
    let result = Array(arr.joined()) // 0.118s
    print(result.count)
}

benchmark {
    let result = arr.joined().map{$0} // 0.149s
    print(result.count)
}

(请注意,执行测试的顺序会影响结果 - 以上两个结果都是以各种不同顺序执行测试的平均值)

【讨论】:

  • 不错的答案!请注意,您可以通过在 lazy. 前面加上前缀来懒惰地应用 flatMapmap(例如 arr.lazy.flatMap{$0})。无法解释为什么地图版本 map 看起来要慢得多,但我会仔细检查(您很可能知道这一点):确保在实际项目中而不是在操场上执行基准测试。
  • @dfri 好点!完全忘记了LazySequence/LazyCollections——我已经更新了我的答案来注意这一点,谢谢你提到它。基准测试确实是在没有编译器优化的完整项目中执行的(我从不使用游乐场):)
  • 感谢您的基准测试@Hamish。这是一个很酷的 sn-p 代码,可以方便使用!另外,感谢您深入了解实现细节
  • 乐于帮助@BenMorrow :)
  • 我做了一个简单的测试,flatMap比join的快很多。
【解决方案2】:

Swiftdoc.org documentation of Array (Swift 3.0/dev) 我们读到[emphasis mine]:

func flatMap<SegmentOfResult : Sequence>(_: @noescape (Element) throws -> SegmentOfResult)

返回一个数组,其中包含调用 给定这个序列的每个元素的变换。

...

其实s.flatMap(transform)等价于Array(s.map(transform).flatten())

我们还可以在 Swift 源代码(生成 Swiftdoc ...)中查看两者的实际实现

最值得注意的是后一个源文件,其中使用的闭包 (transform) 不会产生的 flatMap 实现和可选值(如这里的情况)都被描述为

/// Returns the concatenated results of mapping `transform` over
/// `self`.  Equivalent to 
///
///     self.map(transform).joined()

从上面(假设编译器可以很聪明地使用简单的自我{ $0 }transform),似乎在性能方面,这两种选择应该是等效的,但是joined 确实如此,imo,更好地展示操作的意图


除了语义上的意图之外,还有一个明显的用例,其中joinedflatMap 更可取(但不完全可比):使用joinedinit(separator:) 初始化器来连接带有分隔符的序列:

let array = [[1,2,3],[4,5,6],[7,8,9]]
let j = Array(array.joined(separator: [42]))

print(j) // [1, 2, 3, 42, 4, 5, 6, 42, 7, 8, 9] 

使用flatMap 的相应结果并不那么整洁,因为我们明确需要在flatMap 操作之后删除最终的附加分隔符(两种不同的用例,有或没有尾随分隔符)

let f = Array(array.flatMap{ $0 + [42] }.dropLast())
print(f) // [1, 2, 3, 42, 4, 5, 6, 42, 7, 8, 9] 

另请参阅 Erica Sadun 讨论 flatMapflatten() 的有点过时的帖子(注意:joined() 在 Swift flatten())。

【讨论】:

  • Swift 2.x 及更早版本现在是“Swift
  • @rickster 啊不要删除我的
  • 感谢您的研究,@dfri。我同意.joined() 表达意图感觉更好。我只是希望我不必将它包装在 Array() 初始化程序中。这就是我想为额外的懒惰好处付出的代价
猜你喜欢
  • 2020-10-03
  • 2018-04-12
  • 2017-05-15
  • 2014-02-20
  • 2015-10-26
  • 2017-09-28
  • 1970-01-01
  • 2010-09-18
  • 2021-04-20
相关资源
最近更新 更多