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 了,但願這篇文章能夠幫助你們更好地理解這個提案的來龍去脈,也歡迎你們留言一塊兒交流。
參考連接: