木又的《Swift进阶》读书笔记——集合类型协议

588 阅读4分钟

集合类型协议

Swift 中的集合类型,包括:Array,Dictionary 和 Set。SequenceCollection 协议,构成了这套集合类型的基石。

graph TD
A[Sequence] --> B[LazySequenceProtocol]
A[Sequence] --> C[Collection]
B --> D[LazyCollectionProtocol]
C --> D
C --> E[RangeReplaceableCollection]
C --> F[BidirectionalCollection]
C --> G[MutableCollection]
F --> H[RandomAccessCollection]
  • Sequence 提供了迭代的能力。它允许你创建一个迭代器,但对于序列是否只能单次遍历 (例如读取标准输入的内容) 或者支持多次遍历 (例如遍历一个数组),则不提供任何保障。
  • Collection 扩展了 Sequence。它不仅是一个可以多次遍历的序列,还允许你通过索引访问其中的元素。并且,Collection 还通过 SubSequence 提供了集合切片的能力,而这个切片自身也是一个集合。
  • MutableCollection 提供了可以在常数时间内,通过下标修改集合元素的能力。但它不允许向集合中添加删除元素。
  • RangeReplaceableCollection 提供了替换集合中一个连续区间的元素的能力。通过扩展,这个能力还衍生出了诸如 append 和 remove 等方法。很多可变集合类型 (mutable collections) 都可以进行区间内容替换,但其中也有例外。例如,最常用的 SetDictionary 就不支持这个操作,而 ArrayString 则没问题。
  • BidirectionalCollection 添加了从集合尾部向集合头部遍历的能力。显然,我们无法像这样遍历一个 Dictionary,但“逆着”遍历一个字符串则完全没有问题。对于某些算法来说,逆向遍历是一个至关重要的操作。
  • RandomAccessCollection 扩展了 BidirectionalCollection,添加了更有效率的索引计算能力:它要求计算索引之间的距离或移动索引位置都是常数时间的操作。例如:Array 就是一个随机访问集合,但字符串就不是,因为计算两个字符之间距离是一个线性时间的操作。
  • LazySequenceProtocol 定义了一个只有在开始遍历时才计算其中元素的序列。在函数式风格编写的算法里,它很常用:你可以接受一个无穷序列,从中筛选元素,然后读取结果中的前几个记录。这个过程并不会因为需要计算结果集之外的无穷多个元素而耗尽资源。
  • LazyCollectionProtocolLazySequenceProtocol 是类似的,只是它用于定义有相同行为特性的集合类型。

序列

Sequence 协议是集合类型结构中的基础。一个序列 (sequence) 代表的是一系列类型相同的值,可以对这些值进行迭代。

for element in someSequence {
  doSomething(with: element)
}

满足 Sequence 协议的要求十分简单,唯一要做的就是提供一个返回 迭代器 (iterator)makeIterator() 方法:

protocol Sequence {  
    associatedtype Element  
    associatedtype Iterator: IteratorProtocol    
    func makeIterator() -> Iterator  
    // ...
}

迭代器

序列通过创建一个迭代器来提供对元素的访问。迭代器每次产生序列中的一个值,并对遍历状态进行管理。在 IteratorProtocol 协议中,唯一的一个方法是 next(),这个方法需要在每次被调用时返回序列中的下一个值。当序列被耗尽时,next() 应该返回 nil

protocol IteratorProtocol {
  associatedtype Element
  mutating func next() -> Element?
}

API 设计指南 建议:根据协议承担的角色,协议名称应该是个名词,或带有 -able-ible 或 -ing` 后缀。

关联类型 Element 指定了迭代器产生的值的类型。比如 String 的迭代器的元素类型是 Character。通过扩展,迭代器同时也定义了它对应的序列的元素类型。这是通过给 Sequence 的关联类型 Iterator 定义了一个类型约束实现的:Iterator.Element == Element,它确保了序列和迭代器中的元素类型是一致的:

protocol Sequence {
    associatedtype Element  
    associatedtype Iterator: IteratorProtocol  	
        where Iterator.Element == Element  
    // ...
}

for 循环,其实是下面这段代码的一种简写形式:

var iterator = someSequence.makeIterator()
while let element = iterator.next() {  
    doSomething(with: element)
}

遵守序列协议

举个栗子,下面这个 PrefixGenerator,它将顺次生成字符串的所有非空前缀 (也包含字符串本身)。

struct PrefixIterator: IteratorProtocol {
  let string: String
  var offset: String.Index
  
  init(string: String) {
    self.string = string
    offset = string.startIndex
  }
  
  mutating func next() -> Substring? {
    guard offset < string.endIndex else { return nil }
    offset = string.index(after: offset)
    return string[..<offset]
  }
}
struct PrefixSequence: Sequence {
  let string: String
  func makeIterator() -> PrefixIterator {
    return PrefixIterator(string: string)
  }
}
for prefix in PrefixSequence(string: "Hello") {
  print(prefix)
}
/*
H
He
Hel
Hell
Hello
*/
PrefixSequence(string: "Hello").map { $0.uppercased() }
// ["H", "HE", "HEL", "HELL", "HELLO"]

迭代器和值语义

标准库中的大部分迭代器都具有值语义,不过也有例外存在。AnyIterator 是一个对别的迭代器进行封装的迭代器,进行封装的做法是将另外的迭代器包装到一个内部的盒子对象中,而这个对象是引用类型。因此,AnyIterator 并不具有值语义。

基于函数的迭代器和序列

  • AnyIterator 还有另外一个接受 next 函数作为参数的初始化方法。把它和对应的 AnySequence 类型结合起来使用,就可以让我们在不定义任何新类型的情况下,创建迭代器和序列。
func fibsIterator() -> AnyIterator<Int> {  
    var state = (0,1)  
    return AnyIterator {    
        let upcomingNumber = state.0    
        state = (state.1, state.0 + state.1)    
        return upcomingNumber  
    }
}
let fibsSequence = AnySequence(fibsIterator)
Array(fibsSequence.prefix(10))  // [0,1,1,2,3,5,8,13,21,34]
  • 另一种方法是使用 Sequence 函数
let fibsSequence2 = sequence(state: (0,1)) { state -> Int? in
	let upcomingNumber = state.0
  state = (state.1, state.0 + state.1)
  return upcomingNumber
}

Array(fibsSequence2.prefix(10)) // [0,1,1,2,3,5,8,13,21,34]

sequence(first:next:) 和 sequence(state:next:) 的返回值类型是 UnfoldSequence。这个类型的名称来自函数式编程,在函数式编程中,这种操作被称为 展开 (unfold)。sequence 是和 reduce 对应的,在函数式编程中 reduce 又常被叫做 折叠 (fold)。reduce 将一个序列缩减 (或者说折叠) 为一个单一的返回值,而 sequence 则将一个单一的值 展开 形成一个序列。

单次遍历序列

序列并不只限于像是数组或者列表这样的传统集合数据类型。像是网络流,磁盘上的文件,UI 事件的流,以及其他很多类型的数据都可以使用序列进行建模。但它们之中,并不是所有类型的序列都和数组一样,可以让你反复遍历其中的元素。像是网络包流这样的序列则只能遍历一次。就算再次对其进行迭代,它也不会产生同样的值。但斐波那契序列和网络包流都是有效的序列,因此,Sequence 的文档非常明确地指出了序列并不保证可以被多次遍历:

Sequence 协议并不关心遵守该协议的类型是否会在迭代之后将序列的元素销毁。也就是说,请不要假设对一个序列进行多次的 for-in 循环将继续之前的迭代或是从头开始。

如果一个序列遵守 Collection 协议的话,那它肯定可以被反复遍历,因为 Collection 在这方面进行了保证。但反过来却不一定,标准库中有些序列可以安全地多次遍历,但它们并不是集合类型。例如:stride(from:to:by:) 返回的 StrideTo,以及 stride(from:through:by) 返回的 StrideThrough。

序列和迭代器之间的关系

单次遍历的序列,自己持有迭代状态,并且会随着遍历而发生变化。然而,对于可多次遍历序列来说,它的值不能随着 for 循环而改变,它们需要独立的遍历状态,这就是迭代器所存在的意义。makeIterator 方法的目的就是创建这样一个遍历状态。

让 List 遵守 Sequence

enum List<Element> {  
    case end  
    indirect case node(Element, next: List<Element>)
}
   
extension List {  
    mutating func push(_ x: Element) {    
        self = .node(x, next:self)  
    }    
    mutating func pop() -> Element? {    
        switch self {      
            case .end: return nil      
            case let .node(x, next: tail):      	
                self = tail      	
                return x    
        }  
    }
}
extension List: ExpressibleByArrayLiteral {  
    public init(arrayLiteral elements: Element...) {    
        self = elements.reversed().reduce(into: .end) { partialList, element in    	       partialList.push(element)    
        }  
    }
}

let list: List = [3,2,1]
/*node(3, next: List<Swift.Int>.node(2,next:
List<Swift.Int>.node(1,
next: List<Swift.Int>.end)))
*/
extension List: IteratorProtocol, Sequence {
  public mutating func next() -> Element? {
    return pop()
  }
}

无需实现 makeIterator 方法:对于那些迭代器类型就是它自己的序列,Swift 会提供一份默认的实现。这样,就可以通过 for...in 遍历 List 对象了:

let list2: List = ["1","2","3"]
for x in list2 {
  print("\(x) ", terminator: "")
} // 1 2 3

集合类型

集合类型 (Collection) 指的是那些可以被多次遍历且保持一致的序列。

自定义的集合类型

一个很简单的先进先出队列:

/// 一个高效的 FIFO 队列,其中元素类型为 `Element`
struct FIFOQueue<Element> {  
    private var left: [Element] = []  
    private var right: [Element] = []    
    
    /// 将元素添加到队列最后  
    /// - 复杂度:O(1)	
    mutating func enqueue(_ newElement: Element) {    
        right.append(newElement)  
    }    
    
    /// 从队列前端移除一个元素  
    /// 当队列为空时,返回nil  
    /// - 复杂度:平摊 O(1)  
    mutating func dequence() -> Element? {    
        if left.isEmpty {      
            left = right.reversed()      
            right.removeAll()    
        }    
        return left.popLast()  
    }
}

文档中的说明:

... 要让一个额类型实现 Collection,你必须声明一下内容:

  • startIndex 和 endIndex 属性。
  • 至少能以只读方式访问集合元素的下标操作符。
  • 用来在集合索引之间进行步进的 index(after:) 方法。

于是,我们需要实现的有:

protocol Collection: Sequence {
  /// 一个表示序列中元素的类型
  associatedtype Element
  /// 一个表示集合中位置的类型
  associatedtype Index: Comparable
  /// 一个非空集合中首个元素的位置
  var startIndex: Index { get }
  /// 集合中超过末位的位置---也就是比最后一个有效下标值大1的位置
  var endIndex: Index { get }
  /// 返回在给定索引之后的位置
  func index(after i: Index) -> Index
  /// 访问特定位置的元素
  subscript(position: Index) -> Element { get }
}

让 FIFOQueue 满足 Collection:

extension FIFOQueue: Collection {
  public var startIndex: Int { return 0 }
  public var endIndex: Int { return left.count + right.count }
  
  public func index(after i: Int) -> Int {
    precondition(i >= startindex && i < endIndex, "Index out of bounds")
    return i + 1
  }
  
  public subscript(position: Int) -> Element {
    precondition((startIndex..<endIndex).contains(position), "Index out of bounds")
    if position < left.endIndex {
      return left[left.count - position - 1]
    } else {
      return right[position - left.count]
    }
  }
}

数组字面量

当实现一个类似 FIFOQueue 这样的集合类型时,最好也去实现一下 ExpressibleByArrayLiteral。这可以让用户能够以它们熟知的 [value1,value2,etc] 语法创建一个队列。而这个协议只要求我们实现一个下面这样的初始化方法就好了:

extension FIFOQueue: ExpressibleByArrayLiteral {
  public init(arrayLiteral elements: Element...) {
    self.init(left: elements.reversed(), right: [])
  }
}

关联类型

Collection 为除了 IndexElement 以外的关联类型都提供了默认值。Anyway,还是逐个过一遍它们。

Iterator - 这是从 Sequence 继承来的关联类型。集合类型中的默认迭代器类型是 IndexingIterator<Self>,这是个简单的结构体,它对集合进行了封装,并用集合本身的索引来迭代每个元素。

SubSequence - 是表示集合中一段连续内容切片的类型。SubSequence 自身也是集合类型。它的默认值是 Slice<Self>,这是对原始集合的封装,并存储了切片相对于原始集合的起始和终止索引。

Indices - 集合的 indices 属性的类型。它是集合中所有有效索引按升序排列组成的集合。注意 endIndex 并不包含在其中,因为 endIndex 代表的是最后一个有效索引之后的那个索引,它不是有效的下标参数。

extension FIFOQueue: Collection {
  // ...
  typealias Indices = Range<Int>
  var indices: Range<Int> {
    return startIndex..<endIndex
  }
}

索引

索引表示了集合中的位置。每个集合都有两个特殊的索引值:startIndex 和 endIndex。startIndex 指定集合中第一个元素,endIndex 是集合中最后一个元素的下一个位置。

整数索引十分直观,但它并不是唯一选项。集合类型的 Index 的唯一要求是,它必须实现 Comparable,换句话说,索引必须要有确定的顺序。

字典的索引是 DictionaryIndex 类型,它是一个指向字典内部存储缓冲区的不透明值。

我们平时用键访问的 subscript(_ key:Key) 方法是直接定义在 Dictionary 上的下标方法的一个重载,它返回可选的 Value:

struct Dictionary {
  ...
  subscript(key: Key) -> Value?
}

而通过索引访问的方法是 Collection 协议所定义的,它总是返回非可选值。

protocol Collection {  subscript(position: Index) -> Element { get }}

索引失效

  • 当集合发生改变时,索引可能会失效。
  • 从字典中移除元素总是会 使索引失效。
  • 索引应该是一个只存储包含描述元素位置所需最小信息的简单值。

索引步进

collection.index(after: someIndex)

自定义集合索引

从在 SubString 中寻找第一个单词的范围开始。

extension Substring {
  var nextWordRange: Range<Index> {
    let start = drop(while: { $0 == " " })
    let end = start.firstIndex(where: { $0 == ""}) ?? endIndex
    return start.startIndex..<end
  }
}

Range<Substring.Index> 进行包装,然后用它来作为索引类型。

struct WordsIndex: Comparable {
  fileprivate let range: Range<Substring.Index>
  fileprivate init(_ value: Range<Substring.Index>) {
    self.range = value
  }
  
  static func <(lhs:Words.Index, rhs: Words.Indx) -> Bool {
    return lhs.range.lowerBound < rhs.range.lowerBound
  }
  
  static func ==(lhs: Words.Index, rhs: Words.Index) -> Bool {
    return lhs.range == rhs.range
  }
}

Collection 协议要求 startIndex 的复杂度为 O(1)

struct Words {
  let string: Substring
  let startIndex: WordsIndex
  
  init(_ s: String) {
    self.init(s[...])
  }
  
  private init(_ s: Substring) {
    self.string = s
    self.startIndex = WordsIndex(string.nextWordRange)
  }
  
  public var endIndex: WordsIndex {
    let e = string.endIndex
    return WordsIndex(e..<e)
  }
}

集合类型要求我们提供 subscript 下标方法来获取元素。

extension Words {
  subscript(index: WordsIndex) -> Substring {
    return string[index.range]
  }
}

Collection 的最后一个要求是给定某个索引时,能够计算出下一个索引。

extension Words: Collection {
  public func index(after i: WordsIndex) -> WordsIndex {
    guard i.range.upperBound < string.endIndex else {
      return endIndex
    }
    let remainder = string[i.range.upperBound...]
    return WordsIndex(remainder.nextWordRange)
  }
}

Array(Words(" hello world test ").prefix(2))  // ["hello","world"]

子序列

Collection 协议还有一个关联类型,叫做 SubSequence。它表示集合中一个连续的自区间:

extension Collection {  
    associatedtype Subsequence: Collection = Slice<Self>   	
    where Element == SubSequence.Element,  		
    SubSequence == SubSequence.SubSequence
}

SubSequence 用于那些返回原始结合类型切片的操作:

  • prefixsuffix — 获取开头或结尾的 n 个元素。
  • prefix(while:) — 从集合开始,获取满足 while 指定条件的操作。
  • dropFirstdropLast — 返回移除掉前 n 个或后 n 个元素的子序列。
  • drop(while:) — 移除元素,直到条件不再为真,然后返回剩余元素。
  • split — 将一个序列通过指定的分隔元素截断,返回子序列的数组。

另外,基于范围的下标操作也会以切片的形式返回这个范围内的索引在原始集合中标记的区间。

切片

Slice 是基于任意集合类型的一个轻量级封装,非常适合作为默认的子序列类型,不过当你创建一个自定义集合类型时,最好还是考虑下是否能将集合类型本身当作它的 SubSequence 使用。

不过另一方面,让原始集合和它的切片使用不同的类型,有助于避免意外的内存“泄漏”,这也是标准库中使用 ArraySlice 和 Substring 来作为 Array 和 String 的子序列的原因。

切片与原集合共享索引

Collection 协议还有另一个正式的要求,那就是切片的索引可以和原集合的索引互换使用。

专门的集合类型

  • 和所有精心设计的协议一样,Collection 努力将它的需求控制在最小。
  • 标准库提供了四个专门的集合类型,每一个都用特定的方式给 Collection 追加了新的功能:
    • BidirectionalCollection — “一个既支持前向又支持后向遍历的集合”
    • RandomAccessCollection — “一个支持高效随机访问索引进行遍历的集合”
    • MutableCollection — “一个支持下标赋值的集合”
    • RangeReplaceableCollection — “一个支持将任意子范围的元素用别的集合中的元素进行替换的集合”

BidirectionalCollection

BidirectionalCollection 给集合添加了一个关键的能力,就是通过 index(before:) 方法把索引往回移动一个位置。有了这个方法,就可以对应 first,给出默认的 last 属性的实现了:

extension BidirectionalCollection {
  /// 集合中的最后一个元素
  public var last: Element? {
    return isEmpty ? nil : self[index(before: endIndex)]
  }
}

当然,Collection 本身也能提供 last 属性,但是这么做不太好。在一个只能前向进行索引的集合类型中,想要获取最后一个元素,需要一路从头迭代到尾,而这是一个 O(n) 操作。

受益于这种可以向前遍历集合的能力,BidirectionalCollection 还实现了一些可以高效执行的方法,比如 suffix, removeLastreversed

标准库中的大部分类型都是在实现 Collection 的同时,实现了 BidirectionalCollection。

RandomAccessCollection

RandomAccessCollection 提供了最高效的元素存取方式,它能够在常数时间内跳转到任意索引。要做到这一点,满足该协议的类型必须能够 (a) 以任意距离移动一个索引,以及 (b) 测量任意两个索引之间的距离,两者都需要是 O(1) 时间的常数操作。RandomAccessCollection 以更严格的约束重新声明了关联的 IndicesSubSequence 类型,这两个类型自身也必须是可以进行随机存取的。你可以通过提供 index(_:offsetBy:)distance(from:to:) 方法,或者是使用一个满足 StrideableIndex 类型 (像是 Int), 就可以做到这一点。

MutableCollection

可变集合支持原地的元素更改。相比 Collection,MutableCollection 只增加了一个要求,那就是单个元素的下标访问方法 subscript 现在必须提供一个 setter :

extension FIFOQueue: MutableCollection {
  public var startIndex: Int { return 0 }
  public var endIndex: Int { return left.count + right.count }
  
  public func index(after i: Int) -> Int {
    return i + 1
  }
  
  public subscript(position: Int) -> Element {
    get {
      precondition((0..<endIndex).contains(position), "Index out of bounds")
      if position < left.endIndex {
        return left[left.count - position - 1]
      } else {
        return right[position - left.count]
      }
    }
    set {
      precondition((0..<endIndex).contains(position), "Index out of bounds")
      if position < left.endIndex {
        left[left.count - position - 1] = newValue
      } else {
        return right[position - left.count] = newValue
      }
    }
  }
}
var playlist: FIFOQueue = ["Shake It Off", "Blank Space", "Style"]
playlist.first // Optional("Shake It Off")
playlist[0] = "You Belong With Me"
playlist.first // Optional("You Belong With Me")

很多对集合进行原地修改的算法都要求对应的集合类型满足 MutableCollection 协议。例如标准库中的原地排序、逆序以及 swapAt 方法。相对来说,标准库中满足 MutableCollection 的类型不多。在三个主要的集合类型中,只有 Array 满足这个协议。

RangeReplaceableCollection

对于需要添加或者移除元素的操作,可以使用 RangeReplaceableCollection 协议。这个协议有两个要求:

  • 一个空的初始化方法 — 在泛型函数中这很有用,因为它允许一个函数创建相同类型的空集合。
  • 一个 replaceSubrange(_:with:) 方法 — 它接受一个要替换的范围以及一个用来进行替换的集合。

RangeReplaceableCollection 是展示协议强大能力的绝佳例子。你只要实现一个灵活的 replaceSubrange 方法,协议扩展就可以为你引申出一系列有用的方法:

  • append(_:)apend(contentsOf:) — 将 endIndex..<endIndex (也就是说末尾的空范围) 替换为单个或多个新的元素。
  • remove(at:)removeSubrange(_:) — 将 i...i 或者 subrange 替换为空集合。
  • insert(at:)insert(contentsOf:at:) — 将 i..<i (或者说在数组中某个位置的空范围) 替换为单个或多个新的元素。
  • removeAll — 将 startIndex..<endIndex 替换为空集合。

这个方法,也是协议要求的一部分。当然你可以,视情况自己提供更高效的实现,覆盖默认实现。

extension FIFOQueue: RangeReplaceableCollection {
  mutating func replaceSubrange<C: Collection> (_ subrange: Range<Int>, with newElements: C)
  	where C.Element == Element {
      right = left.reversed() + right
      left.removeAll()
      right.replaceSubrange(subrange,with: newElements)
    }
}

RandomAccessCollection 扩展了 BidirectionalCollection 不同,RangeReplaceableCollection 并不是对 MutableCollection 的扩展,它们拥有各自独立的继承关系。在标准库中,String 就是一个实现了 RangeReplaceableCollection 但是却没有实现 MutableCollection 的例子。

组合能力

举个栗子,标准库中的 sort 方法。

extension MutableCollection where Self: RandomAccessCollection, Element: Comparable {
  /// 原地对集合进行排序
  public mutating func sort() { ... }
}

延迟序列

标准库为支持延迟编程 (lazy programming) 提供了两个协议:LazySequenceProtocolLazyCollectionProtocol延迟编程 意味着结果只有在真正需要的时候才会计算出来,这是相较于 立即编程 (eager programming) 而提出的概念。

标准库为 Sequence 提供了一个 .lazy 属性帮助我们实现一个延迟序列。

集合的延迟处理

当在一个常规集合类型 (例如:Array) 上串联多个操作的时候,可以延迟处理的序列会更加有效率。

(1..<100).map { $0 * $0 }.filter { $0 > 10 }.map { "\($0)" }

(1..<100).lazy.map {$0 * $0 }.filter { $0 > 10 }.map { "\($0)" }

在 Swift5.0 中,不启用编译器优化的条件下,使用了 .lazy 的代码可以ibis之前的版本快三倍,而使用 -O 开启优化之后,性能可以提升八倍。