该篇博客记录了观看WWDC Session229《Using collections effectively》的内容以及一些理解。
该Session讲解了在Swift中高效使用集合的一些注意点。
假如没有集合
假如没有Arrays
假如没有Arrays,我们要表示几头熊,并且打印熊的名字,需要如下代码:
假如没有Dictionaries
假如没有Dictionaries,我们要获取熊的爱好,需要一个函数的帮助:
我们的世界存在集合(Collections)
以上的例子,使用集合的话,我们可以如下表示:
我们把集合拥有的相同的特性和算法抽象成为一个协议:Collection
Protocol Colection
在Swift中,Collection是一个可以被多次访问的、无损的、通过下标可以访问元素的序列。
Collection可以是内存中的一块连续区域、一个哈希表、一颗红黑树或者一个链表。
但是最重要的是:
- Collection拥有一个名为startIndex的初始指针,用来访问Collection的初始元素
- Collection拥有一个名为endIndex的指针,用来表示Collection的最后元素
- Collection支持多次在startIndex和endIndex之间的遍历
- Collection支持通过下标访问其中元素
代码实现如下:
代码中有几点需要注意的:
- associatedtype 表示占位,即指明一个泛型,待需要时再确定具体类型
- Index需要遵循Comparable协议
- 提供了使用下标来访问元素的方法
- 定义了startIndex和endIndex
- 提供了一个获取某个Index之后Index的方法
协议扩展
Collection允许标准库通过协议扩展定义许多强大而有用的行为,其中一部分如下:
常用属性
- first 获取第一个元素
- last 获取最后一个元素
- count 获取集合元素个数
- isEmpty 判断集合是否为空
遍历集合
- forEach
- makeIterator()
高阶函数
- map
- filter
- reduce
自定义协议拓展
我们也可以通过自定义协议拓展来为Collection增加一些更有用的方法。
Collection已经提供了遍历每个元素的方法,我们来自定义一个隔元素访问的方法:
Collection群
Swift中,提供了丰富的Collection协议结构:
- BidirectionalCollection:允许双向访问元素
- RandomAccessCollection:提供随机访问元素功能
- MutableCollection:提供修改集合元素功能
- RangeReplaceCollection:提供替换范围元素功能
Swift中其余Collection
这些类型都属于Collection,所以我们了解其中一种是如何工作的,就能够类推到所有其他Collection中。
接下来分析集合的细节:
索引(Indices)
- 每一个Collection都定义了它自己的索引
- 索引必须遵循Comparable协议
- 将索引视为不透明的
访问第一个元素
1.通过下标访问
可以通过下标访问数组第一个元素,例如array[0],但是该方法对于所有集合并不是全部适用,比如Set(Set不允许使用Int类型进行下标操作)
2.使用集合提供的下标
由于Collection全部提供了startIndex来表示Collection第一个元素的下标,所以可以使用array[array.startIndex]或者set[set.startIndex]的方法来访问第一个元素。
但是在使用这种方法时,需要注意集合是否为空,如果集合为空,那么强制使用startIndex会造成崩溃。
3.调用 first 方法
array.first和set.first
由于Collection全部提供了first方法,同时该方法返回值为optional,这表明如果Collection为空时,first方法不会出错。
访问第二个元素
我们第一印象时通过下标直接访问,如下图:
我们可以看到,通过直接使用下标或者对下标直接偏移来获取第二个元素是不可行的,原因在于并不是所有的Collection的下标都支持Int类型。
自定义方法访问第二个元素
我们可以通过extension来自定义方法来获取Collection的第二个元素:
首先,我们看一下方法名:
由于Collection中不一定存在第二个元素,该方法返回值为optional。
接下来看方法的实现:
实现思路为:
- 判断Collection是否为空,为空则无需返回值(Collection为空时,startIndex和endIndex相等且为0)
- 根据Collection提供的index(after:)方法获取到指向第二个元素的下标
- 判断获取到的下标是否越界(若Collection不为空,endIndex指向Collection最后一个元素的下一个位置)
- 此时下标有效,通过下标获取到对应的第二个元素
切片(Slices)
切片可以很好的解决前面获取第二个元素的问题,不过在看如何解决这个问题之前,我们先看一下切片的工作原理。
切片工作原理
- 切片是描述集合中一部分的一种类型
- 切片有自己的startIndex和endIndex,同时切片独立于原始集合
- 切片由于与原始集合共享索引,不占用额外的存储空间,所以十分高效
- 当切片被下标时,被下标的元素从原始buffer中读出
下面有一段代码可以看出切片的工作原理:
工作流程如下动图:
其中dropFirst方法返回的就是array的一个切片。
切片解决获取Collection第二个元素问题
此时可以使用切片来解决获取Collection第二个元素的问题:
Collection可以有自己类型的切片
切片会持有原始Collection的内存
切片是与产生它的原始Collection共享同一内存的,如下:
在以上代码中,即使在最后执行 array = [] 来将array原始内存释放之后,我们发现切片依然可以正常使用,原因在于,即使 array = [] 之后,原始内存依然被切片持有,并没有释放。
Swift中提供的解决方法如下:
通过使用切片来创建一个新的数组,然后将切片所持有的内存释放,这样就会将原先的内存真正释放掉。
延迟计算(Lazy)
延迟计算(Lazy)在某些特殊的场景下是十分有用的。
按需计算
例如在下面的例子中:
由于Swift中,默认方法调用是急切的(即按照要求消耗输入和输出),所以在该例子中,我们会在map调用时分配4000个内存,在filter调用时分配4个内存,总共会分配4004个内存,但是我们最终只需要4个内存的结果,这样就浪费了大量的消耗。
延迟计算(Lazy)可以很好的解决这个问题,代码如下:
在使用过程中,它的流程如下:
- 通过对Collection使用lazy方法,会对Collection进行包装,包装为
LazyCollection<Range<Int>>
对象,除此之外,不会做任何事情,包括申请内存 - 在使用map方法时,会再次进行包装,包装为
LazyMapCollection<Range<Int>>
对象,除此之外,不会做任何事情,包括申请内存 - 在调用filter方法时,会再次进行包装,包装为
LazyFilterCollection<LazyMapCollection<Range<Int>>>
对象,除此之外,不会做任何事情,包括申请内存
接下来我们向filter查询第一个元素,过程如下:
- 向filter查询第一个元素,filter并不知道第一个元素,因为它包装的是一个map
- filter向包装的map查询第一个元素,map也并不知道第一个元素,因为它包装的是一个Collection
- map向包装的Collection查询第一个元素,Collection提供第一个元素并交由map进行处理
- map将处理好的元素交由filter进行处理
- filter将处理结果交给程序
整个过程如下图:
避免创建中间存储
Lazy可以避免在使用时进行中间存储,如图我们筛选 Gummy Bears 这个元素:
当我们访问第一个元素时,lazy会依次读取Collection中元素,筛选出 Gummy Bears,即依次访问 Grizzly、Panda、Spectacled、Gummy Bears,然后返回 Gummy Bears。
但是当我们再次调用print(redundantBears.first!)
时,会再次进行上面的遍历,即每进行一次查询,就会进行一次遍历。这样的设计就是不保存中间值。
如果我们想要保存中间值,可以采用下面的方法:
在上述代码中,let filteredBears = Array(redundantBears)
会另lazy进行一次完整的查询,并将查询结果保存至filteredBears中,避免多次计算遍历。
延迟计算(Lazy)使用场景
- 链式计算
- 只需要结果中的一部分
- 不会影响到原始数据
- 避免API的边界,即在跨越API边界时,要将Lazy重新具体化为一个Collection
MutableCollection&RangeReplaceableCollection
- MutableCollection可以使用
subscript(_: Self.Index) -> Element { get set}
方法来对数据进行读取和替换。 - RangeReplaceableCollection可以使用
replaceSubrange(_:, with:)
方法来对数据进行替换、删除或者增加。
有时我们在使用Collection时会发生一些Crash,接下来就通过一些例子来看一下:
修改Collection的情况下
看第一个例子:
此例子中,访问的下标越界了,建议的处理方式为:
看第二个例子:
此例子中,使用了无效的索引,建议的处理方式为:
建议
- 在保存索引/切片时要谨慎
- 在更改了集合之后,索引/切片变无效
- 在需要索引/切片时再进行计算
多线程下修改Collection
看下面一个例子:
这段代码可能存在问题,即有时会只添加一只熊到sleepingBears数组中,原因如下:
建议的方法为:使用串行队列来保证某一时刻只有一个线程对sleepingBears进行修改,如下
建议
- 使用单线程来访问Collection
- 若不能使用单线程:1.确保操作之间相互排斥 2.使用TSAN(Thread Sanitizer)进行调试
桥接(Bridging)
Foundation Collections
Foundation Collections全部是引用类型的Collection
Swift中的Collection为值类型
下面一张动图展示了值类型与引用类型的区别:
桥接
- 可以将两种不同语言(即不同运行状态)进行转换
- 桥接转换是双向的
- 桥接是有必要的,但同时,桥接也是有消耗的
桥接是如何工作的呢:
- 首先会创建一个与被桥接对象等同大小的内存
- 遍历被桥接对象,并依次进行桥接
如果被桥接对象内部对象也是需要桥接的,那么需要递归的去桥接内部对象
- Eager桥接:如果被桥接对象内部对象也需要桥接,那么该桥接被称为Eager的
- Lazy桥接:如果被桥接对象内部对象不需要桥接,那么该桥接被称为Lazy的
下面为几个桥接的例子:
对于桥接的消耗,我们可以看下面一段代码:
原因在于我们在访问text.string
时,会涉及到将NSString桥接为String,这是十分消耗资源的,同时在代码中进行了两次桥接。
我们可以使用下面方法减少一次桥接:
但即使这样,也会有一次的桥接。最好的解决方法如下:
在此情况中,虽然访问了text.string
,但是由于指定了NSString
,在实际执行时并不会发送桥接,所以可以节省大量时间。
何时使用桥接
- 你需要引用类型的语义
- 在使用已知类型的代理
- 你已经确定了桥接的消耗
总而言之,Apple建议我们在开发中尽量减少Foundation框架的使用,而多使用Swift Standard Library中内容。