前言
我們先來看一下 String
常見的使用場景:
let str = "String 的 Index 為什么這么難用?"
let targetIndex = str.index(str.startIndex, offsetBy: 4) str[targetIndex]
上面這段代碼有幾個地方容易讓人產生疑惑:
- 為什么
targetIndex
要調用String
的實例方法去生成? - 為什么這里需要使用
str.startIndex
,而不是0
? - 為什么
String.Index
使用了一個自定義類型,而不是直接使用Int
?
上述的這些問題也造成了 String 的 API 變得十分繁瑣,在其它語言里一行代碼能解決的問題在 Swift 需要好幾行,但這些其實都是 Swift 有意而為之的設計……
不等長的元素
在我們使用數組的時候,會有一個這樣的假設:數組的每個元素都是等長的。例如在 C 里面,數組第 n 個元素的位置會是 數組指針 + n * 元素長度
,這道公式可以讓我們在 O(1) 的時間內獲取到第 n 個元素。
但在 Swift 里這件事情並不一定成立,最好的例子就是 String
,它的底層實現是 UTF-8 編碼單位的集合,而暴露給外部的則是字符的集合,每個字符由 1 到 4 個 UTF-8 編碼單位組成,換句話說,作為字符的集合時,實際上 String
的每一個元素的長度是不相等的。
這就意味着通過字符索引獲取元素的時候,沒辦法簡單地通過上面的公式(數組第 n 個元素的位置會是 數組指針 + n * 元素長度
)計算出對應字符的位置,必須一直遍歷到對應的元素才能獲取到它的實際位置(UTF-8 編碼單位的索引)。
那么問題就來了,如果要像 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 是怎么設計的?
思路很簡單,通過自定義 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
的設計說明:
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 = "C 語言" let index = str.index(str.startIndex, offsetBy: 2) // 使用 String 的實例生成 index // | C | | 語 | 言 | // | U+0043 | U+0020 | U+8BED | U+8A00 | // | 43 | 20 | E8 | AF | AD | E8 | A8 | 80 | // ^ // index 的位置 // // index.encodedOffset == 2 “語”之前的字符使用的 UTF-8 編碼單位數量 // 總共有兩個,“C” 使用了一個 43,“ ” 使用了一個 20 // index.transcodedOffset == 3 “語”由 E8 AF AD 三個 UTF-8 編碼單位組成 // // 換句話說 index 所代表的含義就是: // 偏移 transcodedOffset 個 UTF-8 編碼單位 // 取 encodedOffset 個 UTF-8 編碼單位 print(str[index]) // 語
這種實現方式有趣的一點是,Index
使用過程中最消耗性能的是 Index
的生成,一旦 Index
生成了,使用它取值的操作復雜度都只會是 O(1)。
並且由於這種實現的特點,每個 String
的實例只應該使用自己生成的 Index
,使用其它實例生成的 Index
會導致意外情況的發生:
let str2 = "Clang" // | C | l | a | n | g | // | U+0043 | U+006C | U+0061 | U+006E | U+0067 | // | 43 | 6C | 61 | 6E | 67 | // ^ // index 的位置 // // 偏移了 2 個單位,取 3 個單位,所以這里會取到三個字符 // 但作為一個索引,理論上 index 只應該指向一個字符 print(str2[index]) // ang
Index
在多個字符串間復用,就會造成這種一個索引會取到三個字符的意外情況,Swift 開發組表示過這屬於一種未定義行為,在未來有可能會在運行時作為錯誤拋出。
大費周章支持不等長的元素?
如果不需要讓 Collection
去支持不等長的元素,那一切就會變得非常簡單,Collection
不再需要 Index
這一層抽象,直接使用 Int
即可,並且在標准庫的類型里元素不等長的集合類型也只有 String
,對它進行特殊處理也是一種可行的方案。
擺在 Swift 開發組面前的是兩個選擇:
- 繼續完善
Collection
協議,讓它更好地支持元素不等長的情況。 - 或者是專門給
String
建立一套機制,讓它獨立運行在 Collection 的體系之外。
開發組在這件事情上的態度其實也有過搖擺:
- Swift 1 里
String
是遵循Collection
的。 - Swift 2~3 的時候移除了這個 Conformance,計划逐漸棄用掉
Index
這一層抽象直接使用Int
。 - 但在 Swift 4 之后又重新改了回去。
這樣做的好處主要還是保證 API 的正確性,提升代碼的復用,之前在 Swift 2~3 里擴展一些集合相關的函數時,一模一樣的代碼需要在 String
和 Collection
里各寫一套實現。
盡管我們確實需要 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
其實是絕對索引,但對於我們來說,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 之間的距離是 1,但也不是 1
前面提到了 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]
卻是非常常見的。由於這種使用場景上的差異,所以
Array
通常會使用一個非Optional
的下標 API,並且會在使用非法 index 時直接崩潰。而Dictionary
則擁有一個Optional
的下標 API,並且在 index 非法時直接返回nil
。
總結
核心開發團隊先后有過兩個草案改進 String 的 API,基本方向很明確,新增一種相對索引類型:
- Collection 通用的索引類型。不需要考慮具體的
Index
類型,不需要根據數組實例去生成Index
,新的索引會在內部轉換成Collection
里的具體Index
類型。 - 簡化 Index 的生成。
- subscript 返回 Optional 類型。