Swift 里的 String
繁琐难用的问题一直是你们频繁吐槽的点,趁着前两天 Swift 团队发了一份新的提案 SE-0265 Offset-Based Access to Indices, Elements, and Slices 来改善 String
的使用,我想跟你们分享一下本身的理解。html
SE-0265 提案的内容并不难理解,主要是增长 API 去简化几个 Collection.subscript
函数的使用,但这个提案的背景故事就比较多了,因此此次我想聊的是 Collection.Index 的设计。git
在分析 Collection.Index
以前,咱们先来看一下 String
常见的使用场景:程序员
let str = "String 的 Index 为何这么难用?"
let targetIndex = str.index(str.startIndex, offsetBy: 4)
str[targetIndex]
复制代码
上面这段代码有几个地方容易让人产生疑惑:github
targetIndex
要调用 String
的实例方法去生成?str.startIndex
,而不是 0
?String.Index
使用了一个自定义类型,而不是直接使用 Int
?上述的这些问题也也让 String 的 API 调用变得繁琐,在其它语言里一个语句能解决的问题在 Swift 须要多个,但这些其实都是 Swift 有意而为之的设计......算法
在咱们使用数组的时候,会有一个这样的假设:数组的每一个元素都是等长的。例如在 C 里面,数组第 n 个元素的位置会是 数组指针 + n * 元素长度
,这道公式可让咱们在 O(1) 的时间内获取到第 n 个元素。swift
但在 Swift 里这件事情并不必定成立,最好的例子就是 String
,每个元素均可能会是由 1~4 个 UTF8 码位组成。这就意味着经过索引获取元素的时候,没办法简单地经过上面的公式计算出元素的位置,必须一直遍历到索引对应的元素才能获取到它的实际位置(偏移量)。api
像 Array
那样直接使用 Int
做为索引的话,诸如迭代等操做就会产生更多的性能消耗,由于每次迭代都须要从新计算码位的偏移量:数组
// 假设 String 是以 Int 做为 Index 的话
// 下面的代码复杂度将会是 O(n^2)
// O(1) + O(2) + ... + O(n) = O(n!) ~= O(n^2)
let hello = "Hello"
for i in 0..<hello.count {
print(hello[i])
}
复制代码
那 Swift 的 String
是怎么解决这个问题的呢?思路很简单,经过自定义 Index
类型,在内部记录对应元素的偏移量,迭代过程当中复用它计算下一个 index 便可:安全
// 下面的代码复杂度将会是 O(n)
// O(1) + O(1) + ... + O(1) = O(n)
let hello = "Hello"
var i = hello.startIndex
while i != hello.endIndex {
print(hello[i])
hello.formIndex(after: i)
}
复制代码
在源码里咱们能够找到 String.Index
的设计说明:app
String 的 Index 的内存布局以下:
┌──────────┬───────────────────╥────────────────┬──────────╥────────────────┐
│ b63:b16 │ b15:b14 ║ b13:b8 │ b7:b1 ║ b0 │
├──────────┼───────────────────╫────────────────┼──────────╫────────────────┤
│ position │ transcoded offset ║ grapheme cache │ reserved ║ scalar aligned │
└──────────┴───────────────────╨────────────────┴──────────╨────────────────┘
- position aka `encodedOffset`: 一个 48 bit 值,用来记录码位偏移量
- transcoded offset: 一个 2 bit 的值,用来记录字符使用的码位数量
- grapheme cache: 一个 6 bit 的值,用来记录下一个字符的边界(?)
- reserved: 7 bit 的预留字段
- scalar aligned: 一个 1 bit 的值,用来记录标量是否已经对齐过(?)
复制代码
但因为 Index
里记录了码位的偏移量,而每一个 String
的 Index
对应的偏移量都会有差别,因此咱们在生成 Index
时必须使用 String
的实例来进行计算:
let str = "String 的切片为何这么难用?"
let k = 1
let targetIndex = str.index(str.startIndex, offsetBy: k) // 这里必须使用 str 去生成 Index
print(str[targetIndex])
复制代码
这种实现方式有趣的一点是,Index
使用过程当中最消耗性能的是 Index
的生成,一旦 Index
生成了,使用它取值的操做复杂度都只会是 O(1)。
而且因为这种实现的特色,不一样的 String
实例生成的 Index
也不该该被混用的:
// | C | | 语 | 言 |
// | U+0043 | U+0020 | U+8BED | U+8A00 |
// | 43 | 20 | E8 | AF | AD | E8 | A8 | 80 |
let str = "C 语言"
// | C | l | a | n | g |
// | U+0043 | U+006C | U+0061 | U+006E | U+0067 |
// | 43 | 6C | 61 | 6E | 67 |
let str2 = "Clang"
// i.encodedOffset == 2 (偏移量)
// i.transcodedOffset == 3 (长度)
let i = str.index(str.startIndex, offsetBy: 2)
print(str[i]) // 语
print(str2[i]) // ang
复制代码
Swift 开发组表示过 Index
的混用属于一种未定义行为,在将来有可能会在运行时做为错误抛出。
若是不须要让 Collection
去支持不等长的元素,那一切就会变得很是简单,Collection
再也不须要 Index
这一层抽象,直接使用 Int
便可,而且在标准库的类型里元素不等长的集合类型也只有 String
,对它进行特殊处理也是一种可行的方案。
摆在 Swift 开发组面前的是两个选择:
Collection
协议,让它更好地支持元素不等长的状况。String
创建一套机制,让它独立运行在 Collection 的体系以外。开发组在这件事情上的态度其实也有过摇摆:
String
是遵循 Collection
的。Index
这一层抽象直接使用 Int
。这样作的好处主要仍是保证 API 的正确性,提高代码的复用,以前在 Swift 2~3 里扩展一些集合相关的函数时,如出一辙的代码须要在 String
和 Collection
里各写一套实现。
尽管咱们确实须要 Index
这一层抽象去表达 String
这一类元素不等长的数组,但也不能否认它给 API 调用带来了必定程度负担。(Swift 更倾向于 API 的正确性,而不是易用性)
在使用一部分切片集合的时候,例如 ArraySlice
在使用 Index
取值时,你们也许会发现一些意料以外的行为,例如说:
let a = [0, 1, 2, 3, 4]
let b = a[1...3]
print(b[1]) // 1
复制代码
这里咱们预想的结果应该是 2
而不是 1
,缘由是咱们在调用 b[1]
时有一个预设:全部集合的下标都是从 0 开始的。但对于 Swift 里的集合类型来讲,这件事情并不成立:
print(b.startIndex) // 1
print((10..<100).startIndex) // 10
复制代码
换句话说,Collection
里的 Index
实际上是绝对索引,但对于咱们来讲,Array
和 ArraySlice
除了在生命周期处理时须要注意以外,其它 API 的调用都不会存在任何差别,也不该该存在差别,使用相对索引屏蔽掉数组和切片之间的差别应该是更好的选择,那还为何要设计成如今的样子?
这个问题在论坛里有过很激烈的讨论,核心开发组也只是出来简单地提了两句,大意是虽然对于用户来讲确实不存在区别,但对于(标准库)集合类型的算法来讲,基于现有的设计能够采起更加简单高效的实现,而且实现出来的算法也不存在 Index 必须为 Int 的限制。
我我的的理解是,对于 Index == Int
的 Collection
来讲,SubSequence
的 startIndex
设为 0 确实很方便,但这也是最大的问题,任何以此为前提的代码都只对于 Index == Int
的 Collection
有效,对于 Index != Int
的 Collection
,缺少相似于 0 这样的常量来做为 startIndex
,很难在抽象层面去实现统一的集合算法。
其实咱们能够把当前的 Index
看做是 underlying collection 的绝对索引,咱们想要的不是 0-based collection 而是相对索引,但相对索引最终仍是要转换成绝对索引才能获取到对应的数据,但这种相对索引意味着 API 在调用时要加一层索引的映射,而且在处理 SubSequence
的 SubSequence
这种嵌套调用时,想要避免多层索引映射带来的性能消耗也是须要额外的实现复杂度。
不管 Swift 以后是否会新增相对索引,它都须要基于绝对索引去实现,如今的问题只是绝对索引做为 API 首先被呈现出来,而咱们在缺少认知的状况下使用就会显得使用起来过于繁琐。
调整一下咱们对于 Collection
抽象的认知,抛弃掉数组索引一定是 0 开头的想法,换成更加抽象化的 startIndex
,这件事情就能够变得天然不少。引入抽象提高性能在 Swift 并很多见,例如说 @escaping
和 weak
,习惯了以后其实也没那么糟糕。
前面提到了 Index == Int
的 Collection
类型必定是从 0 开始,除此以外,因为 Index
偏移的逻辑也被抽象了出来,此时的 Collection
表现出来另外一个特性 —— Index 之间的距离不必定是 "1"。
假设咱们要实现一个采样函数,每隔 n 个元素取一次数组的值:
extension Array {
func sample(interval: Int, execute: (Element) -> Void) {
var i = 0
while i < count {
execute(self[i])
i += interval
}
}
}
[0, 1, 2, 3, 4, 5, 6].sample(interval: 2) {
print($0) // 0, 2, 4, 6
}
复制代码
若是咱们想要让它变得更加泛用,让它可以适用于大部分集合类型,那么最好将它抽象成为一个类型,就像 Swift 标准库那些集合类型:
struct SampleCollection<C: RandomAccessCollection>: RandomAccessCollection {
let storage: C
let sampleInterval: Int
var startIndex: C.Index { storage.startIndex }
var endIndex: C.Index { storage.endIndex }
func index(before i: C.Index) -> C.Index {
if i == endIndex {
return storage.index(endIndex, offsetBy: -storage.count.remainderReportingOverflow(dividingBy: sampleInterval).partialValue)
} else {
return storage.index(i, offsetBy: -sampleInterval)
}
}
func index(after i: C.Index) -> C.Index { storage.index(i, offsetBy: sampleInterval, limitedBy: endIndex) ?? endIndex }
func distance(from start: C.Index, to end: C.Index) -> Int { storage.distance(from: start, to: end) / sampleInterval }
subscript(position: C.Index) -> C.Element { storage[position] }
init(sampleInterval: Int, storage: C) {
self.sampleInterval = sampleInterval
self.storage = storage
}
}
复制代码
封装好了类型,那么咱们能够像 prefix
/ suffix
那样给对应的类型加上拓展方法,方便调用:
extension RandomAccessCollection {
func sample(interval: Int) -> SampleCollection<Self> {
SampleCollection(sampleInterval: interval, storage: self)
}
}
let array = [0, 1, 2, 3, 4, 5, 6]
array.sample(interval: 2).forEach { print($0) } // 0, 2, 4, 6
array.sample(interval: 3).forEach { print($0) } // 0, 3, 6
array.sample(interval: 4).forEach { print($0) } // 0, 4
复制代码
SampleCollection
经过实现那些 Index
相关的方法达到了采样的效果,这意味着 Index
的抽象实际上是经由 Collection
诠释出来的概念,与 Index
自己并无任何关系。
例如说两个 Index
之间的距离,0 跟 2 对于两个不一样的集合类型来讲,它们的 distance
实际上是能够不一样的:
let sampled = array.sample(interval: 2)
let firstIdx = sampled.startIndex // 0
let secondIdx = sampled.index(after: firstIdx) // 2
let numericDistance = secondIdx - firstIdx. // 2
array.distance(from: firstIdx, to: secondIdx) // 2
sampled.distance(from: firstIdx, to: secondIdx) // 1
复制代码
因此咱们在使用 Index == Int
的集合时,想要获取集合的第二个元素,使用 1
做为下标取值是一种错误的行为:
sampled[1] // 1
sampled[secondIdx] // 2
复制代码
Collection
会使用本身的方式去诠释两个 Index
之间的距离,因此就算咱们赶上了 Index == Int
的 Collection
,直接使用 Index
进行递增递减也不是一种正确的行为,最好仍是正视这一层泛型抽象,减小对于具体类型的依赖。
Swift 一直称本身是类型安全的语言,早期移除了 C 的 for 循环,引入了大量“函数式”的 API 去避免数组越界发生,但在使用索引或者切片 API 时越界仍是会直接致使崩溃,这种行为彷佛并不符合 Swift 的“安全”理念。
社区里每隔一段时间就会有人提议过改成使用 Optional
的返回值,而不是直接崩溃,但这些建议都被打回,甚至在 Commonly Rejected Changes 里有专门的一节叫你们不要再提这方面的建议(除非有特别充分的理由)。
那么类型安全意味着什么呢?Swift 所说的安全其实并不是是指避免崩溃,而是避免未定义行为(Undefined Behavior),例如说数组越界时读写到了数组以外的内存区域,此时 Swift 会更倾向于终止程序的运行,而不是处于一个内存数据错误的状态继续运行下去。
Swift 开发组认为,数组越界是一种逻辑上的错误,在早期的邮件列表里比较清楚地阐述过这一点:
On Dec 14, 2015, at 6:13 PM, Brent Royal-Gordon via swift-evolution wrote:
...有一个很相似的使用场景,
Dictionary
在下标取值时返回了一个Optional
值。你也许会认为这跟Array
的行为很是不一致。让我换一个说法来表达这件认知,对于Dictionary
来讲,当你使用一个 key set 以外的 key 来下标取值时,难道这不是一个程序员的失误吗?
Array
和Dictionary
的使用场景是存在差别的。我认为
Array
下标取值 80% 的状况下,使用的 index 都是经过Array
的实例间接或直接生成的,例如说0..<array.count
,或者array.indices
,亦或者是从tableView(_:numberOfRowsInSection:)
返回的array.count
派生出来的array[indexPath.row]
。这跟Dictionary
的使用场景是不同的,一般它的 key 都是别的什么数据里取出来的,或者是你想要查找与其匹配的值。例如,你不多会直接使用array[2]
或array[someRandomNumberFromSomewhere]
,但dictionary[“myKey”]
或dictionary[someRandomValueFromSomewhere]
倒是很是常见的。因为这种使用场景上的chayi,因此
Array
一般会使用一个非Optional
的下标 API,而且会在使用非法 index 时直接崩溃。而Dictionary
则拥有一个Optional
的下标 API,而且在 index 非法时直接返回nil
。
核心开发团队前后有过两个草案改进 String 的 API,基本方向很明确,新增一种相对索引类型:
Index
类型,不须要根据数组实例去生成 Index
,新的索引会在内部转换成 Collection
里的具体 Index
类型。具体的内容你们能够看提案,我是在第二份草案刚提出的时候开始写这篇文章的,删删改改终于写完了,如今草案已经变成了正式提案在 review 了,但愿这篇文章能够帮助你们更好地理解这个提案的来龙去脉,也欢迎你们留言一块儿交流。
参考连接: