Swift小知識點之String.Index


前言

  我們先來看一下 String 常見的使用場景:

let str = "String 的 Index 為什么這么難用?"
let targetIndex = str.index(str.startIndex, offsetBy: 4) str[targetIndex]

  上面這段代碼有幾個地方容易讓人產生疑惑:

  1. 為什么 targetIndex 要調用 String 的實例方法去生成?
  2. 為什么這里需要使用 str.startIndex,而不是 0
  3. 為什么 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,基本方向很明確,新增一種相對索引類型:

  1. Collection 通用的索引類型。不需要考慮具體的 Index 類型,不需要根據數組實例去生成 Index,新的索引會在內部轉換成 Collection 里的具體 Index 類型。
  2. 簡化 Index 的生成。
  3. subscript 返回 Optional 類型。

參考

https://kemchenj.github.io/2019-10-07/


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM