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]
複製程式碼
上面這段程式碼有幾個地方容易讓人產生疑惑:
- 為什麼
targetIndex
要呼叫String
的例項方法去生成? - 為什麼這裡需要使用
str.startIndex
,而不是0
? - 為什麼
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
的設計說明:
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 的體系之外。
開發組在這件事情上的態度其實也有過搖擺:
- 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,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 == 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,基本方向很明確,新增一種相對索引型別:
-
Collection 通用的索引型別。不需要考慮具體的
Index
型別,不需要根據陣列例項去生成Index
,新的索引會在內部轉換成Collection
裡的具體Index
型別。 - 簡化 Index 的生成。
- subscript 返回 Optional 型別。
具體的內容大家可以看提案,我是在第二份草案剛提出的時候開始寫這篇文章的,刪刪改改終於寫完了,現在草案已經變成了正式提案在 review 了,希望這篇文章可以幫助大家更好地理解這個提案的前因後果,也歡迎大家留言一起交流。
參考連結:
- Offset Indexing and Slicing - Swift Forums
- String Essentials - Swift Forms
- swift/SequencesAndCollections.rst at master
- swift/StringDesign.rst at master
- swift/StringManifesto.md at master
- 46: “A desire for simplicity and performance”,with special guest Michael Ilseman -- Swift by Sundell
- Strings in Swift 4 - Ole Begemann
- Add Accessor With Bounds Check To Array - Swift Forums