为什么在懒惰地评估过滤器(_ :)的谓词被调用了这么多次?

时间:2017-01-30 15:59:53

标签: swift

我看到an answerthis question,在其第一次修订中,代码与此类似:

let numbers = Array(0 ..< 50)

let result = numbers.lazy
    .filter {
        // gets called 2-3x per element in the range (0...15)!
        print("Calling filter for: \($0)")
        return $0 % 3 == 0
    }
    .prefix(5)

print(Array(result)) // [0, 3, 6, 9, 12]

通过使用延迟过滤器集合,能够过滤满足给定谓词的numbers的前5个元素(在这种情况下,可被3整除),而无需评估 numbers数组中的每个元素。

但是,答案然后回答说filter(_:)的谓词可以被调用每个元素多次(对于范围1 ... 15中的元素是3次,对于0来说是两次,结果是)。 / p>

这种过滤器的惰性评估效率低下的原因是什么?有没有办法避免多次评估同一元素?

1 个答案:

答案 0 :(得分:15)

问题

这里的第一个罪魁祸首是通过使用prefix(_:)来延迟过滤器集合的切片 - 在这种情况下,返回BidirectionalSlice LazyFilterBidirectionalCollection }}

通常,Collection的切片需要存储基本集合,以及对切片“有效”的索引范围。因此,为了创建LazyFilterBidirectionalCollection的切片以查看前5个元素,存储的索引范围必须为startIndex ..< indexAfterTheFifthElement

为了获得indexAfterTheFifthElementLazyFilterBidirectionalCollection必须遍历基本集合(numbers)才能找到符合谓词的第6个元素(你可以看到the exact implementation of the indexing here)。

因此,需要针对谓词检查上述示例中0 ... 15范围内的所有元素,只是为了创建一个惰性过滤器集合的切片。

第二个罪魁祸首是Array的{​​{1}},它接受​​init(_:)与数组的Sequence类型相同类型的元素。 The implementation of this initialiser在序列上调用Element,对于大多数序列,forwards the call to this function

_copyToContiguousArray()

这里的问题是internal func _copySequenceToContiguousArray<S : Sequence> (_ source: S) -> ContiguousArray<S.Iterator.Element> { let initialCapacity = source.underestimatedCount // <- problem here var builder = _UnsafePartiallyInitializedContiguousArrayBuffer<S.Iterator.Element>( initialCapacity: initialCapacity) var iterator = source.makeIterator() // FIXME(performance): use _copyContents(initializing:). // Add elements up to the initial capacity without checking for regrowth. for _ in 0..<initialCapacity { builder.addWithExistingCapacity(iterator.next()!) } // Add remaining elements, if any. while let element = iterator.next() { builder.add(element) } return builder.finish() }。对于普通序列,这只有一个返回0的默认实现 - 但是,对于集合,它有一个默认实现,它获取集合的underestimatedCount(我进入这个in more detail here)。

count count的默认实现(Collection将在此处使用)只是:

BidirectionalSlice

对于我们的切片,它将遍历指数public var count: IndexDistance { return distance(from: startIndex, to: endIndex) } ,从而重新评估0 ... 15范围内的元素。

最后,制作切片的迭代器,并迭代完成,将序列中的每个元素添加到新数组的缓冲区中。对于indexAfterTheFifthElement,这将使用BidirectionalSlice,它只是通过推进索引并输出每个索引的元素来工作。

这个遍历索引的原因是没有重新评估结果的第一个元素之前的元素(在问题的示例中注意,0被评估为少一次)是到期的因为它不能直接访问IndexingIterator的{​​{1}},has to evaluate all elements up to the first element in the result。相反,迭代器可以从切片本身的起始索引开始工作。

解决方案

简单的解决方案是避免切片延迟过滤器集合以获取其前缀,而是懒惰地应用前缀。

实际上有两个startIndex的实现。一个是provided by Collection,并返回LazyFilterBidirectionalCollection(这是大多数标准库集合的某种形式的切片)。

另一个是provided by Sequence,它返回一个prefix(_:) - 它在引擎盖下使用_PrefixSequence的基本序列,它只需要一个迭代器并允许迭代它直到给定已迭代过多个元素 - 然后停止返回元素。

对于惰性集合,SubSequence的这种实现非常好,因为它不需要任何索引 - 它只是懒惰地应用前缀。

因此,如果你说:

AnySequence

prefix(_:)(直到第5场比赛)的元素只有let result : AnySequence = numbers.lazy .filter { // gets called 1x per element :) print("Calling filter for: \($0)") return $0 % 3 == 0 } .prefix(5) 的谓词传递给numbers的初始化时才会被评估一次,因为你'强制Swift使用filter(_:)的{​​{1}}默认实现。

在给定的延迟过滤器集合上阻止所有索引操作的简单方法是简单地使用延迟过滤器序列 - 这可以通过仅包装集合来完成您希望在Array

中执行延迟操作
Sequence

但请注意,对于双向集合,这可能会对集合的 end 上的操作产生负面影响 - 因为整个序列必须通过迭代才能到达终点。

对于prefix(_:)AnySequence这样的操作,在序列上使用延迟集合(至少对于小输入)可能更有效,因为它们可以简单地从末尾索引该系列。

尽管与所有与性能相关的问题一样,您应首先检查这是否是一个问题,然后再运行您自己的测试,看看哪种方法更适合您的实施。

结论(TL; DR)

所以,在所有这些之后 - 这里要学到的教训是,你应该警惕切片延迟过滤器集合可以重新评估基本集合的每个元素直到最终索引,切片可以'视图。

通常,更倾向于将延迟过滤器集合视为序列,而不能将其编入索引,因此意味着延迟操作无法评估任何元素(这样做会破坏性地迭代它们),直到急切的行动发生了。

但是,您应该警惕这样一个事实:您可能会牺牲能够从最终索引集合,这对let numbers = Array(0 ..< 50) let result = AnySequence(numbers).lazy .filter { // gets called 1x per element :) print("Calling filter for: \($0)") return $0 % 3 == 0 } .dropFirst(5) // neither of these will do indexing, .prefix(5) // but instead return a lazily evaluated AnySequence. print(Array(result)) // [15, 18, 21, 24, 27] 等操作很重要。

最后,值得注意的是,这不是suffix(_:)等懒惰视图的问题,因为它们的元素不依赖于先前元素的“结果” - 因此它们可以在常量中编入索引时间,如果他们的基本集合是dropLast(_:)