技術乾貨 | iOS 高階容器詳解

近期,在面試 iOS 工程師的過程當中,當我問到候選人小夥伴都瞭解哪些 iOS 容器類型時,大多數小夥伴能給出的答覆就是 NSArray、NSDictionary 和 NSSet 以及對應的可變類型,有些優秀的小夥伴可以說出 NSCache,還能對它的原理侃侃而談,這是很是棒的。可是整體而言,高階容器的普及在技術同窗中仍是比較少。本文,咱們就來詳細聊聊咱們對 iOS 高階容器類型的深刻研究結果,並討論其使用場景。html

在進行具體分析以前,咱們先簡單瞭解一下 iOS 的容器有哪些。 iOS 提供了三種主要的容器類型,它們分別是 Array、Set 和 Dictionary,用來存儲一組值:git

  • Array:存儲一組有序的值
  • Set:存儲一組無序的、不重複的值
  • Dictionary:存儲一組無序的鍵-值映射

這些都是咱們平時用到的基礎容器。除此以外,iOS 提供了不少高階容器類型,他們分別是:github

  • NSCountedSet
  • NSIndexSet && NSMutableIndexSet
  • NSOrderedSet && NSMutableOrderedSet
  • NSPointerArray
  • NSMapTable
  • NSHashTable
  • NSCache

今天,咱們將對這些高階容器進行詳細介紹。面試

NSCountedSet

NSCountedSet 是與 NSMutableSet 用法相似的無序集合,能夠添加、移除元素,判斷元素是否存在及保證元素惟一性。不一樣的是:segmentfault

  • 一個元素能夠添加屢次
  • 能夠獲取元素的數量

設想咱們要作一個淘寶購物車的功能,購物車中統計每個商品的數量,還能夠對數量進行增長和減小。按照慣例,傳統的作法是使用字典:數組

@property (nonatomic, strong) NSMutableDictinary *itemCountDic;

獲取數量:緩存

NSNumber *num = [self.itemCountDic objectForKey:item]; 
if (num == nil) {     
    return 0;    
} 
return num.integerValue;

數量+1:安全

NSNumber *num = [self.itemCountDic objectForKey:item]; 
if (num == nil) {     
    [self.itemCountDic setObject:@1 forKey:item];     
} else { 
    [self.itemCountDic setObject:@(num.integerValue+1) forKey:item]; 
}

數量-1:框架

NSNumber *num = [self.itemCountDic objectForKey:item]; 
if (num == nil) {     
    return; 
}  
if (nums.integerValue == 1) {     
    [self.itemCountDic removeObjectForKey:item]; 
} else {     
    [self.itemCountDic setObject:@(num.integerValue-1) forKey:item]; 
}

這種方式沒有問題,可是有了 NSCountedSet,全部的操做一行代碼就能搞定:函數

@property (nonatomic, strong) NSCountedSet<CartItem *> itemCountSet;

獲取數量:

[self.itemCountSet countForObject:item];

數量+1:

[self.itemCountSet addObject:item];

數量-1:

[self.itemCountSet removeObject:item];

能夠看出,NSCountedSet 就是爲這種場景量身定作的。

NSIndexSet && NSMutableIndexSet

NSIndexSet && NSMutableIndexSet是包含不重複整數的容器類型,使得索引訪問具有批量執行的能力。好比咱們須要獲取數組的第0,第2,第4個元素組成的子數組:

NSMutableIndexSet *indexes = [[NSMutableIndexSet alloc] init]; 
[indexes addIndex:0]; 
[indexes addIndex:2]; 
[indexes addIndex:4]; 
NSArray *newArray = [oldArray objectAtIndexes:indexes];

這樣一看,好像並無節省多少代碼量!別急,咱們再看下面的例子:在一個長度100的數組中,獲取區間5-八、11-1三、19-2二、55-99四個區間的元素。

NSMutableIndexSet *indexes = [[NSMutableIndexSet alloc] init];
[indexes addIndexesInRange:NSMakeRange(5, 4)]; // 5,6,7,8 
[indexes addIndexesInRange:NSMakeRange(11, 3)]; // 11,12,13 
[indexes addIndexesInRange:NSMakeRange(19, 4)]; // 19,20,21,22 
[indexes addIndexesInRange:NSMakeRange(55, 45)]; // 55,56,57,58.....99 
NSArray *newArray = [oldArray objectAtIndexes:indexes];

接下來咱們作一下性能測量,從一個長度10萬的隨機字串中,刪除全部 a 開頭的字符串。

方式1,批量對象刪除:

首先篩選元素:

NSArray *subarrayToRemove = [array filteredArrayUsingPredicate:[NSPredicate                                           predicateWithBlock:^BOOL(id _Nullable evaluatedObject, NSDictionary<NSString *,id> * _Nullable bindings) {     
       return [evaluatedObject hasPrefix:@"a"]; 
}]];

執行刪除:

[array removeObjectsInArray:subarrayToRemove];

方式2,批量索引刪除:

首先篩選索引集:

NSIndexSet *indexesToRemove = [array indexesOfObjectsPassingTest: 
    ^BOOL(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {     
    return [obj hasPrefix:@"a"];     
}];

執行刪除:

[array removeObjectsAtIndexes:indexesToRemove];

咱們對比執行時間:

方式 執行時間ms
方式1,批量對象刪除 25.33
方式2,批量索引刪除 15.33

咱們姑且忽略篩選元素以及篩選索引的時間,他們不會相差不少(都是O(n))。後來實驗證實後者效率更佳。

剖析:方式1比方式2多了一個步驟,即遍歷每個元素以得到他們的索引值。若是待刪除子集的長度是 k,這個多出來的步驟的時間複雜度是是 O(n * k)。隨着 n 和 k 的增長,執行時間的差距將會更加明顯。

NSOrderedSet && NSMutableOrderedSet

NSOrderedSet && NSMutableOrderedSet 是有序 Set,比 傳統 NSSet 增長了索引功能,且可以保持元素的插入順序。

索引示例:

NSString *o1 = @"3"; 
NSString *o2 = @"2"; 
NSString *o3 = @"1"; 
NSOrderedSet *orderedSet = [NSOrderedSet 
                                orderedSetWithObjects:o1, o2, o3, nil]; 
[orderedSet indexOfObject:o2]; // 1 
[orderedSet indexOfObject:o3]; // 2 
[orderedSet objectAtIndex:0];  // o1

使人驚喜的是,NSOrderedSet && NSMutableOrderedSet 支持 subscript:

orderedSet[1];  // o2

判斷集合包含關係:

[a isSubsetOfSet:b]; // a是否爲b的子集。b爲NSSet。 
[a isSubsetOfOrderedSet:b]; // a是否爲b的子集。b爲NSOrderedSet。

判斷集合相交關係:

[a intersectsSet:b]; // a是否與b有交集。b爲NSSet 
[a intersectsOrderedSet:b];  // a是否與b有交集。b爲NSOrderedSet

爲了探索 NSOrderedSet 與 NSArray 的性能差別,咱們看一下性能測試結果:

類型 100w元素,100w次索引訪問(ms) 1w元素,1w次查找 100w元素內存佔用(MB)
NSArray 38.012 597.029 15.266
NSOrderedSet 33.796 1.006 33.398

能夠看出,僅從訪問效率來看,二者差異並不大,而在 1w 次查找的對比中,NSOrderedSet 居然快出 590 倍之多!內存代價雖然比較昂貴,但在可接受的範圍以內。

NSPointerArray

NSPointerArray 是 NSMutableArray 的高階類型,比 NSMutableArray 具有更普遍的內存管理能力,具體以下:

  • 和傳統 NSArray 同樣,用於有序的插入或移除;
  • 與傳統 NSArray 不一樣的是,能夠存儲 NULL,且 NULL 參與 count 的計算;
  • 與傳統 NSArray 不一樣的是,count 能夠被設置,若是設置較大的 count 則使用 NULL 佔位;
  • 可使用 weak 或 unsafe_unretained 來修飾成員;
  • 能夠修改對象的判等方式;
  • 可使對象加入時進行拷貝;
  • 成員能夠是全部指針類型,不只限於 OC 對象;

咱們能夠舉個簡單的例子看一下,例如它能夠存儲 weak 引用:

NSPointerArray *pointerArray = NSPointerArray.weakObjectsPointerArray; 
[pointerArray addPointer:(void *)obj]; // obj的引用計數不會增長

注:obj 被釋放後,pointerArray.count 依然是1,這是由於 NULL 也會參與佔位。調用 compact 方法將清空全部的 NULL 佔位。

咱們能夠經過函數 + pointerArrayWithOptions:指定更多有趣的存儲方式。上面的NSPointerArray.weakObjectsPointerArray 其實是 [NSPointerArray pointerArrayWithOptions:NSPointerFunctionsWeakMemory] 的簡化版。

NSPointerFunctionsOptions 是一個選項,不一樣於枚舉,選項類型是能夠疊加的。這些選項能夠分爲內存管理、個性斷定、拷貝偏好三大類:

內存管理相關

  • NSPointerFunctionsWeakMemory: 弱引用,不增長引用計數。元素被釋放後變成 NULL,但 count 保持不變。調用 compact 方法後將刪除全部 NULL 元素並從新調整大小。對應 ARC 的weak。
  • NSPointerFunctionsStrongMemory:強引用,引用計數+1。對應 ARC 的 strong。
  • NSPointerFunctionsOpaqueMemory:不增長引用計數,也不建立弱引用,元素釋放後變野指針。對應 ARC 的 unsafe_unretained。
  • NSPointerFunctionsMallocMemory:移除元素時調用 free() 進行釋放,添加時調用 calloc()。不一樣於上面三種,這種方式適用於元素爲普通指針類型的狀況。
  • NSPointerFunctionsMachVirtualMemory:用於 Mach 的虛擬內存管理。

個性斷定相關

什麼是個性斷定呢?個性斷定包含如下三個方面:

  • 相等性斷定(即判等)。傳統容器都是使用元素的 -isEqual 進行相等性斷定。當對 NSArray 調用 indexOfObject 方法時,數組會遍歷內部元素,對每一個遍歷到的元素與輸入元素進行 isEqual 對比,直到碰到第一個斷定成功(即 isEqual 返回 YES)的元素並返回其索引;若全部元素均斷定失敗則返回 NSNotFound。
  • 哈希值斷定。如使用對象的 Hash 方法是一種哈希值斷定方式。常見的 NSSet、NSDictionary 都是使用元素的 Hash 方法獲取哈希值,從而決定其索引位置。
  • 描述值斷定。如使用對象的 Description 方法是一種描述值斷定方式。對數組進行打印時,打印的內容中包含了全部對象的 Description 值。

咱們來看下個性斷定相關的 NSPointerFunctionsOptions 有哪些:

  • NSPointerFunctionsObjectPersonality:斷定元素爲 OC 對象。用元素的 isEqual 方法判等,Hash 方法計算哈希值,Description 方法作描述(NSLog 打印)。
  • NSPointerFunctionsObjectPointerPersonality:斷定元素爲對象指針。經過對比指針來判等,經過指針左移計算哈希值,用 Description 方法對其描述。
  • NSPointerFunctionsCStringPersonality:斷定元素爲 CString。使用 strcmp 判等,對該字符串求哈希,用 UTF8 編碼格式對其描述。
  • NSPointerFunctionsIntegerPersonality:斷定元素爲整型值。使用整型值的右移結果做哈希值和判等條件。
  • NSPointerFunctionsStructPersonality::斷定元素爲結構體指針。用 memcmp 對比內存判等,對實際內存求哈希。
  • NSPointerFunctionsOpaquePersonality:不肯定類型。經過對比指針來判等,經過指針左移計算哈希值。

拷貝偏好

NSPointerFunctionsCopyIn: 添加元素時,實際添加的是元素的拷貝。

接下來咱們對比一組數據,單位 ms

容器/方法 100萬次add 100萬次隨機訪問
NSMutableArray 0.023 69.9
NSPointerArray + Strong Memory 0.024 60
NSPointerArray + Weak Memory 759 224.4

可見,NSMutableArray 與 NSPointerArray+ strong 幾乎沒有差異,而 NSPointerArray + Weak 的性能開銷就不那麼樂觀了。

那咱們怎麼理解傳統數組與 NSPointerArray 的關係呢?傳統數組就至關於一個特殊的 NSPointerArray,把它的 options 設成這樣:

NSPointerFunctionsStrongMemory | NSPointerFunctionsObjectPersonality

即個性斷定爲 OC 對象,強引用,不進行拷貝。

NSMapTable

NSMapTable 爲 NSMutableDictionary 的高階類型。它與 NSPointerArray 相似,能夠指定 NSPointerFunctionsOptions,不一樣的是 NSMapTable 的 key 和 value 均可以指定 options:

[NSMapTable mapTableWithKeyOptions:keyOptions valueOptions:valueOptions]

更便捷的初始化方法:

NSMapTable.strongToStrongObjectsMapTable // key 爲 strong,value 爲 strong NSMapTable.weakToStrongObjectsMapTable // key 爲 weak,value 爲 strong NSMapTable.strongToWeakObjectsMapTable // key 爲 strong,value 爲 weak NSMapTable.weakToWeakObjectsMapTable; // key 爲 weak,value 爲 weak

保留傳統字典的經典能力:

[table setObject:obj forKey:key]; // 設置Key,Value 
[table objectForKey:key] // 根據Key獲取Value 
[table removeObjectForKey:] // 刪除

不一樣的是,系統並無給它 subscript 支持,即不能使用相似 dict[key] = value 的中括號語法。

那咱們怎麼理解傳統字典與 NSMapTable 的關係呢?傳統字典就至關於一個特殊的 NSMapTable,把它的 keyOptions 設成這樣:

NSPointerFunctionsStrongMemory  | 
NSPointerFunctionsObjectPersonality|
NSPointerFunctionsCopyIn;

須要注意的是NSPointerFunctionsCopyIn, 老字典會對 key 進行 copy,value 不會。可是若是你們平日裏都使用NSString做爲 key,那大可沒必要考慮 copy 的性能損耗(由於只是淺拷貝)。但若是使用的是NSMutableString或者一些進行深拷貝的類型,那就另當別論了。

再把它的 valueOptions 設成這樣:

NSPointerFunctionsStrongMemory | NSPointerFunctionsObjectPersonality

即 key 爲強引用、個性斷定爲 OC 對象、添加元素時進行拷貝;value 爲強引用,個性斷定爲 OC 對象,但不進行拷貝。

NSMapTable與老字典的性能不能一律而論,由於他們的主要性能差異也是來自於NSPointerFunctionsCopyIn與NSPointerFunctionsWeakMemory。後者會帶來必定的性能損耗,而前者要看key的NSCopying協議是如何實現的。

NSHashTable

NSHashTable 是 NSMutableSet 的高階類型,與 NSPointerArray、NSMapTable 同樣,能夠指定 NSPointerFunctionsOptions:

[NSHashTable hashTableWithOptions:options]

便捷的初始化方法:

NSHashTable.weakObjectsHashTable // weak set 
NSHashTable.strongObjectsHashTable // strong set

保留傳統 Set 的經典能力:

[table addObject:obj] // 添加obj,去重 
[table removeObject:obj] // 移除obj 
[table containsObject:obj] // 是否包含obj 
[table intersectsHashTable:anotherTable] // 是否與anotherTable有交集 
[table isSubsetOfHashTable:anotherTable] // 是不是anotherTable的子集

一樣,若是用 NSHashTable 表示傳統字典,傳統字典應該是這樣的 NSHashTable:

NSPointerFunctionsStrongMemory | NSPointerFunctionsObjectPersonality

NSCache

NSCache是Foundation框架提供的緩存類的實現,使用方式相似於可變字典,因爲NSMutableDictionary的存在,不少人在實現緩存時都會使用可變字典,但這樣是具備不少侷限性的。咱們能夠從3個方面理清楚它與NSMutableDictionary的區別:

  • NSCache集成了多種緩存淘汰策略(雖然官方文檔沒有明確指出,但從測試結果來看是 LRU 即 Lease Recent Usage),且發生內存警告時會進行清理), 保證了 cache 不會佔用過多的內存資源。
  • NSCache是線程安全的。能夠從不一樣的線程中對NSCache進行增刪改查操做,而不須要本身對cache加鎖。
  • 與NSMutableDictionary不一樣, NSCache不會對key進行拷貝。

下面簡單介紹一下 LRU(雙鏈表+散列表)的核心邏輯。

LRU 緩存淘汰策略核心邏輯

  • 與老字典不一樣,散列表的 value 變成通過封裝的節點 Node,包含:

    • key: 即字典的key
    • value:即字典的value
    • prev:上一個節點
    • next: 下一個節點
  • 插入散列表的節點將移到鏈表頭部,時間複雜度爲O(1)
  • 被訪問的或更新的節點將移動到鏈表頭部,時間複雜度爲O(1)
  • 當容量超限時,鏈表尾部的節點將被移除(時間複雜度爲O(1)),同時從散列表中移除

咱們看到,鏈表的各項操做並無影響散列表的總體時間複雜度。

開始使用

首先,初始化容量爲5的 cache:

self.cache = [[NSCache alloc] init];
self.cache.totalCostLimit = 5;
self.cache.delegate = self;

實現 NSCacheDelegate,元素被淘汰時會收到回調:

- (void)cache:(NSCache *)cache willEvictObject:(id)obj { 
    NSLog(@"%@", [NSString stringWithFormat:@"%@ will be evict",obj]);
}

接下來分別插入5個元素:

for (int i = 0; i < 5; i++) { 
     [self.cache setObject:@(i) forKey:@(i) cost:1]; 
 }

元素按照一、二、三、四、5的順序插入的,意味着下一個被淘汰的元素是1。

接下來咱們試着訪問1,而後插入6:

NSNumber *num = [self.cache objectForKey:@(1)];
[self.cache setObject:@6 forKey:@6 cost:1];

結果打印:

2020-07-31 09:30:56.486382+0800 Test_Example[52839:214698] 2 will be evict

緣由是1被訪問後被置換成了鏈表的 head,此時 tail 變成了2。再次插入新數據後,tail 元素2被淘汰。

總結

近不修,無以行遠路; 低不修,無以登高山。若要成爲最煊赫一時的技術人才,打下紮實的地基是必不可少的。面對現在移動端人才市場的飽和,小夥伴們更應該抓住機會,磨礪本身,在行業中不斷成長和進步,最終成爲行業內不可或缺的精英人才。

咱們一樣也在期待志同道合的小夥伴加入,歡迎投遞咱們:https://hr.163.com/job-detail.html?id=27614&lang=zh

優秀且富有抱負的你,還在等什麼呢?

做者簡介

丁文超,網易雲信資深 iOS 工程師,負責雲信 IM、解決方案的設計和研發工做。Github: WenchaoD

活動免費報名中

image

4月10日,娛樂社交技術沙龍,邀請來自快手、網易雲音樂、Bilibili、網易音視頻實驗室的技術大咖們,從音視頻創做、音視頻技術、直播多樣化、互動視頻多樣化等方向,爲你們分享泛娛樂應用在音視頻上的技術實踐,深刻探討音視頻技術如何賦能業務,以知足用戶多樣化需求。

點擊連接便可報名:https://segmentfault.com/e/11...

相關文章
相關標籤/搜索