NSArray, NSSet, NSOrderedSet 和 NSDictionary
基礎集合類是每一個 Mac/iOS 應用的基本組成部分。在本文中,我們將對”老類” (NSArray
, NSSet
)和”新類” (NSMapTable
,NSHashTable
, NSPointerArray
) 進行一個深入的研究,探索每一個的效率細節,並討論其使用場景。
作者提示:本文包含一些參照結果,但它們並不意味著絕對精確,也沒有進行均差分析及多次的測試。這些結果的目的是給出執行時統計,來幫助我們認識到通常來說用什麼會更快。所有的測試基於 iPhone 5s,使用 Xcode 5.1b1 和 iOS 7.1b1 的 64 位程式。編譯選項設定為 -Ofast 的釋出構建。Vectorize loops 和 unroll loops (預設設定) 均設定為關閉。
大 O 符號,演算法複雜度計量
首先,我們需要一些理論知識。效率通常用大 O 符號描述。它定義了一個函式的極限特徵,通常被用於描繪其演算法效率。O 定義了函式增長率的上限。不同量級的差異非常巨大,可以看看通常使用的 O 符號的量級以及它們所對應需要的運算元的關係。
例如,如果用演算法複雜度為 O(n^2)的演算法對一個有 50 個元素的陣列排序,需要 2,500 步的操作。而且,還有內部的系統開銷和方法呼叫 — 所以是 250 0個操作的時間常量。 O(1)是理想的複雜度,代表著恆定的時間。好的排序演算法通常需要 O(n*log n) 的時間。
可變性
大多數的集合類存在兩個版本:可變和不可變(預設)。這和其他大多數的框架有非常大的不同,一開始會讓人覺得有一點奇怪。然而其他的框架現在也應用了這一特性:就在幾個月前,.NET公佈了作為官方擴充套件的不可變集合。
最大的好處是什麼?執行緒安全。不可變的集合完全是執行緒安全的,可以同時在多個執行緒中迭代,避免各種轉變時出現異常的風險。你的 API 絕不應該暴露一個可變的集合。
當然從不可變到可變然後再回來是會有一定代價的 — 物件必須被拷貝兩次,所有集合內的物件將被 retain/release。有時在內部使用一個可變的集合,而在訪問時返回一個不可變的物件副本會更高效。
與其他框架不同的是,蘋果沒有提供一個執行緒安全的可變集合,NSCache
是例外,但它真的算不上是集合類,因為它不是一個通用的容器。大多數時候,你不會需要在集合層級的同步特性。想象一段程式碼,作用是檢查字典中一個 key 是否存在,並根據檢查結果決定設定一個新的 key 或者返回某些值 — 你通常需要把多個操作歸類,這時執行緒安全的可變集合並不能對你有所幫助。
其實也有一些同步的,執行緒安全的可以使用的可變集合案例,它們往往只需要用幾行程式碼,通過子類和組合的方法建立,比如這個NSDictionary
或這個 NSArray
。
需要注意的是,一些較新的集合類,如 NSHashTable
,NSMapTable
和 NSPointerArray
預設就是可變的,它們並沒有對應的不可變的類。它們用於類的內部使用,你基本應該不會能找到需要它們的不可變版本的應用場景。
NSArray
NSArray
作為一個儲存物件的有序集合,可能是被使用最多的集合類。這也是為什麼它有自己的比原來的 [NSArray arrayWithObjects:..., nil]
簡短得多的快速語法糖符號 @[...]
。 NSArray
實現了 objectAtIndexedSubscript:
,因為我們可以使用類 C 的語法 array[0]
來代替原來的 [array objectAtIndex:0]
。
效能特徵
關於 NSArray
的內容比你想象的要多的多。基於儲存物件的多少,它使用各種內部的變體。最有趣的部分是蘋果對於個別的物件訪問並不保證 O(1) 的訪問時間 — 正如你在 CFArray.h CoreFoundation 標頭檔案中的關於演算法複雜度的註解中可以讀到的:
對於 array 中值的訪問時間,不管是在現在還是將來,我們保證在任何一種實現下最壞情況是 O(lg N)。但是通常來說它會是 O(1) (常數時間)。線性搜尋操作很可能在最壞情況下的複雜度為 O(N*lg N),但通常來說上限會更小一些。插入和刪除操作耗時通常和陣列中的值的數量成線性關係。但在某些實現的最壞情況下會是 O(N*lg N) 。在陣列中,沒有對於效能上特別有優勢的資料位置,也就是說,為了更快地訪問到元素而將其設為在較低的 index 上,或者在較高的 index 上進行插入和刪除,或者類似的一些做法,是沒有必要的。
在測量的時候,NSArray
產生了一些有趣的額外的效能特徵。在陣列的開頭和結尾插入/刪除元素通常是一個 O(1)操作,而隨機的插入/刪除通常是 O(N) 的。
有用的方法
NSArray
的大多數方法使用 isEqual:
來檢查物件間的關係(例如 containsObject:
中)。有一個特別的方法indexOfObjectIdenticalTo:
用來檢查指標相等,如果你確保在同一個集合中搜尋,那麼這個方法可以很大的提升搜尋速度。 在 iOS 7 中,我們最終得到了與 lastObject
對應的公開的 firstObject
方法,對於空陣列,這兩個方法都會返回 nil
— 而常規的訪問方法會丟擲一個 NSRangeException
異常。
關於構造(可變)陣列有一個漂亮的細節可以節省程式碼量。如果你通過一個可能為 nil 的陣列建立一個可變陣列,通常會這麼寫:
1 2 3 4 |
NSMutableArray *mutableObjects = [array mutableCopy]; if (!mutableObjects) { mutableObjects = [NSMutableArray array]; } |
或者通過更簡潔的三元運算子:
1 |
NSMutableArray *mutableObjects = [array mutableCopy] ?: [NSMutableArray array]; |
更好的解決方案是使用arrayWithArray:
,即使原陣列為nil,該方法也會返回一個陣列物件:
1 |
NSMutableArray *mutableObjects = [NSMutableArray arrayWithArray:array]; |
這兩個操作在效率上幾乎相等。使用 copy
會快一點點,不過話說回來,這不太可能是你應用的瓶頸所在。提醒:不要使用 [@[] mutableCopy]
。經典的[NSMutableArray array]
可讀性更好。
逆序一個陣列非常簡單:array.reverseObjectEnumerator.allObjects
。我們使用系統提供的 reverseObjectEnumerator
,每一個 NSEnumerator
都實現了 allObjects
,該方法返回一個新陣列。雖然沒有原生的 randomObjectEnumerator
方法,你可以寫一個自定義的打亂陣列順序的列舉器或者使用一些出色的開原始碼。
陣列排序
有很多各種各樣的方法來對一個陣列排序。如果陣列儲存的是字串物件,sortedArrayUsingSelector:
是第一選擇:
1 2 |
NSArray *array = @[@"John Appleseed", @"Tim Cook", @"Hair Force One", @"Michael Jurewitz"]; NSArray *sortedArray = [array sortedArrayUsingSelector:@selector(localizedCaseInsensitiveCompare:)]; |
下面的程式碼對儲存數字的內容同樣很好,因為 NSNumber
實現了 compare:
1 2 |
NSArray *numbers = @[@9, @5, @11, @3, @1]; NSArray *sortedNumbers = [numbers sortedArrayUsingSelector:@selector(compare:)]; |
如果想更可控,可以使用基於函式指標的排序方法:
1 2 3 4 5 |
- (NSData *)sortedArrayHint; - (NSArray *)sortedArrayUsingFunction:(NSInteger (*)(id, id, void *))comparator context:(void *)context; - (NSArray *)sortedArrayUsingFunction:(NSInteger (*)(id, id, void *))comparator context:(void *)context hint:(NSData *)hint; |
蘋果增加了一個方法來加速使用 sortedArrayHint
的排序。
hinted sort 方式在你有一個已排序的大陣列 (N 個元素) 並且只改變其中一小部分(P 個新增和刪除,這裡 P遠小於 N)時,會非常有效。你可以重用原來的排序結果,然後在 N 個老專案和 P 個新專案進行一個概念上的歸併排序。為了得到合適的 hint,你應該在原來的陣列排序後使用 sortedArrayHint 來在你需要的時候(比如在陣列改變後想重新排序時)保證持有它。
因為block的引入,也出現了一些基於block的排序方法:
1 2 3 |
- (NSArray *)sortedArrayUsingComparator:(NSComparator)cmptr; - (NSArray *)sortedArrayWithOptions:(NSSortOptions)opts usingComparator:(NSComparator)cmptr; |
效能上來說,不同的方法間並沒有太多的不同。有趣的是,基於 selector 的方式是最快的。你可以在 GitHub 上找到測試用的原始碼:
Sorting 1000000 elements. selector: 4947.90[ms] function: 5618.93[ms] block: 5082.98[ms].
二分查詢
NSArray
從 iOS 4 / Snow Leopard 開始內建了二分查詢
1 2 3 4 5 6 7 8 9 10 |
typedef NS_OPTIONS(NSUInteger, NSBinarySearchingOptions) { NSBinarySearchingFirstEqual = (1UL << 8), NSBinarySearchingLastEqual = (1UL << 9), NSBinarySearchingInsertionIndex = (1UL << 10), }; - (NSUInteger)indexOfObject:(id)obj inSortedRange:(NSRange)r options:(NSBinarySearchingOptions)opts usingComparator:(NSComparator)cmp; |
為什麼要使用這個方法?類似 containsObject:
和 indexOfObject:
這樣的方法從 0 索引開始搜尋每個物件直到找到目標 — 這樣不需要陣列被排序,但是卻是 O(n)的效率特性。如果使用二分查詢的話,需要陣列事先被排序,但在查詢時只需要 O(log n) 的時間。因此,對於 一百萬條記錄,二分查詢法最多隻需要 21 次比較,而傳統的線性查詢則平均需要 500,000 次的比較。
這是個簡單的衡量二分查詢有多快的資料:
1 |
Time to search for 1000 entries within 1000000 objects. Linear: 54130.38[ms]. Binary: 7.62[ms] |
作為比較,查詢 NSOrderedSet
中的指定索引花費 0.23 毫秒 — 就算和二分查詢相比也又快了 30 多倍。
記住排序的開銷也是昂貴的。蘋果使用複雜度為 O(n*log n) 的歸併排序,所以如果你執行一次 indexOfObject:
的話,就沒有必要使用二分查詢了。
通過指定 NSBinarySearchingInsertionIndex
,你可以獲得正確的插入索引,以確保在插入元素後仍然可以保證陣列的順序。
列舉和總覽
作為測試,我們來看一個普通的使用場景。從一個陣列中過濾出一些元素組成另一個陣列。這些測試都包括了列舉的方法以及使用 API 進行過濾的方式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
// 第一種方式,使用 `indexesOfObjectsWithOptions:passingTest:`. NSIndexSet *indexes = [randomArray indexesOfObjectsWithOptions:NSEnumerationConcurrent passingTest:^BOOL(id obj, NSUInteger idx, BOOL *stop) { return testObj(obj); }]; NSArray *filteredArray = [randomArray objectsAtIndexes:indexes]; // 使用 predicate 過濾,包括 block 的方式和文字 predicate 的方式 NSArray *filteredArray2 = [randomArray filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(id obj, NSDictionary *bindings) { return testObj(obj); }]]; // 基於 block 的列舉 NSMutableArray *mutableArray = [NSMutableArray array]; [randomArray enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) { if (testObj(obj)) { [mutableArray addObject:obj]; } }]; // 傳統的列舉 NSMutableArray *mutableArray = [NSMutableArray array]; for (id obj in randomArray) { if (testObj(obj)) { [mutableArray addObject:obj]; } } // 使用 NSEnumerator,傳統學院派 NSMutableArray *mutableArray = [NSMutableArray array]; NSEnumerator *enumerator = [randomArray objectEnumerator]; id obj = nil; while ((obj = [enumerator nextObject]) != nil) { if (testObj(obj)) { [mutableArray addObject:obj]; } } // 通過下標使用 objectAtIndex: NSMutableArray *mutableArray = [NSMutableArray array]; for (NSUInteger idx = 0; idx < randomArray.count; idx++) { id obj = randomArray[idx]; if (testObj(obj)) { [mutableArray addObject:obj]; } } |
列舉方法 / 時間 [ms] | 10.000.000 elements | 10.000 elements |
---|---|---|
indexesOfObjects: , concurrent |
1844.73 | 2.25 |
NSFastEnumeration (for in ) |
3223.45 | 3.21 |
indexesOfObjects: |
4221.23 | 3.36 |
enumerateObjectsUsingBlock: |
5459.43 | 5.43 |
objectAtIndex: |
5282.67 | 5.53 |
NSEnumerator |
5566.92 | 5.75 |
filteredArrayUsingPredicate: |
6466.95 | 6.31 |
為了更好的理解這裡的效率測量,我們首先看一下陣列是如何迭代的。
indexesOfObjectsWithOptions:passingTest:
必須每次都執行一次 block 因此比傳統的使用 NSFastEnumeration
技術的基於 for 迴圈的列舉要稍微低效一些。但是如果開啟了併發列舉,那麼前者的速度則會大大的超過後者幾乎 2 倍。iPhone 5s 是雙核的,所以這說得通。這裡並沒有體現出來的是 NSEnumerationConcurrent
只對大量的物件有意義,如果你的集合中的物件數量很少,用哪個方法就真的無關緊要。甚至 NSEnumerationConcurrent
上額外的執行緒管理實際上會使結果變得更慢。
最大的輸家是 filteredArrayUsingPredicate:
。NSPredicate
需要在這裡提及是因為,人們可以寫出非常複雜的表示式,尤其是用不基於 block 的變體。使用 Core Data 的使用者應該會很熟悉。
為了比較的完整,我們也加入了 NSEnumerator
作為比較 — 雖然沒有任何理由再使用它了。然而它竟出人意料的快(至少還是比基於NSPredicate
的過濾要快),它的執行時消耗無疑比快速列舉更多 — 現在它只用於向後相容。甚至沒有優化過的 objectAtIndex:
都要更快些。
NSFastEnumeration
在OSX 10.5和iOS的最初版本中,蘋果增加了 NSFastEnumeration
。在此之前,只有每次返回一個元素的 NSEnumeration
,每次迭代都有執行時開銷。而快速列舉,蘋果通過 countByEnumeratingWithState:objects:count:
返回一個資料塊。該資料塊被解析成 id
型別的 C 陣列。這就是更快的速度的原因;迭代一個 C 陣列要快得多,而且可以被編譯器更深一步的優化。手動的實現快速列舉是十分難辦的,所以蘋果的 FastEnumerationSample 是一個不錯的開始,還有一篇 Mike Ash 的文章也很不錯。
應該用arrayWithCapacity:嗎?
初始化NSArray
的時候,可以選擇指定陣列的預期大小。在檢測的時候,結果是在效率上沒有差別 — 至少在統計誤差範圍內的測量的時間幾乎相等。有訊息透漏說實際上蘋果根本沒有使用這個引數。然而使用 arrayWithCapacity:
仍然好處,它可以作為一種隱性的文件來幫助你理解程式碼:
Adding 10.000.000 elements to NSArray. no count 1067.35[ms] with count: 1083.13[ms].
子類化注意事項
很少有理由去子類化基礎集合類。大多數時候,使用 CoreFoundation 級別的類並且自定義回撥函式定製自定義行為是更好的解決方案。 建立一個大小寫不敏感的字典,一種方法是子類化 NSDictionary
並且自定義訪問方法,使其將字串始終變為小寫(或大寫),並對排序也做類似的修改。更快更好的解決方案是提供一組不同的 CFDictionaryKeyCallBacks
集,你可以提供自定義的 hash
和isEqual:
回撥。你可以在這裡找到一個例子。這種方法的優美之處應該歸功於 toll-free 橋接),它仍然是一個簡單的字典,因此可以被任何使用 NSDictionary
作為引數的API接受。
子類作用的一個例子是有序字典的用例。.NET 提供了一個 SortedDictionary
,Java 有 TreeMap
,C++ 有 std::map
。雖然你可以使用 C++ 的 STL 容器,但卻無法使它自動的 retain/release
,這會讓使用起來笨拙得多。因為 NSDictionary
是一個類簇,所以子類化跟人們想象的相比非常不同。這已經超過了本文的討論範疇,這裡有一個真實的有序字典的例子。
NSDictionary
一個字典儲存任意的物件鍵值對。 由於歷史原因,初始化方法 [NSDictionary dictionaryWithObjectsAndKeys:object, key, nil]
使用了相反的值到鍵的順序,而新的快捷語法則從 key 開始,@{key : value, ...}
。
NSDictionary
中的鍵是被拷貝的並且需要是不變的。如果在一個鍵在被用於在字典中放入一個值後被改變的話,那麼這個值就會變得無法獲取了。一個有趣的細節是,在 NSDictionary
中鍵是被 copy 的,但是在使用一個 toll-free 橋接的 CFDictionary
時卻只會被 retain。CoreFoundation 類沒有通用的拷貝物件的方法,因此這時拷貝是不可能的(*)。這隻適用於你使用CFDictionarySetValue()
的時候。如果你是通過 setObject:forKey
來使用一個 toll-free 橋接的 CFDictionary
的話,蘋果會為其增加額外處理邏輯,使得鍵被拷貝。但是反過來這個結論則不成立 — 使用已經轉換為 CFDictionary
的 NSDictionary
物件,並用對其使用 CFDictionarySetValue()
方法,還是會導致呼叫回 setObject:forKey
並對鍵進行拷貝。
(*)其實有一個現成的鍵的回撥函式
kCFCopyStringDictionaryKeyCallBacks
可以拷貝字串,因為對於 ObjC物件來說,CFStringCreateCopy()
會呼叫[NSObject copy]
,我們可以巧妙使用這個回撥來建立一個能進行鍵拷貝的CFDictionary
。
效能特徵
蘋果在定義字典的計算複雜度時顯得相當低調。唯一的資訊可以在 CFDictionary
的標頭檔案中找到:
對於字典中值的訪問時間,不管是在現在還是將來,我們保證在任何一種實現下最壞情況是 O(N)。但通常來說它會是 O(1) (常數時間)。插入和刪除操作一般來說也會是常數時間,但是在某些實現中最壞情況將為 O(N*N)。通過鍵來訪問值將比直接訪問值要快(如果你有這樣的操作要做的話)。對於同樣數目的值,字典需要花費比陣列多得多的記憶體空間。
跟陣列相似的,字典根據尺寸的不同使用不同的實現,並在其中無縫切換。
列舉和總覽
過濾字典有幾個不同的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
// 使用 keysOfEntriesWithOptions:passingTest:,可並行 NSSet *matchingKeys = [randomDict keysOfEntriesWithOptions:NSEnumerationConcurrent passingTest:^BOOL(id key, id obj, BOOL *stop) { return testObj(obj); }]; NSArray *keys = matchingKeys.allObjects; NSArray *values = [randomDict objectsForKeys:keys notFoundMarker:NSNull.null]; __unused NSDictionary *filteredDictionary = [NSDictionary dictionaryWithObjects:values forKeys:keys]; // 基於 block 的列舉 NSMutableDictionary *mutableDictionary = [NSMutableDictionary dictionary]; [randomDict enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { if (testObj(obj)) { mutableDictionary[key] = obj; } }]; // NSFastEnumeration NSMutableDictionary *mutableDictionary = [NSMutableDictionary dictionary]; for (id key in randomDict) { id obj = randomDict[key]; if (testObj(obj)) { mutableDictionary[key] = obj; } } // NSEnumeration NSMutableDictionary *mutableDictionary = [NSMutableDictionary dictionary]; NSEnumerator *enumerator = [randomDict keyEnumerator]; id key = nil; while ((key = [enumerator nextObject]) != nil) { id obj = randomDict[key]; if (testObj(obj)) { mutableDictionary[key] = obj; } } // 基於 C 陣列,通過 getObjects:andKeys: 列舉 NSMutableDictionary *mutableDictionary = [NSMutableDictionary dictionary]; id __unsafe_unretained objects[numberOfEntries]; id __unsafe_unretained keys[numberOfEntries]; [randomDict getObjects:objects andKeys:keys]; for (int i = 0; i < numberOfEntries; i++) { id obj = objects[i]; id key = keys[i]; if (testObj(obj)) { mutableDictionary[key] = obj; } } |
過濾/列舉方法 | Time [ms], 50.000 elements | 1.000.000 elements |
---|---|---|
keysOfEntriesWithOptions: , concurrent |
16.65 | 425.24 |
getObjects:andKeys: |
30.33 | 798.49* |
keysOfEntriesWithOptions: |
30.59 | 856.93 |
enumerateKeysAndObjectsUsingBlock: |
36.33 | 882.93 |
NSFastEnumeration |
41.20 | 1043.42 |
NSEnumeration |
42.21 | 1113.08 |
(*)使用 getObjects:andKeys:
時需要注意。在上面的程式碼例子中,我們使用了可變長度陣列這一 C99 特性(通常,陣列的數量需要是一個固定值)。這將在棧上分配記憶體,雖然更方便一點,但卻有其限制。上面的程式碼在元素數量很多的時候會崩潰掉,所以我們使用基於 malloc/calloc
的分配 (和 free
) 以確保安全。
為什麼這次 NSFastEnumeration
這麼慢?迭代字典通常需要鍵和值兩者,快速列舉只能列舉鍵,我們必須每次都自己獲取值。使用基於 block 的 enumerateKeysAndObjectsUsingBlock:
更高效,因為兩者都可以更高效的被提前獲取。
這次測試的勝利者又是通過 keysOfEntriesWithOptions:passingTest:
和 objectsForKeys:notFoundMarker:
的併發迭代。程式碼稍微多了一點,但是可以用 category 進行漂亮的封裝。
應該用 dictionaryWithCapacity: 嗎?
到現在你應該已經知道該如何測試了,簡單的回答是不,count
引數沒有改變任何事情:
Adding 10000000 elements to NSDictionary. no count 10786.60[ms] with count: 10798.40[ms].
排序
關於字典排序沒有太多可說的。你只能將鍵陣列排序為一個新物件,因此你可以使用任何正規的 NSArray
的排序方法:
1 2 3 4 |
- (NSArray *)keysSortedByValueUsingSelector:(SEL)comparator; - (NSArray *)keysSortedByValueUsingComparator:(NSComparator)cmptr; - (NSArray *)keysSortedByValueWithOptions:(NSSortOptions)opts usingComparator:(NSComparator)cmptr; |
共享鍵
從 iOS 6 和 OS X 10.8 開始,新建的字典可以使用一個預先生成好的鍵集,使用 sharedKeySetForKeys:
從一個陣列中建立鍵集,然後用 dictionaryWithSharedKeySet:
建立字典。共享鍵集會複用物件,以節省記憶體。根據 Foundation Release Notes,sharedKeySetForKeys:
中會計算一個最小完美雜湊,這個雜湊值可以取代字典查詢過程中探索迴圈的需要,因此使鍵的訪問更快。
雖然在我們有限的測試中沒有法線蘋果在 NSJSONSerialization
中使用這個特性,但毫無疑問,在處理 JSON 的解析工作時這個特性可以發揮得淋漓盡致。(使用共享鍵集建立的字典是 NSSharedKeyDictionary
的子類;通常的字典是 __NSDictionaryI
/__NSDictionaryM
,I / M 表明可變性;可變和不可變的的字典在 toll-free 橋接後對應的都是 _NSCFDictionary
類。)
有趣的細節:共享鍵字典始終是可變的,即使對它們執行了”copy”命令後也是。這個行為文件中並沒有說明,但很容易被測試:
1 2 3 4 5 6 7 |
id sharedKeySet = [NSDictionary sharedKeySetForKeys:@[@1, @2, @3]]; // 返回 NSSharedKeySet NSMutableDictionary *test = [NSMutableDictionary dictionaryWithSharedKeySet:sharedKeySet]; test[@4] = @"First element (not in the shared key set, but will work as well)"; NSDictionary *immutable = [test copy]; NSParameterAssert(immutable.count == 1); ((NSMutableDictionary *)immutable)[@5] = @"Adding objects to an immutable collection should throw an exception."; NSParameterAssert(immutable.count == 2); |
NSSet
NSSet
和它的可變變體 NSMutableSet
是無序物件集合。檢查一個物件是否存在通常是一個 O(1) 的操作,使得比 NSArray
快很多。NSSet
只在被使用的雜湊方法平衡的情況下能高效的工作;如果所有的物件都在同一個雜湊筐內,NSSet
在查詢物件是否存在時並不比 NSArray
快多少。
NSSet
還有變體 NSCountedSet
,以及非 toll-free 計數變體 CFBag
/ CFMutableBag
。
NSSet
會 retain 它其中的物件,但是根據 set 的規定,物件應該是不可變的。新增一個物件到 set 中隨後改變它會導致一些奇怪的問題並破壞 set 的狀態。
NSSet
的方法比 NSArray
少的多。沒有排序方法,但有一些方便的列舉方法。重要的方法有 allObjects
,將物件轉化為NSArray
,anyObject
則返回任意的物件,如果 set 為空,則返回 nil。
Set 操作
NSMutableSet
有幾個很強大的方法,例如 intersectSet:
,minusSet:
和 unionSet:
。
應該用setWithCapacity:嗎?
我們再一次測試當建立 set 時給定容量大小是否會有顯著的速度差異:
Adding 1.000.000 elements to NSSet. no count 2928.49[ms] with count: 2947.52[ms].
在統計誤差範圍內,結果沒有顯著差異。有一份證據表明至少在上一個 runtime 版本中,有很多的效能上的影響。
NSSet 效能特徵
蘋果在 CFSet 標頭檔案中沒有提供任何關於演算法複雜度的註釋。
類 / 時間 [ms] | 1.000.000 elements |
---|---|
NSMutableSet , adding |
2504.38 |
NSMutableArray , adding |
1413.38 |
NSMutableSet , random access |
4.40 |
NSMutableArray , random access |
7.95 |
這個檢測非常符合我們的預期:NSSet
在每一個被新增的物件上執行 hash
和 isEqual:
方法並管理一系列雜湊值,所以在新增元素時耗費了更多的時間。set的隨機訪問比較難以測試,因為這裡執行的都是 anyObject
。
這裡沒有必要包含 containsObject:
的測試,set 要快幾個數量級,畢竟這是它的特點。
NSOrderedSet
NSOrderedSet
在 iOS 5 和 Mac OS X 10.7 中第一次被引入,除了 Core Data,幾乎沒有直接使用它的 API。看上去它綜合了NSArray
和 NSSet
兩者的好處,物件查詢,物件唯一性,和快速隨機訪問。
NSOrderedSet
有著優秀的 API 方法,使得它可以很便利的與其他 set 或者有序 set 物件合作。合併,交集,差集,就像 NSSet
支援的那樣。它有 NSArray
中除了比較陳舊的基於函式的排序方法和二分查詢以外的大多數排序方法。畢竟 containsObject:
非常快,所以沒有必要再用二分查詢了。
NSOrderedSet
的 array
和 set
方法分別返回一個 NSArray
和 NSSet
,這些物件表面上是不可變的物件,但實際上在 NSOrderedSet 更新的時候,它們也會更新自己。如果你在不同執行緒上使用這些物件併發生了詭異異常的時候,知道這一點是非常有好處的。本質上,這些類使用的是 __NSOrderedSetSetProxy
和 __NSOrderedSetArrayProxy
。
附註:如果你想知道為什麼 NSOrderedSet
不是 NSSet
的子類,NSHipster 上有一篇非常好的文章解釋了可變/不可變類簇的缺點。
NSOrderedSet 效能特徵
如果你看到這份測試,你就會知道 NSOrderedSet
代價高昂了,畢竟天下沒有免費的午餐:
類 / 時間 [ms] | 1.000.000 elements |
---|---|
NSMutableOrderedSet , adding |
3190.52 |
NSMutableSet , adding |
2511.96 |
NSMutableArray , adding |
1423.26 |
NSMutableOrderedSet , random access |
10.74 |
NSMutableSet , random access |
4.47 |
NSMutableArray , random access |
8.08 |
這個測試在每一個集合類中新增自定義字串,隨後隨機訪問它們。
NSOrderedSet
比 NSSet
和 NSArray
佔用更多的記憶體,因為它需要同時維護雜湊值和索引。
NSHashTable
NSHashTable
效仿了 NSSet
,但在物件/記憶體處理時更加的靈活。可以通過自定義 CFSet
的回撥獲得 NSHashTable
的一些特性,雜湊表可以保持對物件的弱引用並在物件被銷燬之後正確的將其移除,有時候如果手動在 NSSet 中新增的話,想做到這個是挺噁心的一件事。它是預設可變的 — 並且這個類沒有相應的不可變版本。
NSHashTable
有 ObjC 和原始的 C API,C API 可以用來儲存任意物件。蘋果在 10.5 Leopard 系統中引入了這個類,但是 iOS 的話直到最近的 iOS 6 中才被加入。足夠有趣的是它們只移植了 ObjC API;更多強大的 C API 沒有包括在 iOS 中。
NSHashTable
可以通過 initWithPointerFunctions:capacity:
進行大量的設定 — 我們只選取使用預先定義的hashTableWithOptions:
這一最普遍的使用場景。其中最有用的選項有利用 weakObjectsHashTable
來使用其自身的建構函式。
NSPointerFunctions
這些指標函式可以被用在 NSHashTable
,NSMapTable
和 NSPointerArray
中,定義了對儲存在這個集合中的物件的獲取和保留行為。這裡只介紹最有用的選項。完整列表參見 NSPointerFunctions.h
。
有兩組選項。記憶體選項決定了記憶體管理,個性化定義了雜湊和相等。
NSPointerFunctionsStrongMemory
建立了一個r etain/release 物件的集合,非常像常規的 NSSet
或 NSArray
。
NSPointerFunctionsWeakMemory
使用和 __weak
等價的方式來儲存物件並自動移除被銷燬的物件。
NSPointerFunctionsCopyIn
在物件被加入到集合前拷貝它們。
NSPointerFunctionsObjectPersonality
使用物件的 hash
和 isEqual:
(預設)。
NSPointerFunctionsObjectPointerPersonality
對於 isEqual:
和 hash
使用直接的指標比較。
NSHashTable 效能特徵
類 / 時間 [ms] | 1.000.000 elements |
---|---|
NSHashTable , adding |
2511.96 |
NSMutableSet , adding |
1423.26 |
NSHashTable , random access |
3.13 |
NSMutableSet , random access |
4.39 |
NSHashTable , containsObject |
6.56 |
NSMutableSet , containsObject |
6.77 |
NSHashTable , NSFastEnumeration |
39.03 |
NSMutableSet , NSFastEnumeration |
30.43 |
如果你只是需要 NSSet
的特性,請堅持使用 NSSet
。NSHashTable
在新增物件時花費了將近2倍的時間,但是其他方面的效率卻非常相近。
NSMapTable
NSMapTable
和 NSHashTable
相似,但是效仿的是 NSDictionary
。因此,我們可以通過mapTableWithKeyOptions:valueOptions:
分別控制鍵和值的物件獲取/保留行為。儲存弱引用是 NSMapTable
最有用的特性,這裡有4個方便的建構函式:
strongToStrongObjectsMapTable
weakToStrongObjectsMapTable
strongToWeakObjectsMapTable
weakToWeakObjectsMapTable
注意,除了使用 NSPointerFunctionsCopyIn
,任何的預設行為都會 retain (或弱引用)鍵物件而不會拷貝它,這與 CFDictionary
的行為相同而與 NSDictionary
不同。當你需要一個字典,它的鍵沒有實現 NSCopying
協議的時候(比如像 UIView
),這會非常有用。
如果你好奇為什麼蘋果”忘記”為 NSMapTable
增加下標,你現在知道了。下標訪問需要一個 id<NSCopying>
作為 key,對NSMapTable
來說這不是強制的。如果不通過一個非法的 API 協議或者移除 NSCopying
協議來削弱全域性下標,是沒有辦法給它增加下標的。
你可以通過 dictionaryRepresentation
把內容轉換為普通的 NSDictionary
。不像 NSOrderedSet
,這個方法返回的是一個常規的字典而不是一個代理。
NSMapTable 效能特徵
類 / 時間 [ms] | 1.000.000 elements |
---|---|
NSMapTable , adding |
2958.48 |
NSMutableDictionary , adding |
2522.47 |
NSMapTable , random access |
13.25 |
NSMutableDictionary , random access |
9.18 |
NSMapTable
只比 NSDictionary
略微慢一點。如果你需要一個不 retain 鍵的字典,放棄 CFDictionary
而使用它吧。
NSPointerArray
NSPointerArray
類是一個稀疏陣列,工作起來與 NSMutableArray
相似,但可以儲存 NULL
值,並且 count
方法會反應這些空點。可以用 NSPointerFunctions
對其進行各種設定,也有應對常見的使用場景的快捷建構函式 strongObjectsPointerArray
和weakObjectsPointerArray
。
在能使用 insertPointer:atIndex:
之前,我們需要通過直接設定 count
屬性來申請空間,否則會產生一個異常。另一種選擇是使用 addPointer:
,這個方法可以自動根據需要增加陣列的大小。
你可以通過 allObjects
將一個 NSPointerArray
轉換成常規的 NSArray
。這時所有的 NULL
值會被去掉,只有真正存在的物件被加入到陣列 — 因此陣列的物件索引很有可能會跟指標陣列的不同。注意:如果向指標陣列中存入任何非物件的東西,試圖執行allObjects
都會造成 EXC_BAD_ACCESS
崩潰,因為它會一個一個地去 retain ”物件”。
從除錯的角度講,NSPointerArray
沒有受到太多歡迎。description
方法只是簡單的返回了<NSConcretePointerArray: 0x17015ac50>
。為了得到所有的物件需要執行[pointerArray allObjects]
,當然,如果存在NULL
的話會改變索引。
NSPointerArray 效能特徵
在效能方面, NSPointerArray
真的非常非常慢,所以當你打算在一個很大的資料集合上使用它的時候一定要三思。在本測試中我們比較了使用 NSNull
作為空標記的 NSMutableArray
,而對 NSPointerArray
我們用 NSPointerFunctionsStrongMemory
來進行設定 (這樣物件會被適當的 retain)。在一個有 10,000 個元素的陣列中,我們每隔十個插入一個字串 ”Entry %d”。此測試包括了用 NSNull.null
填充 NSMutableArray
的總時間。對於 NSPointerArray
,我們使用 setCount:
來代替:
類 / 時間 [ms] | 10.000 elements |
---|---|
NSMutableArray , adding |
15.28 |
NSPointerArray , adding |
3851.51 |
NSMutableArray , random access |
0.23 |
NSPointerArray , random access |
0.34 |
注意 NSPointerArray
需要的時間比 NSMutableArray
多了超過* 250 倍(!)* 。這非常奇怪和意外。跟蹤記憶體是比較困難的,所以按理說 NSPointerArray
會更高效才對。不過由於我們使用的是同一個 NSNull
來標記空物件,所以除了指標也沒有什麼更多的消耗。
NSCache
NSCache
是一個非常奇怪的集合。在 iOS 4 / Snow Leopard 中加入,預設為可變並且執行緒安全的。這使它很適合快取那些建立起來代價高昂的物件。它自動對記憶體警告做出反應並基於可設定的”成本”清理自己。與 NSDictionary
相比,鍵是被 retain 而不是被 copy 的。
NSCache
的回收方法是不確定的,在文件中也沒有說明。向裡面放一些類似圖片那樣超大的物件並不是一個好主意,有可能它在能回收之前就更快地把你的 cache 給填滿了。(這是在 PSPDFKit 中很多跟記憶體有關的 crash 的原因,在使用自定義的基於 LRU 的連結串列快取的程式碼之前,我們起初使用了 NSCache
儲存事先渲染的圖片。)
可以對 NSCache
進行設定,這樣它就能自動回收那些實現了 NSDiscardableContent
協議的物件。實現了該屬性的一個比較常用的類是同時間加入的 NSPurgeableData
,但是在 OS X 10.9 之前,它是非完全執行緒安全的 (也沒有資訊表明這個變化也影響到了 iOS,或者說在 iOS 7 中被修復了)。
NSCache 效能
那麼相比起 NSMutableDictionary
來說,NSCache
表現如何呢?加入的執行緒安全必然會帶來一些消耗。處於好奇,我也加入了一個自定義的執行緒安全的字典的子類 (PSPDFThreadSafeMutableDictionary),它通過 OSSpinLock
實現同步的訪問。
類 / 時間 [ms] | 1.000.000 elements | iOS 7×64 Simulator | iPad Mini iOS 6 |
---|---|---|---|
NSMutableDictionary , adding |
195.35 | 51.90 | 921.02 |
PSPDFThreadSafeMutableDictionary , adding |
248.95 | 57.03 | 1043.79 |
NSCache , adding |
557.68 | 395.92 | 1754.59 |
NSMutableDictionary , random access |
6.82 | 2.31 | 23.70 |
PSPDFThreadSafeMutableDictionary , random access |
9.09 | 2.80 | 32.33 |
NSCache , random access |
9.01 | 29.06 | 53.25 |
NSCache
表現的相當好,隨機訪問跟我們自定義的執行緒安全字典一樣快。如我們預料的,新增更慢一些,因為 NSCache
要多維護一個決定何時回收物件的成本系數。就這一點來看這不是一個非常公平的比較。有趣的是,在模擬器上執行效率要慢了幾乎 10 倍。無論對 32 或 64 位的系統都是這樣。而且看起來這個類已經在 iOS 7 中優化過,或者是受益於 64 位 runtime 環境。當在老的裝置上測試時,使用 NSCache
的效能消耗就明顯得多。
iOS 6(32 bit) 和 iOS 7(64 bit) 的區別也很明顯,因為 64 位執行時使用標籤指標 (tagged pointer),因此我們的 @(idx)
boxing 要更為高效。
NSIndexSet
有些使用場景下 NSIndexSet
(和它的可變變體,NSMutableIndexSet
) 真的非常出色,對它的使用貫穿在 Foundation 中。它可以用一種非常高效的方法儲存一組無符號整數的集合,尤其是如果只是一個或少量範圍的時候。正如 set 這個名字已經暗示的那樣,每一個 NSUInteger
要麼在索引 set 中,要麼不在。如果你需要儲存任意非唯一的數的時候,最好使用 NSArray
。
下面是如何把一個整數陣列轉換為 NSIndexSet
:
1 2 3 4 5 6 7 |
NSIndexSet *PSPDFIndexSetFromArray(NSArray *array) { NSMutableIndexSet *indexSet = [NSMutableIndexSet indexSet]; for (NSNumber *number in array) { [indexSet addIndex:[number unsignedIntegerValue]]; } return [indexSet copy]; } |
如果不使用block,從索引set中拿到所有的索引有點麻煩,getIndexes:maxCount:inIndexRange:
是最快的方法,其次是使用firstIndex
並迭代直到 indexGreaterThanIndex:
返回 NSNotFound
。隨著 block 的到來,使用 NSIndexSet
工作變得方便的多:
1 2 3 4 5 6 7 |
NSArray *PSPDFArrayFromIndexSet(NSIndexSet *indexSet) { NSMutableArray *indexesArray = [NSMutableArray arrayWithCapacity:indexSet.count]; [indexSet enumerateIndexesUsingBlock:^(NSUInteger idx, BOOL *stop) { [indexesArray addObject:@(idx)]; }]; return [indexesArray copy]; } |
NSIndexSet效能
Core Foundation 中沒有和 NSIndexSet
相當的類,蘋果也沒有對效能做出任何承諾。NSIndexSet
和 NSSet
之間的比較也相對的不公平,因為常規的 set 需要對數字進行包裝。為了緩解這個影響,這裡的測試準備了實現包裝好的 NSUintegers
,並且在兩個迴圈中都會執行 unsignedIntegerValue
:
類 / 時間 [ms] | 1.000.000 elements | iOS 7×64 Simulator | iPad Mini iOS 6 |
---|---|---|---|
NSMutableDictionary , adding |
195.35 | 51.90 | 921.02 |
PSPDFThreadSafeMutableDictionary , adding |
248.95 | 57.03 | 1043.79 |
NSCache , adding |
557.68 | 395.92 | 1754.59 |
NSMutableDictionary , random access |
6.82 | 2.31 | 23.70 |
PSPDFThreadSafeMutableDictionary , random access |
9.09 | 2.80 | 32.33 |
NSCache , random access |
9.01 | 29.06 | 53.25 |
我們看到在一百萬左右物件的時候,NSIndexSet
開始變得比 NSSet
慢,但只是因為新的執行時和標籤指標。在 iOS 6 上執行相同的測試表明,甚至在更高數量級實體的條件下,NSIndexSet
更快。實際上,在大多數應用中,你不會新增太多的整數到索引 set 中。還有一點這裡沒有測試,就是 NSIndexSet
跟 NSSet
比無疑有更好的記憶體優化。
結論
本文提供了一些真實的測試,使你在使用基礎集合類的時候做出有根據的選擇。除了上面討論的類,還有一些不常用但確實有用的類,尤其是 NSCountedSet
,CFBag
,CFTree
,CFBitVector
和CFBinaryHeap
。