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

參考連接:

相關文章
相關標籤/搜索