Array全面解析
//科普:
$0,$1 闭包中的第一第二个变量
两个常用的Array属性
定义好数组之后,我们介绍两个Array最常用的属性。第一个是count,类型是Int。我们之前已经用过,用于获取数组中元素的个数:
1 | array1.count // 0 |
第二个是isEmtpy,类型是Bool。表示数组是否为空:
1 | if array2.isEmpty { |
访问Array中的元素
接下来,我们看访问Array元素的方法,它们之中有我们在其他语言中熟悉的,也有Swift独特的方式。首先,就是几乎所有语言都有的惯用法,使用索引。但是,它却也是在Swift,最不被推荐的使用方法:
1 | fiveInts[2] // 3 |
就像,上面例子中这样。当使用索引访问数组元素时,你必须自行确保索引的安全性。如果索引超过了数组的范围,程序就会直接崩溃。其实,在Swift里,我们几乎不需要直接使用索引来访问数组元素。稍后,我们会专门提到Array的惯用法。因此,Swift开发者也没有对索引访问添加任何安全保护。言外之意就是,非要用,你自己对结果全权负责喽。
其次,是使用range operator访问数组的一个范围:
1 | fiveInts[0...2] // [1, 2, 3] |
要说明的是,使用range operator得到的,并不是一个Array,而是一个ArraySlice。什么是ArraySlice呢?简单来说,就是Array某一段内容的view,它不真正保存数组的内容,只保存这个view引用的数组的范围:
1 | // +---------+---+ |
从上面这个注释,就很容易理解view的概念了,它只记录了要表达内容的区间。但是我们也可以直接通过这个view创建新的Array对象:
1 | Array(fiveInts[0...2]) // [1, 2, 3] |
这样,就得到了一个值是[1, 2, 3]的Array对象。
添加和删除元素
最后,来看如何编辑Array中的元素。要在数组的末尾添加元素,我们可以这样:
1 | array1.append(1) // [1] |
要在Array中间位置添加元素,可以使用insert方法:
1 | // [1, 2, 3, 4, 5] |
它的第一个参数表示要插入的值,第二个参数表示要插入的位置,这个位置必须是一个合法的范围,即0...array1.endIndex,如果超出这个范围,会直接引发运行时错误。
要删除Array中的元素,可以使用remove(at:)方法,它只接受一个参数,表示要删除元素的位置:
1 | array1.remove(at: 4) // [1, 2, 3, 4] |
同样,你必须自行保证使用的at参数不超过数组的合法范围,否则会引发运行时错误。当然,如果你仅仅想删除数组中的最后一个元素,还可以使用removeLast()方法:
1 | array1.removeLast() // [1, 2, 3] |
从上面的例子可以看到,你同样要对removeLast()的应用安全负责,当你删除一个空数组中最后一个元素的时候,会直接引发运行时错误。
按值语义实现的Array
在Swift中,Array是按照值语义实现的,当我们复制一个Array对象时,会拷贝整个Array的内容:
1 | var a = [1, 2, 3] // [1, 2, 3] |
上面的代码中,有两点值得说明。
首先,Swift数组是否可以被修改完全是通过var和let关键字来决定的,Array类型自身并不解决它是否可以被修改的问题;
其次,从结果可以看到,复制a并向a添加内容之后,copyA的内容并不会修改。但是,Swift在复制Array时,同样对Array的性能有所考量,它使用了copy on write的方式。即如果你仅仅复制了Array而不对它修改时,真正的复制是不会发生的,两个数组仍旧引用同一个内存地址。只有当你修改了其中一个Array的内容时,才会真正让两个Array对象分开。为了看到这个过程,我们先来实现一个方法,把保存Array内容的地址变成一个字符串:
1 | func getBufferAddress<T>(of array: [T]) -> String { |
其中,withUnsafeBufferPointer是Array的一个方法,它可以把保存Array内容的地址,传递给它的closure参数。在我们的例子里,这个closure只是把Array的地址,变成了一个String对象。
然后,我们在a.append(4)前后,分别观察a和copyA的内容:
1 | getBufferAddress(of: a) |

就如同图中显示的,只有在给a添加内容后,它才被重新分配了内存地址。
了解了Swift Array之后,我们再来看Foundation中NSArray的情况。
按引用语义实现的NSArray
在Foundation中,数组这个类型有两点和Swift Array是不同的:
- 数组是否可以被修改是通过
NSArray和NSMutableArray这两个类型来决定的; NSArray和NSMutableArray都是类对象,复制它们执行的是引用语义;
当把这两个因素放在一起的时候,Foundation中的“常量数组”这个概念就会在一些场景里表现的很奇怪。因为你还可以通过对一个常量数组的非常量引用去修改它,来看下面的例子:
1 | // Mutable array [1, 2, 3] |
从上面的代码可以看到,尽管我们在创建copyB时,使用了NSArray,表明我们不希望它的值被修改,由于这个赋值执行的是引用拷贝,因此,实际上它和b指向的是同一块内存空间。因此,当我们修改b的内容时,copyB也就间接受到了影响。
为了在拷贝NSArray对象时,执行值语义,我们必须使用它的copy方法复制所有的元素:
1 | let b = NSMutableArray(array: [1, 2, 3]) |
从注释中的结果,你就能很容易理解deep copy的含义了。
当我们使用
NSArray和NSMutableArray时,Swift中的var和let关键字就和数组是否可以被修改没关系了。它们只控制对应的变量是否可以被赋值成新的NSArray或NSMutableArray对象。
绝大多数时候,其实你不需要[]
对于下标访问数组元素这种老旧的形式,Swift的开发者应该是不太喜欢的。为了避免你这么做,他们甚至在Swift语言中去掉了传统C风格for initial; condition; step循环。的确,当你对数组使用这种循环时,下标是一定会在循环内出场的。
另外一个他们不喜欢下标操作符的理由是,对于array[index]这样的访问,甚至都没有使用optional来保护越界的情况。通过下面的代码可以看到:
1 | let a = [1, 2, 3] |
a[1]的类型是Int,而不是Optional<Int>,这说明什么呢?你必须小心翼翼的使用index来访问Array中的元素,一旦index的值不正确,你就需要承担运行崩溃的严重后果。
那么,为什么要对[]如此冷漠呢?因为当我们把基于连续内存中的一组值进一步抽象成一个数组集合之后,下标这种方式带着太多C语言中和内存访问相关的历史气息。而我们应该把注意力更多的放在我们要解决的各种问题上。例如:
当我们想访问数组中的每一个元素时:
1 | a.forEach { print($0) } |
当我们要获得数组中每一个元素的索引和值时:
1 | for (index, value) in a.enumerated() {} |
当我们要查找数组中元素的位置时(例如,查找等于1的元素的索引):
1 | a.index { $0 == 1 } |
index会返回一个Optional<Int>,当要查找的元素存在时,就返回该元素的索引,否则,就返回nil。
当我们要筛选出数组中的某些元素时(例如,得到所有偶数):
1 | a.filter { $0 % 2 == 0 } |
当然,在下个视频中我们会专门和大家分享Swift Array常用操作的惯用形式。但至少现在,你应该已经感受到了,当你要完成特定的操作时,Swift一定有比直接使用下标更具表现力和安全的写法。
话又说回来,给[]添加optional保护也不能解决安全问题,因为一旦你force unwrapping一个optional,就有可能会带来一连串的force unwrapping。这不仅看上去不美观,从代码表现的含义上来说,既然已经准备好要为结果全权负责了,又何必要再让你多执行一步force unwrapping呢。
一些安全周到的方法
和[]的高风险形成鲜明对比的是,对于那些可以生成优秀代码的方法,Swift则考虑的面面俱到。例如:
访问数组中第一个和最后一个元素的first和last属性,当Array为空时,它们的值都是nil:
1 | a.first // 1 |
另外一个值得一提的是在Array末尾删除元素。Swift为这个动作提供了两个API:
removeLast,你需要自行确保数组中有元素,否则会引发运行时错误;popLast,如果数组为空,会返回nil;
为什么要如此呢?一个最通俗的解释就是,为了表意更清晰的代码。
当你基于Array实现诸如栈这样后入先出的数据结构时,弹出一个元素并判断是否为空是一个常规的操作,所以popLast返回了一个optional。而对于更一般的“删除数组中最后一个元素”这样的行为,Swift认为,这没有任何更具体的使用场景,你应该自己对这样的“低级操作”负责。
从循环到map
假设我们有一个简单的Fibonacci序列:[0, 1, 1, 2, 3, 5]。如果我们要计算每个元素的平方,怎么办呢?
一个最朴素的做法是for循环:
1 | var fibonacci = [0, 1, 1, 2, 3, 5] |
也许,现在你还觉得这样没什么不好理解,但是,想象一下这段代码在几十行代码中间的时候,或者当这样类似的逻辑反复出现的时候,整体代码的可读性就不那么强了。
如果你觉得这还不是个足够引起你注意的问题,那么,当我们要定义一个常量squares的时候,上面的代码就完全无法胜任了。怎么办呢?先来看解决方案:
1 | // [0, 1, 1, 4, 9, 25] |
上面这行代码,和之前那段for循环执行的结果是相同的。显然,它比for循环更具表现力,并且也能把我们期望的结果定义成常量。当然,map并不是什么魔法,无非就是把for循环执行的逻辑,封装在了函数里,这样我们就可以把函数的返回值赋值给常量了。我们可以通过extension很简单的自己来实现map:
1 | extension Array { |
虽然和Swift标准库相比,myMap的实现中去掉了和异常声明相关的部分。但它已经足以表现map的核心实现过程了。除了在append之前使用了reserveCapacity给新数组预留了空间之外,它的实现过程和一开始我们使用的for循环没有任何差别。
如果你还不了解
Element也没关系,把它理解为Array中元素类型的替代符就好了。在后面我们讲到Sequence类型的时候,会专门提到它。
完成后,当我们在playground里测试的时候:
1 | // [0, 1, 1, 4, 9, 25] |
就会发现执行结果和之前的constSequence是一样的了。
参数化数组元素的执行动作
其实,仔细观察myMap的实现,就会发现它最大的意义,就是保留了遍历Array的过程,而把要执行的动作留给了myMap的调用者通过参数去定制。而这,就是我们一开始提到的用closure来参数化对数组的操作行为的含义。
有了这种思路之后,我们就可以把各种常用的带有遍历行为的操作,定制成多种不同的遍历“套路”,而把对数组中每一个元素的处理动作留给函数的调用者。但是别急,在开始自动动手造轮子之前,Swift library已经为我们准备了一些,例如:
首先,是找到最小、最大值,对于这类操作来说,只要数组中的元素实现了Equatable protocol,我们甚至无需定义对元素的具体操作:
1 | fibonacci.min() // 0 |
使用min和max很安全,因为当数组为空时,这两个方法将返回nil。
其次,过滤出满足特定条件的元素,我们只要通过参数指定筛选规则就好了:
1 | fibonacci.filter { $0 % 2 == 0 } |
第三,比较数组相等或以特定元素开始。对这类操作,我们需要提供两个内容,一个是要比较的数组,另一个则是比较的规则:
1 | // false |
第四,最原始的for循环的替代品:
1 | fibonacci.forEach { print($0) } |
要注意它和map的一个重要区别:forEach并不处理closure参数的返回值。因此它只适合用来对数组中的元素进行一些操作,而不能用来产生返回结果。
第五、对数组进行排序,这时,我们需要通过参数指定的是排序规则:
1 | // [0, 1, 1, 2, 3, 5] |
其中,sorted(by:)的用法是很直接的,它默认采用升序排列。同时,也允许我们通过by自定义排序规则。在这里>是{ $0 > $1 }的简写形式。Swift中有很多在不影响语义的情况下的简写形式。
而partition(by:)则会先对传递给它的数组进行重排,然后根据指定的条件在重排的结果中返回一个分界点位置。这个分界点分开的两部分中,前半部分的元素都不满足指定条件;后半部分都满足指定条件。而后,我们就可以使用range operator来访问这两个区间形成的Array对象。大家可以根据例子中注释的结果,来理解partition的用法。
第六,是把数组的所有内容,“合并”成某种形式的值,对这类操作,我们需要指定的,是合并前的初始值,以及“合并”的规则。例如,我们计算fibonacci中所有元素的和:
1 | fibonacci.reduce(0, +) // 12 |
在这里,初始值是0,和第二个参数+,则是{ $0 + $1 }的缩写。
通过这些例子,你应该能感受到了,这些通过各种形式封装了遍历动作的方法,它们之中的任何一个,都比直接通过for循环实现具有更强的表现力。这些API,开始让我们的代码从面向机器的,转变成面向业务需求的。因此,在Swift里,你应该试着让自己转变观念,当你面对一个Array时,你真的几乎可以忘记下标和循环了。
区分修改外部变量和保存内部状态
当我们使用上面提到的这些带有closure参数的Array方法时,一个不好的做法就是通过closure去修改外部变量,并依赖这种副作用产生的结果。来看一个例子:
1 | var sum = 0 |
在这个例子里,map的执行产生了一个副作用,就是对fibonacci中所有的元素求和。这不是一个好的方法,我们应该避免这样。你应该单独使用reduce来完成这个操作,或者如果一定要在closure参数里修改外部变量,哪怕用forEach也是比map更好的方案。
但是,在函数实现内部,专门用一个外部变量来保存closure参数的执行状态,则是一个常用的实现技法。例如,我们要创建一个新的数组,其中每个值,都是数组当前位置和之前所有元素的和,可以这样:
1 | extension Array { |
在上面这个例子里,我们利用map的closure参数捕获了sum,这样就保存了每一次执行map时,之前所有元素的和。
1 | // [0, 1, 2, 4, 7, 12] |
filter和与filter类似的语义
之前,我们提到过filter的用法,用于在Array中,过滤满足特定条件的元素。而这个条件,就是通过filter的closure参数来确定的:
1 | var fibonacci = [0, 1, 1, 2, 3, 5] |
按照上一节中实现map的思路,我们可以自己来实现一个filter:
1 | extension Array { |
在上面的实现里,最核心的环节就是通过带有where条件的for循环找到原数组中符合条件的元素,然后把它们一一添加到tmp中,并最终返回给函数的调用者。然后,我们测试下myFilter:
1 | fibonacci.myFilter { $0 % 2 == 0 } // [0, 2] |
结果,应该是和标准库中自带的filter是一样的。理解了filter之后,我们就可以自行定义一些标准库中没有的方法。例如:
剔除掉数组中满足条件的元素:
1 | extension Array { |
我们只要把调用转发给filter,然后把指定的条件取反就好了。这样,剔除元素的代码语义上就会更好看一些:
1 | fibonacci.reject { $0 % 2 == 0 } // [1, 1, 3, 5] |
另一个基于filter语义的常用操作是判断数组中是否存在满足条件的元素。下面的代码可以完成任务:
1 | fibonacci.filter { $0 % 2 == 0 }.count > 0 // true |
但这样做在性能上并不理想,因为即便找到了满足条件的元素,也要遍历完整个数组,这显然是没必要的。Swift标准库中,提供了一个更方便的方法:
1 | fibonacci.contains { $0 % 2 == 0 } // true |
contains的一个好处就是只要遇到满足条件的元素,函数的执行就终止了。基于这个contains,我们还可以给Array添加一个新的方法,用来判断Array中所有的元素是否满足特定的条件:
1 | extension Array { |
在allMatch的实现里,只要没有不满足条件的元素,也就是所有元素都满足条件了。我们可以用下面的代码测试一下:
1 | let evens = [2, 4, 6, 8] |
reduce和与reduce相关的语义
除了用一个数组生成一个新的数组,有时,我们会希望把一个数组变成某种形式的值。例如,之前我们提到的求和:
1 | fibonacci.reduce(0, +) // 12 |
了解reduce的进一步用法之前,我们先来自己实现一个:
1 | extension Array { |
从上面的实现就可以看出,reduce的实现也没有什么神奇之处。无非就是把for循环迭代相加的过程封装了起来。然后,用下面的代码测试一下,就会发现和标准库中的reduce一样了。
1 | fibonacci.myReduce(0, +) // 12 |
除了求和之外,我们还可以把fibonacci reduce成一个字符串:
1 | let str = fibonacci.myReduce("") { str, num in |
甚至,我们还可以用reduce模拟map和filter的实现:
1 | extension Array { |
然后,简单测试一下:
1 | // [0, 1, 1, 4, 9, 25] |
它们的结果和标准库中的map和filter是一样的。但是,这种看似优雅的写法却没有想象中的那么好。在它们内部的reduce调用中,每一次$0的参数都是一个新建的数组,因此整个算法的复杂度是O(n2),而不再是for循环版本的O(n)。所以,这样的实现方法最好还是用来作为理解reduce用法的例子。
flatMap
最后,我们来了解flatMap。简单来说,如果你用在map中的closure参数不返回一个数组元素,而是也返回一个数组,这样,你就会得到一个数组的数组,但如果你只需要一个一维数组,flatMap就可以派上用场了,而这,也就是flat的含义。先来看一个例子:

假设,我们要给animals数组中的动物都使用ids中的数字进行编号。一开始,可能我们会写下这样的代码:
1 | animals.map { animal in |
但如果是这样,由于animals.map使用的closure参数返回的是一个Array,而不是单一元素,最终,我们会得到一个“数组的数组”:

但这并不是我们想要的,我们只是需要一个一维数组表示所有的(animal, id)。此时,就可以让flatMap派上用场了:
1 | animals.flatMap { animal in |
这样,我们就能得到期望的内容了:

实际上,flatMap的实现很简单,只要在map内部的for循环里,不断把closure参数生成的数组的内容,添加到要返回的结果里就好了:
1 | extension Array { |
用下面的代码测试,得到的结果,应该和之前使用flatMap是一样的:
1 | animals.myFlatMap { animal in |
Dictionary是除了Array之外的另一种非常重要的数据结构,它用于把某种形式的key,关联到某种形式的value。我们来看一个例子。
定义Dictionary
假设我们要定义一个数据结构,用来保存用户在泊学对某个视频的观看情况。可以这样:
1 | enum RecordType { |
在上面代码里,我们用[KeyType: ValueType]的形式来定义一个Dictionary。当定义好Dictionary之后,我们就能直接用[Key]来访问某个key对应的值了:
1 | record11["uid"] // number(11) |
上面例子中的结果都很直观。但是有一个细节却是值得我们注意的。和Array不同的是,[]用在Dictionary的时候,会返回一个Optional类型来确保这种形式的访问安全。因此,访问不存在的key,并不会导致运行时错误。
你怎么理解这种差异呢?
这是因为索引这个概念,对Array和Dictionary来说,是截然不同的。对于Array来说,我们有可能使用的正常索引值只源于Array自身,也就是0..<array.count,因此,如果你使用了不在这个范围里的值,则一定是可以被定性为Bug的,何况,我们之前也看到了,对于Array,我们几乎不需要直接使用索引来访问元素。
而对于Dictionary来说,它包含的内容并不直接决定我们可以查询的内容。举个例子来说,英汉词典中也可能并不包含我们要查询的单词。所以,Dictionary中包含的所有键值,从语义上说,并不完全决定了它的使用者会查询的值,所以,我们也无法把这类问题明确的归因于是Bug。所以,Swfit为Dictionary的索引查询操作,提供了optional保护。要么得到正确的结果,要么通过nil表示要查询的内容不存在。
常用的基本属性
作为一个集合类型,Dictionary同样有count和isEmpty两个属性读取其元素的个数以及判断其是否为空:
1 | record11.count // 4 |
另外,我们可以单独访问一个Dictionary的所有keys和所有values:
1 | record11.keys |
这两个属性也分别是一个集合,我们可以暂时忽略掉它们具体的类型,如果要我们要访问它们的每一个元素,直接用for循环或forEach遍历就好了:
1 | for key in record11.keys { print(key) } |
添加、更新和删除元素
和Array一样,Dictionary也是一个值类型,当我们复制Dictionary对象的时候,就会拷贝Dictionary中的所有内容:
1 | var record10 = record11 |
并且,直接使用key就可以访问和修改Dictionary的内容:
1 | record10['favourite'] = .bool(false) // false |
如果我们希望更新value的时候,同时获得修改前的值,还可以使用updateValue(_:forKey:)方法:
1 | record10.updateValue(.bool(true), |
从上面的结果可以看出修改record10并不会影响record11。
当我们要在Dictionary中添加元素时,直接给要添加的key赋值就好了:
1 | record10["watchLater"] = .bool(false) |
这样,record10中的内容,就变成了5项。而当我们要删除特定的key时,直接把它的值设置为nil:
1 | record10["watchLater"] = nil |
这里,并不是把特定key的值设置为nil(毕竟Dictionary中value部分的类型也不是optional),而是删除特定的key。当某个key的value被设置成nil后,这个key也就从Dictionary中删除了。
遍历Dictionary
由于Dictionary同时包含了key和value,因此,我们也有多重方式来遍历Dictionary。最简单的,就是遍历Dictionary中的每一个元素:
1 | for (k, v) in record10 { |
从上面的例子可以看到,遍历Dictionary和遍历Array是类似的。当我们使用for循环遍历时,它的每一个元素都用一个tuple来表示,封装了每一个元素的key和value。而当使用forEach方法时,它会给它的closure参数传递两个值,分别是每一个元素的key和value。
但是,由于Dictionary是一个无序集合(unordered collection),因此当我们编辑了Dictionary之后,每次遍历,访问元素的顺序都可能是不同的。如果我们希望按照固定的顺序来访问Dictionary中的元素,一个最简单的办法,就是对key排序后,再进行遍历:
1 | for key in record10.keys.sorted() { |
如果我们为上一节提到的视频观看记录提供一个默认值:
1 | enum RecordType { |
这样,当创建新纪录时,我们希望保持默认记录中的默认值,同时合并进不同用户的设置,例如:
1 | var template = defaultRecord |
merge
然而,该如何实现这个merge呢?最重要的事情,就是要想一下什么内容可以被merge进来。最一般的情况来说,无论任何形式的序列,只要它的元素中key和value的类型和Dictionary相同,就可以进行合并。
如何在代码中表达这个特征呢?来看下面的例子:
1 | extension Dictionary { |
由于Dictionary是一个struct,并且merge修改了self,我们必须使用mutating关键字修饰这个方法。而对于sequence参数,我们通过where关键字限定了两个内容:
S必须遵从Sequenceprotocol,Dictionary是众多遵从了Sequenceprotocol的collection类型之一,但是,我们没必要一定只能合并Dictionary;S的元素类型必须和原Dictionary的Element相同,其中Key和Value是Dictionary声明中的两个反省参数;
解决了参数问题之后,实现合并的算法就很简单了,我们只是更新self中每一个和sequence有相同key的值就好了。
这样,之前template.merge(record11Patch)就可以正常工作了。
既然,我们把merge参数的约束定义为了Sequence,那我们就来看一个合并非Dictionary类型的情况,例如,合并一个包含正确内容的Array:
1 | let record10Patch: [(key: String, value: RecordType)] = [ |
在上面的代码里,我们合并了一个tuple数组,它的类型是Array<String, RecordType>,数组中的每一项都包含了一个要合并进来的键值对。如果没有意外,合并Array和Dictionary都应该是可以正常工作的。
按照我们对merge的实现方式,实际上,任何一个遵从了Sequence protocol的类型,只要它包含了和template相同的元素类型,都是可以merge的。
用一个tuple数组初始化Dictionary
理解了merge的实现和用法之后,其实,我们很容易把这个场景进一步扩展下,如果我们可以merge类型兼容的Sequence,那么,用这样的Sequence来初始化一个Dictionary也是可以的,把它看成是和一个空的Dictionary进行合并就好了:
1 | extension Dictionary { |
有了这个方法之后,我们直接用下面的代码就可以创建一个新的Dictionary对象:
1 | let record11 = Dictionary(record11Patch) |
定制map的行为
最后一个要介绍的常用功能,是定制Dictionary.map的行为,默认情况下它返回的是一个Array,例如:
1 | record11.map { $1 } |
在上面的例子里,map返回一个Array<RecordType>,但有时,我们仅仅希望对value做一些变换,而仍旧保持Dictionary的类型。为此,我们可以自定义一个“只map value”的方法:
1 | extension Dictionary { |
在这个实现的最内部,我们用标准库中的map得到了一个Array<(String, RecordType)>类型的Array,而后,由于Array也遵从了Sequenceprotocol,因此,我们就能直接使用这个Array来定义新的Dictionary了。
完成之后,用下面的代码测试下:
1 | let newRecord11 = record11.mapValue { record -> String in |
这样,我们就用record11生成了一个Dictionary<String, String>类型的对象。
本质上来说,Dictionary是一个哈希表,它所有的key都用各自的哈希值保存在一个数组里。因此,通过key在Dictionary中访问value是一个O(1)操作。但这也对key的类型做出了一个要求,它必须可以计算哈希值。Swift标准库中提供的绝大多数类型,例如:Int / Float / Double / String / Bool / Date ...等,都满足这个要求,因此我们可以直接拿它们来定义Dictionary。
但如果我们有一个自定义类型Account,表示泊学的网站账号,其中的alias / type / createdAt分别表示账号的别名、类型和注册日期:
1 | struct Account { |
当我们把Account用作key的时候,Swift就会给我们提示下面的错误:Account没有遵从Hashable protocol:
1 | let account11 = Account(alias: "11", |

Conform to Hashable protocol
如何让自定义类型遵从Hashable protocol呢?第一件要做的事,就是告诉Swift,如何获取一个类型的哈希值,这是通过一个叫hashValue的属性完成的:
1 | extension Account: Hashable { |
如何计算Account.hashValue呢?有两个最重要的考量,分别是:性能和哈希值在整数范围的分布。因为每当我们要在Dictionary中查询、添加、修改或删除元素的时候,都要计算key的哈希值,如果这个计算过于消耗性能,那么计算哈希值的过程就有可能抵消掉通过key随机访问value带来的O(1)性能提升。
当然,你也不能盲目追求性能而忽视哈希值的整数值分布。说一个最极端的例子,如果你让所有情况计算得到的哈希值都是某个常数:
1 | extension Account: Hashable { |
这个哈希函数有O(1)的性能,但这样,不同的Account对象就会有不同的哈希值,这叫做Collision。当然,Swift Dictionary可以处理哈希值碰撞的情况,但你要随之付出的代价就是,通过哈希值读取value将从一个O(1)变成一个O(n)算法。因此,让哈希值在整数区间均匀分布也是设计哈希函数很重的考虑。
综上所述,设计一个好的哈希函数并不是一个容易的事情。对于我们来说,可以假设Swift标准库的类型提供的hashValue都满足性能和分布的要求。因此,当我们设计复合类型的哈希值的时候,可以基于这些标准类型的哈希值进行一些“低功耗”运算,例如,对这些值进行异或运算,绝大多数的CPU都对这个操作提供了指令级支持:
1 | extension Account: Hashable { |
解决了Account的哈希值之后,Swift会继而提示我们:Account没有遵从Equatable protocol。为什么还要遵从Equatable呢?这是因为哈希函数还有一个很重要的性质:两个相等对象的哈希值必须是相同的。因此,我们必须要解决什么叫做两个相等的对象,然后才有比较它们各自哈希值的事情。
Equatable只有一个约束,就是为自定义类型实现==操作符:
1 | extension Account: Equatable { |
在Swift里,运算符必须要定义成static方法,它的两个参数lhs / rhs则表示==两边的操作数。我们判断Account相等的方式很简单,只要它们每一个属性相等,则两个Account对象就是相等的。
当我们让Account遵从了Equatable之后,Swift编译器就不会再报错了。此时,我们在一开始创建的data也可以正常工作了。
Bitwise rotation
我们上面例子中提到的把所有属性进行XOR运算的方法,虽然简单高效,但也有一个问题,就是比较容易造成碰撞。因为XOR运算是可交换的,也就是说a ^ b == b ^ a,因此,如果一个自定义类型中,有多个类型相同属性的时候,就会增大哈希值发生碰撞的概率,因此,我们可以用下面的代码,对其中的一些基础属性的哈希值进行按位旋转后再进行XOR运算:
1 | struct Account { |
首先,我们在Account中添加了一个常量INT_BIT表示一个整数的位数。其次,定义了一个辅助方法bitwiseRotate(value:bits:),它用于先把value向左移动bits位,再向右移动(UINT_BIT - bits)位。
有了这个方法之后,我们就可以在计算哈希值的时候,对其中的属性进行按位旋转了。
警惕引用类型的Key
和Dictionary.Key相关的最后一个内容,是尽可能避免使用引用类型作为key,这通常会给你带来不必要的麻烦。当一个引用类型作为key之后,当引用类型的对象在Dictionary之外被修改的时候,Key的内容也会随之修改。这样你就再也无法获得之前的哈希值,也就无法获得对应的value了。
除了Dictionary之外,Set是Swift标准库中,另外一个主要的无序集合(unordered collection)类型,包含一组不重复的值。你也可以把Set理解为一个只包含key而没有value的集合。本质上,Set也是一个哈希表,因此它有着和Dictionary诸多类似的地方。
在了解Set的各种用法之前,我们先来看如何定义一个Set。
初始化Set
例如,我们要创建一个包含所有元音的Set:
1 | var vowel: Set<Character> = ["a", "e", "i", "o", "u"] |
这里,由于初始化Set和Array的方式是一样的,因此,当我们要定义一个Set对象时,必须明确使用type annotation。Type inference会把这样的定义方式推导为一个Array。
Set的常用属性和方法
作为一个集合类型,Set提供了和Array以及Dictionary一样的常用属性:
1 | vowel.count // 5 |
以及常用的编辑方法:
1 | vowel.contains("a") // true |
在上面的代码里:
contains判断它的参数是否在Set中,并返回一个bool值表示判断结果;remove在Set中删除参数指定的元素,如果元素存在就成功删除并返回删除的元素,否则就返回nil;insert在Set中插入参数指定的内容,如果插入的内容已存在,会返回一个值为(false, 插入值)的tuple,否则,就返回(true, 插入值);removeAll则删除所有Set中的元素,留下一个空的集合;
遍历Set
和Dictionary类似,我们有三种方式来遍历Set。首先,是最普通的for循环:
1 | for character in vowel { |
其次,是集合自身的forEach方法:
1 | vowel.forEach { print($0) } |
通过注释中的方法可以看到,当遍历一个Set时,访问元素的顺序,并不是我们定义Set时的顺序,这也就是unordered collection的含义。
当我们每次遍历Set时,遍历的顺序,都会根据当前Set包含的值而有所不同。如果你希望按照某种“固定”的排序方式访问Set中的元素,就要使用它的sorted方法:
1 | for character in vowel.sorted() { |
这样,我们就可以顺序访问Set中的元素了。