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

Session 229: Using Collections Effectivelygit

全部應用都用到了集合,爲了得到最佳性能,瞭解背後的基礎知識,關於若是更好的使用索引、切片、惰性、橋接以及引用類型,本 Session 講了些 Tips。github

集合簡介

Swift 中集合類型不少,如: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安全

文檔翻譯過來就是至少要實現以下協議接口:性能優化

基於 Collection 協議類型,咱們能夠自定義集合並遵照此類協議,而且還能夠作一些協議擴展,Swift 標準庫還額外提供了四種集合協議類型markdown

  • 雙向索引集合:BidirectionalCollection,支持向後而且可以向前遍歷的集合多線程

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

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

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

以上四種協議類型本文不作詳述。

索引

索引,即在集合中的位置。每種集合類型都定義了本身的索引,而且索引都必須遵照 Comparable 協議。

  • 如何取數組中的第一個元素?

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

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

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

    1. 方式一

    2. 方式二

    3. 方式三

針對第二個問題,前兩種方式彷佛行不通,由於 Index 類型不必定是 Int 類型,那麼就只有方式三了, 咱們有沒有更好的方式來解決呢?固然有,下面筆者先簡單介紹下切片(Slice)。

切片

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

這裏有兩個關鍵點

  • 切片與原始集合共享索引。
  • 切片會持有集合該塊內存。

什麼意思呢?Show the code.

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

關於切片會「持有」集合該內存塊如何理解呢?咱們來看看代碼:

什麼意思呢?當 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),咱們一般調用的函數,都是屬於及早計算(馬上求值)。

咱們來看段代碼:

這段代碼的性能怎樣呢?咱們能夠看出 map & filter 函數分別會對集合作了遍歷一次,而且中途多建立了一個數組(由於 map 是馬上求值函數),若是 map 了屢次,那麼當數據量很是大的狀況下,是可能出現問題的(好比:內存峯值等)。 如:

map {
}.map {
}.flatmap {
}. .......
複製代碼

若是咱們僅僅是爲了取最後的求值結果,咱們是否是能夠作些優化呢?

如下面這兩個 CASE 爲例:

  • 取 items 的第一個元素,即 items.first。
  • 取 items 中全部元素

因爲咱們僅僅只須要最後的求值結果甚至結果的某一部分,那麼咱們可使用惰性(Lazy)延遲計算來作些優化,使用起來很是簡單,在鏈式調用前加個 Lazy 就 OK 了。

使用 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) ......

如何避免集合相關崩潰?

可變性

  • 索引失效

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

  • 複用以前的索引

正確姿式:

如何規避此類問題呢?

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

多線程

  • 線程不安全的作法

如何規避此類問題呢?

  • 單個線程訪問

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

其餘

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

Foundation 中的集合

值類型與引用類型

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 // 未拷貝
複製代碼

y.append("🐼") // 發生拷貝
複製代碼

Foundation 中的集合都是引用類型,相比你們都知道,直接上圖:

所以在實際開發過程當中,咱們要很是注意值類型與引用類型集合的選擇。

Swift & Objective-C 橋接

橋接就是把一種語言的某個類型轉換爲另外一種語言的某個類型。橋接在 Swift 和 Objective-C 間是雙向的。集合的橋接是頗有必要的(如:Swift 數組能夠橋接到 Objective-C等),開銷也是會有的,所以在使用過程當中建議測量並肯定其橋接成本。來看個小例子:

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

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

這裏 NSMutableAttributedString 取 string 的時候發生了橋接,咱們能夠理解爲返回類型橋接(return type)。

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

這行代碼的傳入參數也發生了橋接,咱們能夠理解爲參數類型橋接(parameter type),下圖更加直觀的展現哪一個地方發生了橋接:

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

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

關於橋接(bridge),感興趣的同窗還能夠看看往期的幾個 Session:

小結

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

相關文章
相關標籤/搜索