1. 程式人生 > IOS開發 >Swift 的字串為什麼這麼難用?

Swift 的字串為什麼這麼難用?

Swift 裡的 String 繁瑣難用的問題一直是大家頻繁吐槽的點,趁著前兩天 Swift 團隊發了一份新的提案 SE-0265 Offset-Based Access to Indices,Elements,and Slices 來改善 String 的使用,我想跟大家分享一下自己的理解。

SE-0265 提案的內容並不難理解,主要是增加 API 去簡化幾個 Collection.subscript 函式的使用,但這個提案的背景故事就比較多了,所以這次我想聊的是 Collection.Index 的設計

在分析 Collection.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,每一個元素都可能會是由 1~4 個 UTF8 碼位組成。這就意味著通過索引獲取元素的時候,沒辦法簡單地通過上面的公式計算出元素的位置,必須一直遍歷到索引對應的元素才能獲取到它的實際位置(偏移量)。

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 的設計說明:

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,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,6]
array.sample(interval: 2).forEach { print($0) } // 0,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 了,希望這篇文章可以幫助大家更好地理解這個提案的前因後果,也歡迎大家留言一起交流。

參考連結: