[ WWDC2018 ] - 高效使用集合 Using Collections Effectively

一个没有集合的世界

假如世界没有集合

假如没有Array

假如, 我们要定义一个熊, 我们可以

let bear1 = "Grizzly"  

如果, 我们要四个呢?

let bear1 = "Grizzly"  
let bear2 = "Panda"  
let bear3 = "Polar"  
let bear4 = "Spectacled"  

现在, 让我们尝试打印出来

let bear1 = "Grizzly"  
let bear2 = "Panda"  
let bear3 = "Polar"  
let bear4 = "Spectacled" 

print("\(bear1) bear") // Grizzly bear  
print("\(bear2) bear") // Panda bear  
print("\(bear3) bear") // Polar bear  
print("\(bear4) bear") // Spectacled bear  

我们需要不断的做重复的事情

假如没有Dictionary

让我们继续上面的例子, 现在我们有一个记录每个熊的喜好的函数

func habitat(for bear: String) -> String? {  
    if bear == "Polar" { 
        return "Arctic" 
    } else if bear == "Grizzly" { 
        return "Forest" 
    } else if bear == "Brown" { 
        return "Forest" 
    } else if /* all the other bears */ 
    ... 
    return nil 
} 

我们仍然有很多重复的事情需要做.

一个拥有集合的世界

当我们引入集合的概念, 上面的事情, 就变得清晰明亮了

let bear = ["Grizzly", "Panda", "Polar", "Spectacled"]  
let habitats = ["Grizzly": "Forest", "Polar": "Arctic"] 


for bear in bears {  
    print("\(bear) Bear") 
} 

let bear = bears[2]  
let habitat = habitats[bear] ?? ""  
print("\(bear) bears live in the \(habitat)") 

集合有很多相同的行为和属性, 于是我们把它们抽象出来, 作为集合协议.

访问集合

集合有很多应用, 例如链表, 红黑树等等, 他们都有一个起始位置, 一个终止位置, 可以通过下表访问任意位置的元素 WX20180614-204452@2x.png

代码展示

protocol Collection : Sequence { 

    // 集合中元素的类型
    associatedtype Element 

    // 索引类型, 需要遵守Comparable协议
    associatedtype Index : Comparable 

    // 遍历时所用到的方法了, 即通过索引查询到对应的元素
    subscript(position: Index) -> Element { get } 

    // 开始索引
    var startIndex: Index { get } 

    // 结束索引
    var endIndex: Index { get } 

    // 通过一个索引, 获取它后面的索引
    func index(after i: Index) -> Index 
} 

这里用到了associatedtype关键字, 在Swift协议定义的时候, 会看到使用这个关键字, 你可以认为这是一个占位符, 具体的类型直到被用到的时候才会确定. 但是有时候我们需要规定这个占位符要有一些能力, 比如这里的Index, 他就需要遵守Comparable协议.

接下来的一个方法, 是通过索引访问到对应的元素

startIndex, endIndex, 表示了集合的边界

最后这个方法, 可以通过索引来获取下一个元素的索引.

集合的扩展

WX20180614-210500@2x.png

这张图是一些集合的扩展, 放眼望去, 有一些我们会经常用到的方法或属性, 例如:

  • first, 集合的第一个元素
  • last, 集合的最后一个元素
  • isEmpty, 集合是否是空的
  • count, 集合元素个数

用于遍历的

  • forEach
  • makeIterator()

一些高阶函数

  • map
  • filter
  • reduce

当然, 我们也可以做一些自己的扩展

扩展DIY

系统提供的遍历是逐个元素遍历, 现在, 让我们来实现一个隔元素访问的功能.

WX20180614-211836@2x.png

extension Collection { // 扩展集合协议  
    func everyOther(_ body: (Element) -> Void) { 
        // 获取首元素索引
        let start = self.startIndex 
        // 获取末尾元素索引
        let end = self.endIndex 
        var iter = start 
        // 未走到末尾
        while iter != end {
            // 执行外部的闭包 
            body(self[iter])
            // 获取当前元素的下一个索引
            let next = index(after: iter) 
            // 索引是否走到末尾
            if next == end { break } 
            // 将当前索引指向next的下一个
            iter = index(after: next) 
        } 
    } 
} 

(1...10).everyOther { print($0) } 

继承关系

WX20180614-212511@2x.png

实际上, 除了Collection以外, 我们还有很多继承自Collection的协议, 例如,

  • BidirectionalCollection 双向集合, 可以向前访问元素, 当然, 它继承自Collection, 也可以向后访问元素.

  • RandomAccessCollection 随机访问集合, 提供了复杂度为O(1)的访问方法, 当然, 它也有向前和向后访问元素的能力

  • MutableCollection 可变集合, 提供了修改集合元素的能力

  • RangeReplaceCollection 范围替换集合, 提供了通过指定范围替换元素的能力

索引

我们可以通过索引的方式来访问集合中的元素, 例如, 我们要访问集合中的第一个元素,

访问第一个元素

通过下标进行直接访问

使用array[0]访问第一个元素, 当然没有问题, 可是如果我们扩展开来, 如果给的集合不是数组, 而是一个set, 那么, 这样的方式就行不通了.

通过索引进行访问

使用set[set.startIndex]进行访问, 这样就可以了, 但是, 你需要注意潜在的问题, 你需要判空, 需要判断越界, 诸如此类

first

好在苹果的工程师为这些常用的元素访问留了方便的方式我们可以使用set.first进行获取. 而且不用担心那些潜在的问题

访问第二个元素

当然, 苹果工程师也无法预测到所有的情况, 比如我们想要获得第二个元素. 这时候, 就需要进行DIY了

通过下标直接访问

WX20180614-220150@2x.png

WX20180614-220200@2x.png

显然, 不能通过这两种方式来进行获取, 因为我们之前说到, Index这个占位符并不一定是Int, 而是一个遵守了Comparable的类型.

WX20180614-220223@2x.png

切片

那么, 对于上面的例子来说, 我们有没有更加易于维护或者说更加优雅的实现方式呢?

假如我们去掉首元素, 然后再获取新得到的集合的第一个元素, 那么, 就可以优雅的实现了.

WX20180614-221939@2x.png

那么, dropFirst所产生的对象, 就是一个切片, 在WWDC中, 将它比喻成了一个buffer.

注意内存

值得注意的是, 持有切片, 将使得即便将原来的集合置空, 内存也不会释放. 这里, 我的理解是这样的, 切片是一个 原有集合 + 映射关系 的产物. 所以, 除非将切片也置空, 否则, 原有集合并不会被释放.

共享索引

WX20180614-221957@2x.png

延迟计算

WX20180614-222457@2x.png

经过这样的一套操作, 我们计算了4004个元素, 如果我们后面还有一些其他的操作, 更糟糕的是, 如果我们最终只是取取first, 这样, 前面生成的那些元素, 都成为了浪费.

这时, 我们可以通过lazy关键字, 可以规避这样的浪费 WX20180614-223243@2x.png

可以看到, 使用lazy后, 刚才的遍历过程, 变成了组织一个新集合的过程,

WX20180614-223222@2x.png

只有在first进行计算的时候, 才进行计算

让我们通过一个更直观的例子来体验

验证

import UIKit

class ViewController: UIViewController {

    var arr = Array<Int>()
    var result: Int?

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        for i in 0..<10000000 {
            arr.append(i)
        }
    }

    @IBAction func slice(_ sender: UIBarButtonItem) {

        result = arr.map { $0 * 2 }.map { $0 + 5 }.filter { $0 < 100 }.first
        print(result)
    }


}

在这个例子中, 为了验证非lazy情况下浪费的资源包含哪些, 以及上文中提到的 切片是一个 原有集合 + 映射关系 的产物 的结论验证

我使用的模拟器进行测试, 在初始内存为125M, 当我点击按钮, 开始进行计算的时候, 可以看到CPU和内存都有飙升, 内存波动飙升到274M后, 下降到125M. CPU则飙升到100%后, 下降到0.

而使用lazy后, 内存和CPU几乎没有变化

使用情况

  • 链式计算
  • 仅仅需要求值结果中的某一部分
  • 本身的计算不影响外部

multi & safe

可变集合

使用了失效索引 WX20180614-224232@2x.png

WX20180614-224242@2x.png

复用写之前的索引 WX20180614-224440@2x.png

WX20180614-224451@2x.png

如何规避

  • 在持有索引和切片时, 处理要谨慎
  • 集合发生改变时, 要更新索引后再使用
  • 在需要索引和切片的情况下才对其进行计算

多线程访问

WX20180614-224839@2x.png

如何规避

  • 使用单线程进行访问
  • 使用Thread Sanitizer

其他建议

如果可以, 尽量使用带capacity的初始化函数去初始化你的集合, 因为这样节省一些不必要的内存开销, 虽然这并不能节省多少, 但是想象你的项目中有成千上万个集合对象, 他们可以省出一个相当可观的内存数量.

桥接

Foundation Collection

WX20180614-225245@2x.png

值类型与引用类型

在swift 中的集合, 都是值类型, 为什么这么设计呢? 让我们先看一组图片

WX20180614-225539@2x.png

WX20180614-225609@2x.png

引用类型的操作

  1. 我们有一个集合x
  2. 当我们执行 let y = x 的时候, y指针会指向x所指向的内存空间
  3. 当我们继续执行append的时候, x和y所指的集合新增一个元素

值类型的操作

  1. 我们有一个集合x
  2. 当我们执行 let y = x 的时候, y指针会指向x所指向的内存空间
  3. 当我们继续执行append的时候, y所指向的集合将x内容拷贝进集合, 并将新元素放入集合

对于值类型来说, 这样有什么好处呢? 因为在现代CPU在设计的时候, 采用了缓存机制, 可以快速的访问连续区域的地址. 而值类型的这种操作, 各个元素之间的内存是相连的, 而引用类型的则不是.

Swift与Foundation Collection的桥接

桥接就是把一种语言的某个类型转换为另一种语言的某个类型. 桥接在swift与OC之间是双向的, 也是必要的, 当然, 也是有一些资源开销的, 可以通过Instrument进行测量

WX20180614-231112@2x.png

WX20180614-231133@2x.png

这里的桥接发生在

  • NSMutableAttributedString取string上, return bridge.
  • 需要传入一个NSString, 的参数类型桥接 param bridge

WX20180614-231207@2x.png

虽然在这里也发生了桥接, 但是集合可以忽略不计

建议

建议在开发过程中, 尽量避免Swift的collection与NS以及CF的collection进行混用. 由此, 笔者猜测, swift中的类型去掉NS头的原因, 就是为了方便辨认是否需要桥接, 当然, 如果确切的知道是否存在桥接的损耗, 还是需要通过Instrument进行测量.

最后

WX20180614-232351@2x.png

本文总阅读量