Swift 的字符串为何这么难用?

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

  1. 为何 targetIndex 要调用 String 的实例方法去生成?
  2. 为何这里须要使用 str.startIndex,而不是 0
  3. 为何 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])
}
复制代码

String.Index 是怎么设计的?

那 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

StringIndex 的内存布局以下:
 
 ┌──────────┬───────────────────╥────────────────┬──────────╥────────────────┐
 │ 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 里记录了码位的偏移量,而每一个 StringIndex 对应的偏移量都会有差别,因此咱们在生成 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 的体系以外。

开发组在这件事情上的态度其实也有过摇摆:

  1. Swift 1 里 String 是遵循 Collection 的。
  2. Swift 2~3 的时候移除了这个 Conformance,计划逐渐弃用掉 Index 这一层抽象直接使用 Int
  3. 但在 Swift 4 以后又从新改了回去。

这样作的好处主要仍是保证 API 的正确性,提高代码的复用,以前在 Swift 2~3 里扩展一些集合相关的函数时,如出一辙的代码须要在 StringCollection 里各写一套实现。

尽管咱们确实须要 Index 这一层抽象去表达 String 这一类元素不等长的数组,但也不能否认它给 API 调用带来了必定程度负担。(Swift 更倾向于 API 的正确性,而不是易用性)

Index 不必定从 0 开始

在使用一部分切片集合的时候,例如 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 是绝对索引

换句话说,Collection 里的 Index 实际上是绝对索引,但对于咱们来讲,ArrayArraySlice 除了在生命周期处理时须要注意以外,其它 API 的调用都不会存在任何差别,也不该该存在差别,使用相对索引屏蔽掉数组和切片之间的差别应该是更好的选择,那还为何要设计成如今的样子?

这个问题在论坛里有过很激烈的讨论,核心开发组也只是出来简单地提了两句,大意是虽然对于用户来讲确实不存在区别,但对于(标准库)集合类型的算法来讲,基于现有的设计能够采起更加简单高效的实现,而且实现出来的算法也不存在 Index 必须为 Int 的限制。

我我的的理解是,对于 Index == IntCollection 来讲,SubSequencestartIndex 设为 0 确实很方便,但这也是最大的问题,任何以此为前提的代码都只对于 Index == IntCollection 有效,对于 Index != IntCollection,缺少相似于 0 这样的常量来做为 startIndex,很难在抽象层面去实现统一的集合算法。

咱们想要的是相对索引

其实咱们能够把当前的 Index 看做是 underlying collection 的绝对索引,咱们想要的不是 0-based collection 而是相对索引,但相对索引最终仍是要转换成绝对索引才能获取到对应的数据,但这种相对索引意味着 API 在调用时要加一层索引的映射,而且在处理 SubSequenceSubSequence 这种嵌套调用时,想要避免多层索引映射带来的性能消耗也是须要额外的实现复杂度。

不管 Swift 以后是否会新增相对索引,它都须要基于绝对索引去实现,如今的问题只是绝对索引做为 API 首先被呈现出来,而咱们在缺少认知的状况下使用就会显得使用起来过于繁琐。

调整一下咱们对于 Collection 抽象的认知,抛弃掉数组索引一定是 0 开头的想法,换成更加抽象化的 startIndex,这件事情就能够变得天然不少。引入抽象提高性能在 Swift 并很多见,例如说 @escapingweak,习惯了以后其实也没那么糟糕。

Index 之间的距离是 1,但也不是 1

前面提到了 Index == IntCollection 类型必定是从 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 == IntCollection,直接使用 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 来下标取值时,难道这不是一个程序员的失误吗?

ArrayDictionary 的使用场景是存在差别的。

我认为 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,基本方向很明确,新增一种相对索引类型:

  1. Collection 通用的索引类型。不须要考虑具体的 Index 类型,不须要根据数组实例去生成 Index,新的索引会在内部转换成 Collection 里的具体 Index 类型。
  2. 简化 Index 的生成
  3. subscript 返回 Optional 类型

具体的内容你们能够看提案,我是在第二份草案刚提出的时候开始写这篇文章的,删删改改终于写完了,如今草案已经变成了正式提案在 review 了,但愿这篇文章能够帮助你们更好地理解这个提案的来龙去脉,也欢迎你们留言一块儿交流。

参考连接:

相关文章
相关标签/搜索