Swift中的惰性序列及其工作方式

您也可以在我的博客SwiftRocks中阅读此帖子,最好在此处进行格式化!

诸如mapfilter类的高阶函数的用法在Swift项目中非常常见,因为它们是简单的算法,可让您将广泛的构想转换为简单的单行代码。 不幸的是,他们并不能解决所有问题-至少不是在其默认实现中。 高阶函数很渴望 :它们立即使用闭包并返回一个新数组,无论您需要提前返回还是仅使用特定元素。 当性能很重要时,您可能会被迫编写专门的助手方法来避免高阶的急切性质:

 让地址= getFirstThreeAddresses(withIdentifier:“ HOME”) 
func getFirstThreeAddresses(withIdentifier identifier:String)-> [地址] {
//不使用.filter {}。prefix(3),因为我们需要早日返回
变量地址= [地址]()
用于allAddresses中的地址,其中address.identifier ==标识符{
address.append(地址)
如果addresss.count == 3 {
打破
}
}
寄信人地址
}

幸运的是,Swift有一种使用高阶函数的方式,同时仍然保持了辅助方法的改进性能-可以通过lazy属性访问Swift标准库SequencesCollections惰性版本。

这些惰性变体的工作方式与常规变体一样,但有一点不同:它们具有诸如mapfilter类的方法的自定义实现,以使它们懒惰地工作-意味着实际的计算只会在需要它们的地方和时间进行。

  let allNumbers = Array(1 ... 1000)let normalMap = allNumbers.map {$ 0 * 2} //将映射整个序列,而不管您需要做什么。letlazyMap = allNumbers.lazy.map {$ 0 * 2} //这里什么也没有发生。print(lazyMap [0])//打印2,但是其他所有内容都保持不变! 

尽管起初有些令人恐惧,但它们使您可以减少大多数for循环,并尽早返回单层。 例如,以下是用于查找满足谓词的第一个元素时它与其他方法的比较:

  // //具有10000个元素的[Address]中的allAddresses,并将“ HOME”地址放在开头的地址上= allAddresses.filter {$ 0.identifier ==“ HOME”} .first //〜0.15秒 

// 与

func firstAddress(withIdentifier标识符:字符串)->地址? {
//现在,您可以使用标准库的first(where :)方法,
//,但假装不存在。
用于allAddresses中的地址,其中address.identifier ==标识符{
退货地址
}
返回零
}

let address = firstAddress(withIdentifier:“ HOME”)//即时

// 与

let address = allAddresses.lazy.filter {$ 0.identifier ==“ HOME”} .first //也可以用更少的代码即时实现!

除了编写较短的代码外,它们通常对于延迟操作以使代码更易于阅读也非常有用。 假设您有一个购物应用程序,如果用户花费太长时间才能完成购买,则该应用程序会显示来自本地数据库的优惠信息:

  let offerViews = offerJson.compactMap {database.load(offer:$ 0)} .map(OfferView.init)// O(n)var currentOffer = -1 

func displayNextOffer(){
保护currentOffer + 1 <offerViews.count else {
返回
}
currentOffer + = 1
offerViews [currentOffer] .display(atViewController:self)
}

尽管此解决方案有效,但它有一个主要缺陷:我急切地将整个offer json映射到OfferViews ,即使不能保证用户会看到其中任何一个。 如果offerJson是一个小型阵列,这并不是真正的问题,但是对于大型数据集,从数据库中提取所有要offerJson很快成为问题。

您可以通过将解析逻辑移动到displayNextOffer()来仅映射必要的OfferViews ,但是由于现在必须保留原始报价数据,因此代码质量可能变得更难以理解:

 让offerJson:[[String:Any]] = // 
var currentOffer = -1

func displayNextOffer(){
保护currentOffer + 1 <offerViews.count else {
返回
}
currentOffer + = 1
后卫let offer = database.load(offer:OffersJson [currentOffer])else {
返回
}
让offerView = OfferView(要约:要约)
offerView.display(atViewController:self)
}

通过使用lazy ,仅当在displayNextOffer()访问数组位置时才映射当前offerView ,从而保持第一个实现的读取质量和第二个实现的性能!

  let offerViews = offerJson.lazy.compactMap {database.load(offer:$ 0)} .map(OfferView.init)//这里什么都没有发生!var currentOffer = -1 

func displayNextOffer(){
保护currentOffer + 1 <offerViews.count else {
返回
}
currentOffer + = 1
offerViews [currentOffer] .display(atViewController:self)//仅在此处进行映射,仅适用于所需的元素。
}

但是请注意,惰性序列没有缓存。 这意味着,如果两次访问offerViews[0]则整个映射过程也会发生两次。 如果您需要多次访问元素,请将它们移动到常规数组中。

尽管它们在使用时看起来像魔术,但是内部的惰性序列实现并不像看起来那样复杂。

如果我们打印第二个示例的类型,我们可以看到,即使我们延迟映射的Collection像常规Collection一样工作,我们仍在处理不同的类型:

  let lazyMap = Array(1 ... 1000).lazy.map {$ 0 * 2} print(lazyMap)// LazyMapCollection <Array ,Int> let lazyMap = Array(1 ... 1000).lazy.filter {$ 0%2 == 0} .map {$ 0 * 2} print(lazyMap)// LazyMapCollection <LazyFilterCollection <Array >,Int> //在这种情况下,第一个通用参数是lazy的内部Collection操作,而第二个是map操作的变换函数。 

查看Swift的源代码,我们可以发现,不急于来自以下事实:这些方法除了返回新类型外实际上不做任何事情:

(我将使用LazySequence代码示例而不是LazySequence代码示例,因为它们本质上要简单得多。如果您不知道常规Sequences工作方式,请查看此Apple页面。)

 扩展LazySequenceProtocol { 
///在此“序列”上返回“ LazyMapSequence”。 的要素
///每次读取结果时,结果都是惰性计算的
///在基本元素上调用`transform`函数。
@inlinable
公共功能地图(_转换:@逃逸(Elements.Element)-> U)-> LazyMapSequence {
返回LazyMapSequence(_base:self.elements,transform:transform)
}
}

魔术来自这些专用类型的内部实现。 例如,如果我们看一下LazyMapSequenceLazyFilterSequence ,我们可以看到它们只不过是存储操作并仅在迭代时才应用其对应的eager函数的常规Sequences

  // _base是原始序列 
扩展LazyMapSequence.Iterator:IteratorProtocol,序列{
@inlinable
公共变异函数next()->元素? {
返回_base.next()。map(_transform)
}
}扩展LazyFilterSequence.Iterator:IteratorProtocol,序列{
@inlinable
公共变异函数next()->元素? {
而让n = _base.next(){
如果_predicate(n){
返回n
}
}
返回零
}
}

如果本文在此处结束,这会很好,但是重要的是要知道惰性序列存在缺陷-特别是在基础类型为Collection

在开头的示例中,我们的方法获取满足特定谓词的前三个地址。 通过将惰性操作链接在一起,还可以将其简化为单一格式:

  let homeAddresses = allAddresses.lazy.filter {$ 0.identifier ==“ HOME”} .prefix(3) 

但是,与急切的对应示例相比,请看此特定示例的性能:

  allAddresses.filter {$ 0.identifier ==“ HOME”} .prefix(3)//〜0.11 secsArray(allAddresses.lazy.filter {$ 0.identifier ==“ HOME”} .prefix(3))//〜0.22秒 

即使lazy版本在找到这三个地址后立即停止,但其性能却比渴望的版本高出一倍!

不幸的原因是SequencesCollections之间的细微差异。 虽然给Sequence加上前缀就像将所需元素移动到单独的Array一样简单,但对Collections切片操作需要知道所需切片的end索引:

 公共函数前缀(_ maxLength:Int)-> SubSequence { 
_precondition(maxLength> = 0,“不能从集合中获取负长度的前缀”)
let end = index(startIndex,offsetBy:maxLength,limitedBy:endIndex)?? endIndex
返回self [startIndex .. <end]
} @inlinable
公共下标(bounds:Range )-> Slice {
_failEarlyRangeCheck(bounds,bounds:startIndex .. <endIndex)
返回slice(base:self,bounds:bounds)
}

问题在于,在Collection术语中, endIndex不是最后一个元素的索引,而是最后一个元素之后的索引( index(startIndex, offsetBy: maxLength) )。 对于我们的惰性filter ,这意味着要切片前三个家庭地址,我们必须找到其中四个-甚至可能不存在。

某些惰性类型的文档指出了此问题:

  ///-注意:访问`endIndex`,`last`以及任何方法的性能 
///取决于`endIndex`,或者移动索引取决于多少个元素
///满足集合开始时的谓词,可能不提供
///通常由Collection协议提供的性能。 意识到,
///因此,在$$ Self实例上的常规操作可能没有
///记录的复杂性。
公共结构LazyPrefixWhileCollection {

更糟糕的是,由于Slice只是原始Collection窗口,因此对Array的转换将调用调用延迟过滤的Collectioncount属性的函数-但由于lazy.filter(_:)操作不符合到RandomAccessCollectioncount只能通过再次遍历整个Collection来找到。

由于惰性序列缺少缓存,这导致整个过滤/切片过程再次发生。 因此,如果第四个元素不存在或与第三个元素相距太远,则lazy版本的性能将是原始元素的两倍。

好消息是,可以避免这种情况-如果您不确定惰性操作是否会在合理的时间内运行,可以通过将结果视为Sequence来保证它。 这样做的缺点是失去了BidirectionalCollection的反向迭代功能,但可以保证正向操作将再次快速进行。

  let sequence:AnySequence = allAddresses.lazy.filter {$ 0.identifier ==“ HOME”} .prefix(3) 
let result = Array(sequence)//〜0.004秒!

lazy对象的使用可以使您快速地编写高性能的复杂事物-付出的代价是需要对Swift内部有一定的了解,以防止出现重大问题。 像所有功能一样,它们在缺点相同的情况下也具有很大的优势,在这种情况下,需要了解SequencesCollections之间的主要差异才能提取出最好的。 一旦掌握,映射特定元素将变得非常简单和直观。

在我的Twitter上关注我-@rockthebruno,让我知道您想分享的任何建议和更正。

过滤器
SR-4164
LazyPrefixWhileCollection
惰性序列协议
顺序


最初发布于 swiftrocks.com