作者:MrPeakTech(公眾號)
狀態維護是個怎麼說都不夠的話題,畢竟狀態的處理是我們整個App最核心的部分,也是最容易出bug的地方。之前寫過一篇以函數式編程的角度看狀態維護的文章,這次從Swift語言層面的改進,看看Objective C下該如何合理的處理數組的維護。
Objective C數組的內存布局
要了解NSArray,NSSet,NSDictionary這些集合類的使用方法,我們需要先弄明白其對應的內存布局(Memory Layout),以一個NSMutableArray的property為例:
//declare @property (nonatomic, strong) NSMutableArray* arr; //init self.arr = @[@1, @2, @3].mutableCopy;
arr初始化之後,以64位系統為例,其實際的內存布局分為三塊:
第一塊是指針NSMutableArray* arr所處的位置,為8個字節。第二塊是數組實際的內存區域所處的位置,為連續3個指針地址,各占8個字節一共24個字節。第三塊才是@1,@2,@3這些NSNumber對象真正的內存空間。當我們調用不同的API對arr進行操作的時候,要分清楚實際是在操作哪部分內存。
比如:
self.arr = @[@4];
是在對第一塊內存區域進行賦值。
self.arr[0] = @4;
是在對第二塊內存區域進行賦值。
[self.arr[0] integerValue];
是在訪問第三塊內存區域。
之前寫過一篇多線程安全的文章,我們知道即使在多線程的場景下,對第一塊內存區域進行讀寫都是安全的,而第二塊和第三塊內存區域都是不安全的。
NSMutableArray為什麼危險?
在Objective C的世界裡,帶Mutable的都是危險分子。我們看下面代碼:
//main thread self.arr = @[@1, @2, @3].mutableCopy; for (int i = 0; i < _arr.count; i ++) { NSLog(@"element: %@", _arr[i]); } //thread 2 NSMutableArray* localArr = self.arr; //get result from server NSArray* results = @[@8, @9, @10]; //refresh local arr [localArr removeAllObjects]; [localArr addObjectsFromArray:results];
NSMutableArray* localArr = self.arr;執行之後,我們的內存模型是這樣的:
這行代碼實際上只是新生成了8個字節的第一類內存空間給localArr,localArr實際上還是和arr共享第二塊和第三塊內存區域,當在thread 2執行[localArr removeAllObjects];清理第二塊內存區域的時候,如果主線程正在同時訪問第二塊內存區域_arr[1],就會導致crash了。這類問題的根本原因,還是在對於同一塊內存區域的同時讀寫。
Swift的改變
Swift對於上述的數組賦值操作,從語言層面做了根本性的改變。
Swift當中所有針對集合類的操作,都符合一種叫copy on write(COW)的機制,比如下面的代碼:
var arr = [1, 2, 3] var localArr = arr print("arr: \(arr)") print("localArr: \(localArr)") arr += [4]; print("arr: \(arr)") print("localArr: \(localArr)")
當執行到var localArr = arr的時候,arr和localArr的內存布局還是和Objective C一致,arr和localArr都共享第二第三塊內存區域,但是一旦出現寫操作(write),比如arr += [4];的時候,Swift就會針對原先arr的第二塊內存區域,生成一份新的拷貝(copy),也就是所謂的copy on write,執行cow之後,arr和localArr就指向不同的第二塊內存區域了,如下圖所示:
一旦出現針對arr寫操作,系統就會將內存區域2拷貝至一塊新的內存區域4,並將arr的指針指向新開辟的區域4,之後再發生數組的改變,arr和localArr就指向不同的區域,即使在多線程的環境下同時發生讀寫,也不會導致訪問同一內存區域的crash了。
上面的代碼,最後打印的結果中,arr和localArr中所包含的元素也不一致了,畢竟他們已經指向各自的第二類內存區域了。
這也是為什麼說Swift是一種更加安全的語言,通過語言層面的修改,幫助開發者避免一些難以調試的bug,而這一切都是對開發者透明的,免費的,開發者並不需要做特意的適配。還是一個簡單的=操作,只不過背後發生的事情不一樣了。
Objective C的領悟
Objective C還沒有退出歷史舞台,依然在很多項目中發揮著余熱。明白了Swift背後所做的事情,Objective C可以學以致用,只不過要多寫點代碼。
Objective C既然沒有COW,我們可以自己copy。
比如需要對數組進行遍歷操作的時候,在遍歷之前先Copy:
NSMutableArray* iterateArr = [self.arr copy]; for (int i = 0; i < iterateArr.count; i ++) { NSLog(@"element: %@", iterateArr[i]); }
比如當我們需要修改數組中的元素的時候,在開始修改之前先Copy:
self.arr = @[@1, @2, @3].mutableCopy; NSMutableArray* modifyArr = [self.arr mutableCopy]; [modifyArr removeAllObjects]; [modifyArr addObjectsFromArray:@[@4, @5, @6]]; self.arr = modifyArr;
比如當我們需要返回一個可變數組的時候,返回一個數組的Copy:
- (NSMutableArray*)createSamples { [_samples addObject:@1]; [_samples addObject:@2]; return [_samples mutableCopy]; }
只要是針對共享數組的操作,時刻記得copy一份新的內存區域,就可以實現手動COW的效果,這樣Objective C也能在維護狀態的時候,是多線程安全的。
Copy更健康
除了NSArray之外,還有其他集合類NSSet,NSDictionary等,NSString本質上也是個集合,對於這些狀態的處理,copy可以讓他們更加安全。
宗旨是避免共享狀態,這不僅僅是出於多線程場景的考慮,即使是在UI線程中維護狀態,在一個較長的時間跨度內狀態也可能出現意料之外的變化,而copy能隔絕這種變化帶來的副作用。
當然copy也不是沒有代價的,最明顯的代價是內存方面的額外開銷,一個含有100個元素的array,如果copy一份的話,在64位系統下,會多出800個字節的空間。這也是為什麼Swift只有在write的時候才copy,如果只是讀操作,就不會產生copy額外的內存開銷。但綜合來看,這點內存開銷和我們程序的穩定性比起來,幾乎可以忽略不計。在維護狀態的時候多使用copy,讓我們的函數符合Functional Programming當中的純函數標准,會讓我們的代碼更加穩定。
總結
學習Swift的時候,如果細心觀察,可以發現其他很多地方,也有Swift避免共享同一塊內存區域的語法特性。要能真正理解這些語言背後的機制,說到底還是在於我們對於memory layout的理解。