WWDC 2018:在Swift中如何高效地使用集合

tingxins發表於2019-02-27

Session 229: Using Collections Effectively

所有應用都用到了集合,為了獲得最佳效能,瞭解背後的基礎知識,關於如果更好的使用索引、切片、惰性、橋接以及引用型別,本 Session 講了些 Tips。

集合簡介

Swift 中集合型別很多,如:

WWDC 2018:在Swift中如何高效地使用集合

所有集合型別都有很多共有特性(Common Features),在 Swift 中,它們都遵守 Protocol Collection 協議,官方文件有這樣一段話:

If you create a custom sequence that can provide repeated access to its elements, make sure that its type conforms to the Collection protocol in order to give a more useful and more efficient interface for sequence and collection operations. To add Collection conformance to your type, you must declare at least the following requirements: - The startIndex and endIndex properties - A subscript that provides at least read-only access to your type's elements - The index(after:) method for advancing an index into your collection

文件翻譯過來就是至少要實現如下協議介面:

WWDC 2018:在Swift中如何高效地使用集合

基於 Collection 協議型別,我們可以自定義集合並遵守此類協議,並且還可以做一些協議擴充套件,Swift 標準庫還額外提供了四種集合協議型別

WWDC 2018:在Swift中如何高效地使用集合

  • 雙向索引集合:BidirectionalCollection,支援向後並且能夠向前遍歷的集合

  • 隨機訪問集合:RandomAccessCollection,提供高效的儲存方式,跳轉到任意索引的時間複雜度為O(1)。

  • 可變集合:MutableCollection,支援下標訪問修改元素。

  • 區間可替換集合:RangeReplaceableCollection,支援通過索引範圍替換集合中的元素。

以上四種協議型別本文不做詳述。

索引

索引,即在集合中的位置。每種集合型別都定義了自己的索引,並且索引都必須遵守 Comparable 協議。

  • 如何取陣列中的第一個元素?

    1. array[0] ?
    2. array[array.startIndex] ?
    3. array.first ?

顯然第三種方式更加安全,防止潛在的 Crash。

  • 如何獲取集合中的第二個元素?

    1. 方式一

      WWDC 2018:在Swift中如何高效地使用集合

    2. 方式二

      WWDC 2018:在Swift中如何高效地使用集合

    3. 方式三

      WWDC 2018:在Swift中如何高效地使用集合

針對第二個問題,前兩種方式似乎行不通,因為 Index 型別不一定是 Int 型別,那麼就只有方式三了, 我們有沒有更好的方式來解決呢?當然有,下面筆者先簡單介紹下切片(Slice)。

切片

相信大家看到 Slice 這個單詞都很熟悉,如:使用陣列時,經常會出現 ArraySlice 這種型別。切片其實也是一種型別,基於集合型別的一種封裝,通俗點講,切片就是集合某一部分。

這裡有兩個關鍵點

  • 切片與原始集合共享索引。
  • 切片會持有集合該塊記憶體。

什麼意思呢?Show the code.

WWDC 2018:在Swift中如何高效地使用集合

從上圖中,我們可以看出 subarray 的 startIndex 等於 array 的 secondIndex。因此針對上述提出的問題如何獲取集合中的第二個元素?,更好的處理方式是這樣,因為切片與原始集合共享索引:

WWDC 2018:在Swift中如何高效地使用集合

關於切片會“持有”集合該記憶體塊如何理解呢?我們來看看程式碼:

WWDC 2018:在Swift中如何高效地使用集合

什麼意思呢?當 array = [] 時,集合並沒有從記憶體中銷燬,當 firstHalf = [] 時,集合才正真被銷燬。官方的這個 CASE,讀者可能不怎麼好理解,筆者再簡單舉個例子:

class Person {
   var name: String
   
   init(name: String) {
       self.name = name
   }
   
   deinit {
       print("\(#function)###\(name)")
   }
}

var persons = [Person(name: "jack"), Person(name: "tom"), Person(name: "john"), Person(name: "tingxins")]
   
print("集合準備置空處理")
var personsSlices = persons.dropLast(2)
persons = []
print("集合已置空處理")
print("Slice 準備置空處理")
let personsCopy = Array(personsSlices) // 拷貝一份
personsSlices = []
print("Slice 已置空處理")

/** 控制檯輸出如下
集合準備置空處理
集合已置空處理
Slice 準備置空處理
deinit###john
deinit###tingxins
Slice 已置空處理
deinit###jack
deinit###tom
**/
複製程式碼

即,當 persons 和 personsSlices 都被置空時,Person 例項物件才會被釋放,如果針對切片進行了一次拷貝(personsCopy),那麼被拷貝的這些元素不會被釋放。

延遲計算

延遲計算(Lazy Defers Computation),與之相對應的是及早計算(Eager Computation),我們通常呼叫的函式,都是屬於及早計算(立刻求值)。

我們來看段程式碼:

WWDC 2018:在Swift中如何高效地使用集合

這段程式碼的效能怎樣呢?我們可以看出 map & filter 函式分別會對集合做了遍歷一次,並且中途多建立了一個陣列(因為 map 是立刻求值函式),如果 map 了多次,那麼當資料量非常大的情況下,是可能出現問題的(比如:記憶體峰值等)。 如:

map {
}.map {
}.flatmap {
}. .......
複製程式碼

如果我們僅僅是為了取最後的求值結果,我們是不是可以做些優化呢?

以下面這兩個 CASE 為例:

  • 取 items 的第一個元素,即 items.first。
  • 取 items 中所有元素

由於我們僅僅只需要最後的求值結果甚至結果的某一部分,那麼我們可以使用惰性(Lazy)延遲計算來做些優化,使用起來非常簡單,在鏈式呼叫前加個 Lazy 就 OK 了。

WWDC 2018:在Swift中如何高效地使用集合

使用 Lazy 後有什麼區別呢?我們可以統計 map & filter 函式的 block 的回撥次數。

  • 取 items.first,map & filter 函式的 block 分別只會呼叫一次。
  • 取 items 集合中所有元素時, map & filter 函式對集合只做了一次遍歷

使用 Lazy 的好處主要有兩個:

  • 我們可以規避中途產生的臨時陣列,從而在資料量大的情況下,避免記憶體峰值。
  • 只有在訪問 items 的元素時,才會進行求值,即延遲求值

下面我們來舉個例子:

// 過濾的最終結果:["2", "4", "6"]
let formats = [1, 2, 3, 4, 5, 6].lazy.filter { (value) -> Bool in
 print("filter: \(value)")
 return value % 2 == 0
 }.map { (value) -> String in
     print("map: \(value)")
     return "\(value)"
}
// 取結果集的第一個元素 "2"
print(formats[formats.startIndex])
print("####")
// 取結果集的第二個元素 "4"
print(formats[formats.index(after: formats.startIndex)])
print("####")
// 取結果集的第三個元素 "6"
print(formats[formats.index(after: formats.index(after: formats.startIndex))]) 
print("####")
// 取結果集中元素的個數
print("formats.count \(formats.count)")

/** 控制檯輸出如下
filter: 1
filter: 2
map: 2
2
####
filter: 1
filter: 2
filter: 3
filter: 4
map: 4
4
####
filter: 1
filter: 2
filter: 3
filter: 4
filter: 5
filter: 6
map: 6
6
####
filter: 1
filter: 2
filter: 3
filter: 4
filter: 5
filter: 6
formats.count 3
**/
複製程式碼

讀者如果感興趣可以把 Lazy 去掉後執行下程式碼,看下輸出結果就明白了。

當然,在使用 Lazy 時,也要注意:

  • 每次訪問 items 中的元素時,都會重新進行求值

如果想解決重新求值的問題,我們可以直接把 Lazy 型別的集合轉成普通型別的集合:

let formatsCopy = Array(formats)
複製程式碼

但筆者不推薦,這樣做使 Lazy 事與願違。

什麼情況下惰性計算呢

  • 鏈式計算
  • 僅僅需要求值結果中的某一部分
  • 本身的計算不影響外部(no side effects) ......

如何避免集合相關崩潰?

可變性

  • 索引失效

WWDC 2018:在Swift中如何高效地使用集合

正確的做法應該是這樣,使用前更新索引:

WWDC 2018:在Swift中如何高效地使用集合

  • 複用之前的索引

WWDC 2018:在Swift中如何高效地使用集合

正確姿勢:

WWDC 2018:在Swift中如何高效地使用集合

如何規避此類問題呢?

  • 在持有索引和切片時,要謹慎處理
  • 集合發生改變時,索引會失效,要記得更新
  • 在需要索引和切片的情況下才對其進行計算

多執行緒

  • 執行緒不安全的做法

WWDC 2018:在Swift中如何高效地使用集合

如何規避此類問題呢?

  • 單個執行緒訪問

我們可以使用 Xcode 自帶的 Thread Sanitizer 來規避此類問題。

其他

  • 優先使用不可變的集合型別,只有在你真正需要改變它時,才去使用可變型別。

Foundation 中的集合

WWDC 2018:在Swift中如何高效地使用集合

值型別與引用型別

Swift 標準庫中的集合都是值型別:

Swift only performs an actual copy behind the scenes when it is absolutely necessary to do so. Swift manages all value copying to ensure optimal performance, and you should not avoid assignment to try to preempt this optimization.

我們都知道 Swift 中值型別都是採用寫時複製的方式進行效能優化。以 Array 為例:

//Value
var x:[String] = []
x.append("?")
var y = x // 未拷貝
複製程式碼

WWDC 2018:在Swift中如何高效地使用集合

y.append("?") // 發生拷貝
複製程式碼

WWDC 2018:在Swift中如何高效地使用集合

Foundation 中的集合都是引用型別,相比大家都知道,直接上圖:

WWDC 2018:在Swift中如何高效地使用集合

因此在實際開發過程中,我們要非常注意值型別與引用型別集合的選擇。

Swift & Objective-C 橋接

橋接就是把一種語言的某個型別轉換為另一種語言的某個型別。橋接在 Swift 和 Objective-C 間是雙向的。集合的橋接是很有必要的(如:Swift 陣列可以橋接到 Objective-C等),開銷也是會有的,因此在使用過程中建議測量並確定其橋接成本。來看個小例子:

WWDC 2018:在Swift中如何高效地使用集合

假設 story 是一個非常長的字串,這段程式碼橋接成本較大的地方有兩處,主要是 text.string 的影響,下面我們簡單分析一下:

let range = text.string.range(of: "Brown")
複製程式碼

這裡 NSMutableAttributedString 取 string 的時候發生了橋接,我們可以理解為返回型別橋接(return type)。

let nsrange = NSRange(range, in: text.string)   
複製程式碼

這行程式碼的傳入引數也發生了橋接,我們可以理解為引數型別橋接(parameter type),下圖更加直觀的展示哪個地方發生了橋接:

WWDC 2018:在Swift中如何高效地使用集合

蘋果更建議我們採用這種方式來規避不必要的開銷:

WWDC 2018:在Swift中如何高效地使用集合

雖然 let range = string.range(of: "Brown")! 也發生了引數型別橋接,但就 “Brown” 字串而言,橋接成本可忽略不計。

關於橋接(bridge),感興趣的同學還可以看看往期的幾個 Session:

小結

這個 Session 主要是針對集合型別講了些使用過程中的 Tips。

相關文章