原文:Building a Custom Collection in Swift
作者:Eric Cerney
译者:kmyhy
数组、字典和集合是常见的集合类型,它们都内置在 Swift 标准库中。但如果它们不能满足你的 App 的需要的时候怎么办?
一种最常见的办法是使用 Array 或 Dictionary,然后用一堆业务逻辑去保存你的数据结构。但这种方式太过于直接且难于维护。
这样,创建自定义集合类型就变得有意义了。在本文,你将学习用 Swift 的 collection 协议创建自定义集合类型。
当文本结束,你会拥有一个强大的自定义集合类型,拥有 Swift 内置集合的所有功能。
注:本文用 Swift 3.0。小于次的版本无法编译,因为 Swift 标准库发生了剧烈改变。
开始
在本文中,你将从头开始创建一个“多集合”(Bag)类型。
一个 Bag 对象就像一个 Set,用于存储不会重复的对象。在一个 Set 集合中,重复对象会被忽略。在一个 Bag 中,每个对象都会被算进去。
一个好例子是购物清单。你拥有一个清单,每个商品都和一个数量关联。如果添加了重复的商品,则我们会增加已有商品的数量,而不是重新插入一条商品记录。
在介绍 collection 协议前,首先来实现一个基本的 Bag。
创建一个新的 Playground:在 Xcode 中,选择 File\New\Playground… 然后给 playground 命名为 Bag。 你可以选择任何平台,因为本教程是和平台无关的,你只需要选择 Swift 语言就可以了。
点击 Next,选择一个地方保存 playground,然后点 Create。
编辑 playground 文件为:
struct Bag<Element: Hashable> { }
Bag 结构是一个泛型结构,需要元素类型必须是 Hashable 的。Hashable 允许你对元素进行比较,在 0(1)时间复杂度上只存储唯一值。也就是说,无论内容有多复杂,Bag 存取速度相同。你通过定义一个结构体,强制让它具备值语义(C++ 术语),这就和 Swift 标准库保持一致了。
// 1
fileprivate var contents: [Element: Int] = [:]
// 2
var uniqueCount: Int {
return contents.count
}
// 3
var totalCount: Int {
return contents.values.reduce(0) { $0 + $1 }
}
这是 Bag 的基本属性:
- 用一个作为内部存储结构。因为字典的键是唯一的,你可以用它来存储数据。字典的值则表示每个元素的个数。fileprivate 关键字表示这个属性是隐藏的,在外部不可访问。
- uniqueCount 返回了字典的每一种对象的统计值,并不累加它们每一个的数量。例如,一个 Bag 中有 4 个橙子和 2 个苹果只会返回 2。
- totalCount 返回的是 Bag 中所有对象的总计。以同一个例子为例,totalCount 返回值为 6。
现在,需要几个方法以便增减 Bag 中的内容。在属性声明下面加入:
// 1
mutating func add(_ member: Element,occurrences: Int = 1) {
// 2
precondition(occurrences > 0,"Can only add a positive number of occurrences")
// 3
if let currentCount = contents[member] {
contents[member] = currentCount + occurrences
} else {
contents[member] = occurrences
}
}
代码解释如下:
- add(_:occurrences:) 方法提供了增加元素的方法。它需要两个参数:泛型参数 Element 和一个 Optional 的元素个数。如果 Bag 实例以常量形式 let 定义而不是变量 var 形式定义的话,则这个方法无效。
- precondition(_:_:)方法的第一个参数是一个 Boolean 表达式,如果为 false,则程序会中断,并在 Debug 窗口输出第二个参数的内容。这个方法有一个前置条件,以保证 Bag 能够被正确使用。这个方法检查了调用 add 方法时符合我们的预设。
- if 语句判断元素是否已经存在,如果存在,则累加它的计数器,否则加入新的元素。
另外,你还需要一个删除元素的方法。在 add 方法后新增方法:
mutating func remove(_ member: Element,occurrences: Int = 1) {
// 1
guard let currentCount = contents[member],currentCount >= occurrences else {
preconditionFailure("Removed non-existent elements")
}
// 2
precondition(occurrences > 0,"Can only remove a positive number of occurrences")
// 3
if currentCount > occurrences {
contents[member] = currentCount - occurrences
} else {
contents.removeValue(forKey: member)
}
}
remove(_:occurrences:) 方法使用的参数和 add 方法一模一样,只不过做了相反的事情:
这里,Bag 还不能干更多的事情,甚至无法访问它的内容。你还不能使用 Dictionary 中存在的高阶方法。
但亡羊补牢为时未晚。我们开始在 Bag 中一一添加这些代码。现在的任务是保持你的代码整洁。
请先等一下!Swift 提供了让 Bag 符合传统集合的所有工具。
你需要先了解一下在 Swift 中,让一个对象变成集合需要做些什么。
自定义集合需要做些什么?
要理解什么是 Swift 集合,首先需要它继承的协议层次:
Sequence 协议表示类型支持排序、以迭代的方式访问其元素。你可以把一个 Sequence 对象视作一个元素的列表,允许你挨个挨个地访问其中的元素。
迭代(Iteration)是一个简单概念,但它能给你的对象提供许多功能。它允许你各种强大的操作比如:
- map(_:): 用一个闭包将 Sequence 中的每个元素挨个进行转换,并构成另一个数组返回。
- filter(_:): 用一个闭包过滤所需的元素,将符合闭包谓词所指定条件的元素放到新数组中返回。
- reduce(_:_:): 用一个闭包将 Sequence 中的所有元素合并成一个值返回。
- sorted(by:): 根据指定的闭包谓词,将 Sequence 中的元素进行排序并返回排序后的数组。
这只是其中很少的一部分功能。要查看 Sequence 中提供的所有方法,请查看 Sequence 的文档。
需要说明的一点是,采用 Sequence 协议的类型强制要求是破坏性的或者是非破坏性的。这意味着,在迭代之后,无法保证下一次迭代会从头开始。
这是一个大问题,如果你的数据准备迭代不止一次的话。要实现非破坏性的迭代,你的对象需要使用 Collection 协议。
Collection 协议继承了 Sequence 和 Indexable 协议。Collection 和 Sequence 的主要区别是,你可以迭代多次,而且可以用索引来访问。
实现 Collection 协议之后,你会获得更多“免费”的方法和属性,例如:
- isEmpty: 返回一个布尔值,表示集合是否为空。
- first: 返回集合中的第一个元素。
- count: 返回集合中的元素个数。
依据集合中的元素类型的不同,你还可能拥有更多的方法和属性。如果你想了解更多,请查看Collection 的文档。
在实现这些协议之前,Bag 还有一个地方需要改进。
打印对象
当前,Bag 对象可以用 print(_:) 方法或在 Result Sidebar 视图中暴露出的信息很少。
在 Playground 中加入如下代码:
var shoppingCart = Bag<String>()
shoppingCart.add("Banana")
shoppingCart.add("Orange",occurrences: 2)
shoppingCart.add("Banana")
shoppingCart.remove("Orange")
这里创建了一个 Bag 对象,并加入了几种水果。如果你查看Playground 的调试窗口,你会看到这些对象的类型信息而不是它保存的内容。
你可以用 Swift 标准库中的一个协议来解决这个问题。在 shoppingCart 变量上面的 Bag 类型定义结束的 } 之后添加:
extension Bag: CustomStringConvertible {
var description: String {
return String(describing: contents)
}
}
采用 CustomStringConvertible 协议需要实现一个属性,叫做 description。这个属性返回一个实例对象的文字表示。
在这里,你可以放入任何足以表示你的数据的逻辑。因为字典已经继承了这个协议,你可以简单调用 contents 对象的 description 值。
看一眼 shopingCart 的 debug 信息:
漂亮!现在你已经为 Bag 添加了功能,你可以对它的 contents 进行校验了。
在 Playground 中编写代码时,你可以使用 precondition(_:_:) 来检验返回结果。这会避免你突然破坏之前编写的功能。可以用这个工具作为你的单元测试——将它放到你的日常编码中去做是一个不错的主意!
在最后一次调用 remove(_:occurrences:) 之后加入:
precondition("\(shoppingCart)" == "\(shoppingCart.contents)","Expected bag description to match its contents description")
如果 shoppingCart 的 description 属性不等于 contents 的 description,则会导致一个错误。
为了创建我们屌爆了的集合类型,接下来的步骤自然就是初始化。
初始化
每次只能加一个元素真的很烦。通常的办法是在初始化的时候用另一个集合来进行初始化。
这正是我们希望 Bag 能够做到的。在 Playground 的最后加入:
let dataArray = ["Banana","Orange","Banana"]
let dataDictionary = ["Banana": 2,"Orange": 1]
let dataSet: Set = ["Banana","Banana"]
var arrayBag = Bag(dataArray)
precondition(arrayBag.contents == dataDictionary,"Expected arrayBag contents to match \(dataDictionary)") var dictionaryBag = Bag(dataDictionary) precondition(dictionaryBag.contents == dataDictionary,"Expected dictionaryBag contents to match \(dataDictionary)") var setBag = Bag(dataSet) precondition(setBag.contents == ["Banana": 1,"Orange": 1],"Expected setBag contents to match \(["Banana": 1,"Orange": 1])")
无法进行编译,因为还没有定义针对这些类型的初始化方法。不要为每种类型创建一种初始化方法,你可以使用泛型。
在 Bag 定义的 totalCount 后面添加:
// 1
init() { }
// 2
init<S: Sequence>(_ sequence: S) where S.Iterator.Element == Element {
for element in sequence {
add(element)
}
}
// 3
init<S: Sequence>(_ sequence: S) where S.Iterator.Element == (key: Element,value: Int) {
for (element,count) in sequence {
add(element,occurrences: count)
}
}
代码解释如下:
- 首先,创建一个空的 init 方法。在定义了其他初始化方法之后,你必须添加这个方法,否则编译器会报错。
- 然后,定义一个初始化方法并接受一个元素为 Sequence 集合的参数。sequence 参数的类型必须和元素类型匹配。例如,Array 和 Set 对象。然后,对 sequence 进行迭代,挨个添加元素。
- 最后一个方法类似,但元素类型变成元素了(Element,Int)。这种情况最典型的例子就是字典。 这里,你依然对 sequence 中的元素进行迭代并以指定的个数来添加元素。
这些泛型初始化方法为 Bag 对象添加了大量的数据源。但是,它们仍然你初始化另一个 Sequence 对象然后传递给 Bag。
为了避免这个,Swift 标准库提供了两个协议。这两个协议支持以 Sequence 的写法进行初始化。这种写法能让你用更简短的方式定义数据,而不必显式地创建一个对象。
在 Playground 最后加入下列代码,来看看如何使用这种写法:
var arrayLiteralBag: Bag = ["Banana","Banana"]
precondition(arrayLiteralBag.contents == dataDictionary,"Expected arrayLiteralBag contents to match \(dataDictionary)")
var dictionaryLiteralBag: Bag = ["Banana": 2,"Orange": 1]
precondition(dictionaryLiteralBag.contents == dataDictionary,"Expected dictionaryLiteralBag contents to match \(dataDictionary)")
没说的,编译器报错了,我们后面再来解决。这种写法用初始化数组和字典的写法来进行初始化,而不需要创建对象。
在 Bag 的其它扩展之后定义两个扩展:
extension Bag: ExpressibleByArrayLiteral {
init(arrayLiteral elements: Element...) {
self.init(elements)
}
}
extension Bag: ExpressibleByDictionaryLiteral {
init(dictionaryLiteral elements: (Element,Int)...) {
// The map converts elements to the "named" tuple the initializer expects.
self.init(elements.map { (key: $0.0,value: $0.1) })
}
}
ExpressibleByArrayLiteral 和 ExpressibleByDictionaryLiteral 扩展需要实现一个初始化方法,以处理它们对应的参数的那种写法。由于前面已经定义的初始化方法,它们的实现都非常简单。
现在 Bag 已经非常像原生的集合类型了,我们该来点猛货了。
Sequence
集合类型的最常用的操作是对其元素进行迭代。来看一个例子,在 Playground 最后添加如下代码:
for element in shoppingCart {
print(element)
}
超级简单。就像数组和字典一样,你可以遍历一个 Bag 对象。因为 Bag 还没有实现 Sequence 协议,编译不能通过。
在 ExpressibleByDictionaryLiteral 扩展后加入另一个扩展:
extension Bag: Sequence {
// 1
typealias Iterator = DictionaryIterator<Element,Int>
// 2
func makeIterator() -> Iterator {
// 3
return contents.makeIterator()
}
}
- 定义了一个类型别名,叫做 Iterator,这是 Sequence 中指定的,需要实现 IteratorProtocol 协议。DictionaryIterator 类型是字典用于迭代其元素的。你可以用它,因为 Bag 在底层使用了字典来存储数据的。
- makeIterator() 方法返回一个 Iterator,它会用来对序列中的每个元素进行迭代。
- 调用 contents 的 makeIterator() 方法创建一个 Iterator,它已经实现了 Sequence 协议。
这就是 Bag 对 Sequence 协议进行实现的全部。
你现在可以迭代 Bag 对象中的每个元素了,并可以获得每个对象的个数。在之前的 for-in 循环后加入:
for (element,count) in shoppingCart {
print("Element: \(element),Count: \(count)")
}
打开 Debug 视图,你会看到每个元素都打印出来了:
实现 Sequence 协议之后,你就可以使用许多 Sequence 中有的方法了。
在 Playground 最后加入代码试一试:
// 查找所有数目大于 1 的对象
let moreThanOne = shoppingCart.filter { $0.1 > 1 }
moreThanOne
precondition(moreThanOne.first!.key == "Banana" && moreThanOne.first!.value == 2,"Expected moreThanOne contents to be [(\"Banana\",2)]")
// 获取所有对象的数组(不需要数量)
let itemList = shoppingCart.map { $0.0 }
itemList
precondition(itemList == ["Orange","Banana"],"Expected itemList contents to be [\"Orange\",\"Banana\"]")
// 获得所有对象的加总数据
let numberOfItems = shoppingCart.reduce(0) { $0 + $1.1 }
numberOfItems
precondition(numberOfItems == 3,"Expected numberOfItems contents to be 3")
// 所有商品按照数量降序排序
let sorted = shoppingCart.sorted { $0.0 < $1.0 }
sorted
precondition(sorted.first!.key == "Banana" && moreThanOne.first!.value == 2,"Expected sorted contents to be [(\"Banana\",2),(\"Orange\",1)]")
所有 Sequence 对象能有的方法都能用——它们完全是免费的。
现在,你可能满足于以这种方式使用 Bag,但还有比这更好玩的吗?当前的 Squence 实现仍然还有改进的余地。
加强版的 Sequence
当前,你依赖于字典为你提供底层支持。这很好,对你来说,这不乏为一种轻松实现功能强大的集合的方式。问题是,它会让 Bag 的用户感到奇怪和困惑。
例如,Bag 会返回一个 DictionaryIterator 类型的 Iterator 好像不妥。你可以创建自己的 Iterator 类型,但这次不是免费的了。
Swift 提供了一个 AnyIterator 类型,将底层的 itertator 隐藏起来。
将 Sequence 的实现修改为:
extension Bag: Sequence {
// 1
typealias Iterator = AnyIterator<(element: Element,count: Int)>
func makeIterator() -> Iterator {
// 2
var iterator = contents.makeIterator()
// 3
return AnyIterator {
return iterator.next()
}
}
}
Playground 报了一堆错,等会来解决。除了使用了一个 AnyIterator 外,这个实现和之前没有太大区别:
- AnyIterator 是一个无类型的 Iterator,它只暴露出底层实际的 Iterator 的 next() 方法给你。这样你就可以将实际使用的 Iterator 类型隐藏起来。
- 跟前面一样,通过 contents 创建了一个新的 DictionaryIterator 实例。
- 最后,将 Iterator 包装成 AnyIterator 以传递其 next() 方法。
前面,你用 DictionaryIterator 的元组命名为 key 和 value。现在你已经将 DictionaryIterator 隐藏起来了,并且将元组的名字修改为 element 和 count。要解决这个错误,将 key 和 value 替换成 element 和 count。
这样你的 precondition 语句的问题就解决了。这就是前置条件的好处了,它能保证某些东西不会被意外修改。
现在,任何人都不知道你在用字典进行所有的工作。
现在的 Bag 让你感觉更好了吧?是时候让它回家了。呃,将你激动的心情收起来吧,它是属于集合的!
Collectdion
闲话少说,接下来还有一道大菜,创建一个集合……即 Collection 协议!再次声明,集合是能够通过索引进行访问并进行多次非破坏性迭代的结合。
为了符合 Collection 协议,你需要提供这些数据:
- startIndex 和 endIndex: 指定集合的边界,并说明遍历的起点。
- subscript (position:): 允许你通过索引找到集合中的任意元素。这个访问方法的时间复杂度应控制在 0(1) 上。
- index(after:): 返回传入的索引的下一个索引。
使用 Cellection 协议时只需要这 4 个数据。在 Sequence 扩展后新增扩展:
extension Bag: Collection {
// 1
typealias Index = DictionaryIndex<Element,Int>
// 2
var startIndex: Index {
return contents.startIndex
}
var endIndex: Index {
return contents.endIndex
}
// 3
subscript (position: Index) -> Iterator.Element {
precondition(indices.contains(position),"out of bounds")
let dictionaryElement = contents[position]
return (element: dictionaryElement.key,count: dictionaryElement.value)
}
// 4
func index(after i: Index) -> Index {
return contents.index(after: i)
}
}
代码非常简单:
- 首先用 DictionaryIndex 来声明一个 Index 类型,DictionaryIndex 在 Collection 协议中已定义。注意,其实编译器会基于你后面的实现来推断这个类型,但为了保持代码的清晰和可维护性,我们显示地指定了类型。
- 然后用 contents 来返回第一个索引和最后一个索引。
- 用一个前置条件来强制校验索引的有效性。然后,以元组的方式返回 contents 中位于该索引的元素。
- 最后,调用 contents.index(after:) 方法并返回结果。
通过添加几个属性和方法,你创建了一个功能完整的集合!在 Playground 最后用几行代码来测试这些功能:
// 读取 Bag 中的第一个对象
let firstItem = shoppingCart.first
precondition(firstItem!.element == "Orange" && firstItem!.count == 1,"Expected first item of shopping cart to be (\"Orange\",1)")
// 判断 Bag 是否为空
let isEmpty = shoppingCart.isEmpty
precondition(isEmpty == false,"Expected shopping cart to not be empty")
// 获取 Bag 中的商品种类数
let uniqueItems = shoppingCart.count
precondition(uniqueItems == 2,"Expected shoppingCart to have 2 unique items")
// 查找第一个名为 Banana 的元素
let bananaIndex = shoppingCart.indices.first { shoppingCart[$0].element == "Banana" }!
let banana = shoppingCart[bananaIndex]
precondition(banana.element == "Banana" && banana.count == 2,"Expected banana to have value (\"Banana\",2)")
漂亮!(当你对自己所做的一切感到心满意足时,“等等,你还可以做得更好”这句话又在等着你了……)
是的,你说的没错!你可以做得更好。仍然能够从 Bag 中看出一丝字典的模样。
加强版的 Collection
Bag 暴露了太多底层实现细节。Bag 的用户仍然需要使用 DictionaryIndex 对象去访问集合中的元素。
这个很好搞定。在 Collection 扩展后面增加:
// 1
struct BagIndex<Element: Hashable> {
// 2
fileprivate let index: DictionaryIndex<Element,Int>
// 3
fileprivate init(_ dictionaryIndex: DictionaryIndex<Element,Int>) {
self.index = dictionaryIndex
}
}
没有任何新奇的玩意儿,但我们还是来过一下吧:
- 定义了一个泛型类型 BagIndex,和 Bag 一样,为了访问字典对象,它需要一个 Hashable 的泛型参数。
- index 类型的真实类型是一个私有的 DictionaryIndex 对象。BagIndex 仅仅是一个封装,隐藏了它真正的 index 类型。
- 最后,创建一个私有的初始化方法,接收一个 DictionaryIndex 参数。
Collection 对象需要索引对象实现 Comparable 协议,能够对两个索引进行比较以进行某些操作。因此,BagIndex 必须实现 Comparable 协议。在 BagIndex 扩展之后添加:
extension BagIndex: Comparable {
static func == (lhs: BagIndex,rhs: BagIndex) -> Bool {
return lhs.index == rhs.index
}
static func < (lhs: BagIndex,rhs: BagIndex) -> Bool {
return lhs.index < rhs.index
}
}
这个逻辑非常简单;方法返回的结果直接调用 DictionaryIndex 已实现的 Comparable 协议的相同方法。
现在将 Bag 修改为使用 BagIndex。将 Collecdtion 扩展替换成:
extension Bag: Collection {
// 1
typealias Index = BagIndex<Element>
var startIndex: Index {
// 2.1
return BagIndex(contents.startIndex)
}
var endIndex: Index {
// 2.2
return BagIndex(contents.endIndex)
}
subscript (position: Index) -> Iterator.Element {
precondition((startIndex ..< endIndex).contains(position),"out of bounds")
// 3
let dictionaryElement = contents[position.index]
return (element: dictionaryElement.key,count: dictionaryElement.value)
}
func index(after i: Index) -> Index {
// 4
return Index(contents.index(after: i.index))
}
}
注释中的数字标出了修改之处。分别解释如下:
- 首先将 Index 的类型从 DictionaryIndex 修改为 BagIndex。
- 然后,是 startIndex 和 endIndex,你换成新的 BagIndex。
- 接着,用这个 BagIndex 从 contents 后访问对应元素并返回。
- 最后,结合上面几个步骤。从 contents 中获取 DictionaryIndex,然后用它来创建一个 BagIndex。
就这样!用户不会知道你底层是用什么来存储数据的了。你未来还有可能对索引对象的获得更大的控制。
在完成之前,还有一个很重要的地方。除了基于索引来访问元素,你还以通过一段连续索引来访问集合中的值。
要实现这个,你可以看一下集合中是如何进行切片操作的。
切片
切片就是查看集合中多个连续的元素。它允许你在集合元素的子集上进行某些操作,而不用复制这些元素。
切片只会保存原来集合中的元素的引用。它还存储了元素子集的起、始索引。切片的时间复杂度为 0(1),因为它们直接引用了它的原始集合。
切片直接重用原始集合的索引,这使得它们尤其有用。
要测试切片操作,在 Playground 最后添加代码:
// 1
let fruitBasket = Bag(dictionaryLiteral: ("Apple",5),("Orange",2),("Pear",3),("Banana",7))
// 2
let fruitSlice = fruitBasket.dropFirst() // No pun intended ;]
// 3
if let fruitMinIndex = fruitSlice.indices.min(by: { fruitSlice[$0] > fruitSlice[$1] }) {
// 4
let minFruitFromSlice = fruitSlice[fruitMinIndex]
let minFruitFromBasket = fruitBasket[fruitMinIndex]
}
我们来看一下这些代码做了些什么,以及它们的意思:
- 首先,创建一个由 4 种水果构成的水果篮。
- 然后拿走第一种水果。这会创建一个水果篮的切片,但第一种水果不见了。
- 通过切片找到数量最少的水果的索引。
- 尽管上一步的索引是从切片中得到的,你可以把这个索引同时用在原来的集合和后面的切片中来访问对象。
注意:切片对于基于哈希的字典和 Bag 来说,用处不大,因为它们的顺序在任何方向上都是不确定的。而数组则相反,数组是集合中的一个极端例子,在执行顺序操作时,切片扮演很重要的角色。
祝贺你——你现在是一个集合方面的专家了!你可以创建任意内容的 Bag 对象来表示庆祝。
结束
完整的 Playground 代码可以在这里下载。如果你想了解或者实现更完整的 Bag,请从 github 中 checkout 项目。
在这篇文章里,你学习了在 Swift 中,如何通过一个数据结构来创建自己的集合。你使用了 Sequence 、Collection 、CustomStringConvertible、 ExpressibleByArrayLiteral、ExpressibleByDictionaryLiteral 协议以及资第一的索引类型。
这仅仅是对 Swift 提供的用于创建健壮、使用的结合类型的协议的一点尝试。如果你想看一下还有什么其他的协议,你可以参考:
- 数组继承的协议层次:Array Hierarchy
- 字典继承的协议层次:Dictionary Hierarchy
- 双向集合:BidirectionalCollection 文档
希望你能喜欢这篇教程!创建自定义的集合并不是一个常见的需求,当它能加深你对 Swift 内置集合类型的裂解。
如果有任何疑问或建议,请在下面留言。