高效的使用集合(WWDC 2018 session 229)
該篇部落格記錄了觀看WWDC Session229《Using collections effectively》的內容以及一些理解。
該Session講解了在Swift中高效使用集合的一些注意點。
假如沒有集合
假如沒有Arrays
假如沒有Arrays,我們要表示幾頭熊,並且列印熊的名字,需要如下程式碼:
假如沒有Dictionaries
假如沒有Dictionaries,我們要獲取熊的愛好,需要一個函式的幫助:
我們的世界存在集合(Collections)
以上的例子,使用集合的話,我們可以如下表示:
我們把集合擁有的相同的特性和演算法抽象成為一個協議:Collection
Protocol Colection
在Swift中,Collection是一個可以被多次訪問的、無損的、通過下標可以訪問元素的序列。
Collection可以是記憶體中的一塊連續區域、一個雜湊表、一顆紅黑樹或者一個連結串列。
但是最重要的是:
- Collection擁有一個名為startIndex的初始指標,用來訪問Collection的初始元素
- Collection擁有一個名為endIndex的指標,用來表示Collection的最後元素
- Collection支援多次在startIndex和endIndex之間的遍歷
- Collection支援通過下標訪問其中元素
程式碼實現如下:
程式碼中有幾點需要注意的:
- associatedtype 表示佔位,即指明一個泛型,待需要時再確定具體型別
- Index需要遵循Comparable協議
- 提供了使用下標來訪問元素的方法
- 定義了startIndex和endIndex
- 提供了一個獲取某個Index之後Index的方法
協議擴充套件
Collection允許標準庫通過協議擴充套件定義許多強大而有用的行為,其中一部分如下:
常用屬性
- first 獲取第一個元素
- last 獲取最後一個元素
- count 獲取集合元素個數
- isEmpty 判斷集合是否為空
遍歷集合
- forEach
- makeIterator()
高階函式
- map
- filter
- reduce
自定義協議拓展
我們也可以通過自定義協議拓展來為Collection增加一些更有用的方法。
Collection已經提供了遍歷每個元素的方法,我們來自定義一個隔元素訪問的方法:
Collection群
Swift中,提供了豐富的Collection協議結構:
- BidirectionalCollection:允許雙向訪問元素
- RandomAccessCollection:提供隨機訪問元素功能
- MutableCollection:提供修改集合元素功能
- RangeReplaceCollection:提供替換範圍元素功能
Swift中其餘Collection
這些型別都屬於Collection,所以我們瞭解其中一種是如何工作的,就能夠類推到所有其他Collection中。
接下來分析集合的細節:
索引(Indices)
- 每一個Collection都定義了它自己的索引
- 索引必須遵循Comparable協議
- 將索引視為不透明的
訪問第一個元素
1.通過下標訪問
可以通過下標訪問陣列第一個元素,例如array[0],但是該方法對於所有集合並不是全部適用,比如Set(Set不允許使用Int型別進行下標操作)
2.使用集合提供的下標
由於Collection全部提供了startIndex來表示Collection第一個元素的下標,所以可以使用array[array.startIndex]或者set[set.startIndex]的方法來訪問第一個元素。
但是在使用這種方法時,需要注意集合是否為空,如果集合為空,那麼強制使用startIndex會造成崩潰。
3.呼叫 first 方法
array.first和set.first
由於Collection全部提供了first方法,同時該方法返回值為optional,這表明如果Collection為空時,first方法不會出錯。
訪問第二個元素
我們第一印象時通過下標直接訪問,如下圖:
我們可以看到,通過直接使用下標或者對下標直接偏移來獲取第二個元素是不可行的,原因在於並不是所有的Collection的下標都支援Int型別。
自定義方法訪問第二個元素
我們可以通過extension來自定義方法來獲取Collection的第二個元素:
首先,我們看一下方法名:
由於Collection中不一定存在第二個元素,該方法返回值為optional。
接下來看方法的實現:
實現思路為:
- 判斷Collection是否為空,為空則無需返回值(Collection為空時,startIndex和endIndex相等且為0)
- 根據Collection提供的index(after:)方法獲取到指向第二個元素的下標
- 判斷獲取到的下標是否越界(若Collection不為空,endIndex指向Collection最後一個元素的下一個位置)
- 此時下標有效,通過下標獲取到對應的第二個元素
切片(Slices)
切片可以很好的解決前面獲取第二個元素的問題,不過在看如何解決這個問題之前,我們先看一下切片的工作原理。
切片工作原理
- 切片是描述集合中一部分的一種型別
- 切片有自己的startIndex和endIndex,同時切片獨立於原始集合
- 切片由於與原始集合共享索引,不佔用額外的儲存空間,所以十分高效
- 當切片被下標時,被下標的元素從原始buffer中讀出
下面有一段程式碼可以看出切片的工作原理:
工作流程如下動圖:
其中dropFirst方法返回的就是array的一個切片。
切片解決獲取Collection第二個元素問題
此時可以使用切片來解決獲取Collection第二個元素的問題:
Collection可以有自己型別的切片
切片會持有原始Collection的記憶體
切片是與產生它的原始Collection共享同一記憶體的,如下:
在以上程式碼中,即使在最後執行 array = [] 來將array原始記憶體釋放之後,我們發現切片依然可以正常使用,原因在於,即使 array = [] 之後,原始記憶體依然被切片持有,並沒有釋放。
Swift中提供的解決方法如下:
通過使用切片來建立一個新的陣列,然後將切片所持有的記憶體釋放,這樣就會將原先的記憶體真正釋放掉。
延遲計算(Lazy)
延遲計算(Lazy)在某些特殊的場景下是十分有用的。
按需計算
例如在下面的例子中:
由於Swift中,預設方法呼叫是急切的(即按照要求消耗輸入和輸出),所以在該例子中,我們會在map呼叫時分配4000個記憶體,在filter呼叫時分配4個記憶體,總共會分配4004個記憶體,但是我們最終只需要4個記憶體的結果,這樣就浪費了大量的消耗。
延遲計算(Lazy)可以很好的解決這個問題,程式碼如下:
在使用過程中,它的流程如下:
- 通過對Collection使用lazy方法,會對Collection進行包裝,包裝為
LazyCollection<Range<Int>>
物件,除此之外,不會做任何事情,包括申請記憶體 - 在使用map方法時,會再次進行包裝,包裝為
LazyMapCollection<Range<Int>>
物件,除此之外,不會做任何事情,包括申請記憶體 - 在呼叫filter方法時,會再次進行包裝,包裝為
LazyFilterCollection<LazyMapCollection<Range<Int>>>
物件,除此之外,不會做任何事情,包括申請記憶體
接下來我們向filter查詢第一個元素,過程如下:
- 向filter查詢第一個元素,filter並不知道第一個元素,因為它包裝的是一個map
- filter向包裝的map查詢第一個元素,map也並不知道第一個元素,因為它包裝的是一個Collection
- map向包裝的Collection查詢第一個元素,Collection提供第一個元素並交由map進行處理
- map將處理好的元素交由filter進行處理
- filter將處理結果交給程式
整個過程如下圖:
避免建立中間儲存
Lazy可以避免在使用時進行中間儲存,如圖我們篩選 Gummy Bears 這個元素:
當我們訪問第一個元素時,lazy會依次讀取Collection中元素,篩選出 Gummy Bears,即依次訪問 Grizzly、Panda、Spectacled、Gummy Bears,然後返回 Gummy Bears。
但是當我們再次呼叫print(redundantBears.first!)
時,會再次進行上面的遍歷,即每進行一次查詢,就會進行一次遍歷。這樣的設計就是不儲存中間值。
如果我們想要儲存中間值,可以採用下面的方法:
在上述程式碼中,let filteredBears = Array(redundantBears)
會另lazy進行一次完整的查詢,並將查詢結果儲存至filteredBears中,避免多次計算遍歷。
延遲計算(Lazy)使用場景
- 鏈式計算
- 只需要結果中的一部分
- 不會影響到原始資料
- 避免API的邊界,即在跨越API邊界時,要將Lazy重新具體化為一個Collection
MutableCollection&RangeReplaceableCollection
- MutableCollection可以使用
subscript(_: Self.Index) -> Element { get set}
方法來對資料進行讀取和替換。 - RangeReplaceableCollection可以使用
replaceSubrange(_:, with:)
方法來對資料進行替換、刪除或者增加。
有時我們在使用Collection時會發生一些Crash,接下來就通過一些例子來看一下:
修改Collection的情況下
看第一個例子:
此例子中,訪問的下標越界了,建議的處理方式為:
看第二個例子:
此例子中,使用了無效的索引,建議的處理方式為:
建議
- 在儲存索引/切片時要謹慎
- 在更改了集合之後,索引/切片變無效
- 在需要索引/切片時再進行計算
多執行緒下修改Collection
看下面一個例子:
這段程式碼可能存在問題,即有時會只新增一隻熊到sleepingBears陣列中,原因如下:
建議的方法為:使用序列佇列來保證某一時刻只有一個執行緒對sleepingBears進行修改,如下
建議
- 使用單執行緒來訪問Collection
- 若不能使用單執行緒:1.確保操作之間相互排斥 2.使用TSAN(Thread Sanitizer)進行除錯
橋接(Bridging)
Foundation Collections
Foundation Collections全部是引用型別的Collection
Swift中的Collection為值型別
下面一張動圖展示了值型別與引用型別的區別:
橋接
- 可以將兩種不同語言(即不同執行狀態)進行轉換
- 橋接轉換是雙向的
- 橋接是有必要的,但同時,橋接也是有消耗的
橋接是如何工作的呢:
- 首先會建立一個與被橋接物件等同大小的記憶體
- 遍歷被橋接物件,並依次進行橋接
如果被橋接物件內部物件也是需要橋接的,那麼需要遞迴的去橋接內部物件
- Eager橋接:如果被橋接物件內部物件也需要橋接,那麼該橋接被稱為Eager的
- Lazy橋接:如果被橋接物件內部物件不需要橋接,那麼該橋接被稱為Lazy的
下面為幾個橋接的例子:
對於橋接的消耗,我們可以看下面一段程式碼:
原因在於我們在訪問text.string
時,會涉及到將NSString橋接為String,這是十分消耗資源的,同時在程式碼中進行了兩次橋接。
我們可以使用下面方法減少一次橋接:
但即使這樣,也會有一次的橋接。最好的解決方法如下:
在此情況中,雖然訪問了text.string
,但是由於指定了NSString
,在實際執行時並不會傳送橋接,所以可以節省大量時間。
何時使用橋接
- 你需要引用型別的語義
- 在使用已知型別的代理
- 你已經確定了橋接的消耗
總而言之,Apple建議我們在開發中儘量減少Foundation框架的使用,而多使用Swift Standard Library中內容。