狀態(tài)維護(hù)是個(gè)怎么說(shuō)都不夠的話題,畢竟?fàn)顟B(tài)的處理是我們整個(gè)App最核心的部分,也是最容易出bug的地方。之前寫過(guò)一篇以函數(shù)式編程的角度看狀態(tài)維護(hù)的文章,這次從Swift語(yǔ)言層面的改進(jìn),看看Objective C下該如何合理的處理數(shù)組的維護(hù)。
Objective C數(shù)組的內(nèi)存布局
要了解NSArray,NSSet,NSDictionary這些集合類的使用方法,我們需要先弄明白其對(duì)應(yīng)的內(nèi)存布局(Memory Layout),以一個(gè)NSMutableArray的property為例:
//declare
@property (nonatomic, strong) NSMutableArray* arr;
//init
self.arr = @[@1, @2, @3].mutableCopy;
arr初始化之后,以64位系統(tǒng)為例,其實(shí)際的內(nèi)存布局分為三塊:

第一塊是指針NSMutableArray* arr所處的位置,為8個(gè)字節(jié)。第二塊是數(shù)組實(shí)際的內(nèi)存區(qū)域所處的位置,為連續(xù)3個(gè)指針地址,各占8個(gè)字節(jié)一共24個(gè)字節(jié)。第三塊才是@1,@2,@3這些NSNumber對(duì)象真正的內(nèi)存空間。當(dāng)我們調(diào)用不同的API對(duì)arr進(jìn)行操作的時(shí)候,要分清楚實(shí)際是在操作哪部分內(nèi)存。
比如:
self.arr = @[@4];
是在對(duì)第一塊內(nèi)存區(qū)域進(jìn)行賦值。
self.arr[0] = @4;
是在對(duì)第二塊內(nèi)存區(qū)域進(jìn)行賦值。
[self.arr[0] integerValue];
是在訪問(wèn)第三塊內(nèi)存區(qū)域。
之前寫過(guò)一篇多線程安全的文章,我們知道即使在多線程的場(chǎng)景下,對(duì)第一塊內(nèi)存區(qū)域進(jìn)行讀寫都是安全的,而第二塊和第三塊內(nèi)存區(qū)域都是不安全的。
NSMutableArray為什么危險(xiǎn)?
在Objective C的世界里,帶Mutable的都是危險(xiǎn)分子。我們看下面代碼:
//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;執(zhí)行之后,我們的內(nèi)存模型是這樣的:

這行代碼實(shí)際上只是新生成了8個(gè)字節(jié)的第一類內(nèi)存空間給localArr,localArr實(shí)際上還是和arr共享第二塊和第三塊內(nèi)存區(qū)域,當(dāng)在thread 2執(zhí)行[localArr removeAllObjects];清理第二塊內(nèi)存區(qū)域的時(shí)候,如果主線程正在同時(shí)訪問(wèn)第二塊內(nèi)存區(qū)域_arr[1],就會(huì)導(dǎo)致crash了。這類問(wèn)題的根本原因,還是在對(duì)于同一塊內(nèi)存區(qū)域的同時(shí)讀寫。
Swift的改變
Swift對(duì)于上述的數(shù)組賦值操作,從語(yǔ)言層面做了根本性的改變。
Swift當(dāng)中所有針對(duì)集合類的操作,都符合一種叫copy on write(COW)的機(jī)制,比如下面的代碼:
var arr = [1, 2, 3]
var localArr = arr
print("arr: \(arr)")
print("localArr: \(localArr)")
arr += [4];
print("arr: \(arr)")
print("localArr: \(localArr)")
當(dāng)執(zhí)行到var localArr = arr的時(shí)候,arr和localArr的內(nèi)存布局還是和Objective C一致,arr和localArr都共享第二第三塊內(nèi)存區(qū)域,但是一旦出現(xiàn)寫操作(write),比如arr += [4];的時(shí)候,Swift就會(huì)針對(duì)原先arr的第二塊內(nèi)存區(qū)域,生成一份新的拷貝(copy),也就是所謂的copy on write,執(zhí)行cow之后,arr和localArr就指向不同的第二塊內(nèi)存區(qū)域了,如下圖所示:

一旦出現(xiàn)針對(duì)arr寫操作,系統(tǒng)就會(huì)將內(nèi)存區(qū)域2拷貝至一塊新的內(nèi)存區(qū)域4,并將arr的指針指向新開(kāi)辟的區(qū)域4,之后再發(fā)生數(shù)組的改變,arr和localArr就指向不同的區(qū)域,即使在多線程的環(huán)境下同時(shí)發(fā)生讀寫,也不會(huì)導(dǎo)致訪問(wèn)同一內(nèi)存區(qū)域的crash了。
上面的代碼,最后打印的結(jié)果中,arr和localArr中所包含的元素也不一致了,畢竟他們已經(jīng)指向各自的第二類內(nèi)存區(qū)域了。
這也是為什么說(shuō)Swift是一種更加安全的語(yǔ)言,通過(guò)語(yǔ)言層面的修改,幫助開(kāi)發(fā)者避免一些難以調(diào)試的bug,而這一切都是對(duì)開(kāi)發(fā)者透明的,免費(fèi)的,開(kāi)發(fā)者并不需要做特意的適配。還是一個(gè)簡(jiǎn)單的=操作,只不過(guò)背后發(fā)生的事情不一樣了。
Objective C的領(lǐng)悟
Objective C還沒(méi)有退出歷史舞臺(tái),依然在很多項(xiàng)目中發(fā)揮著余熱。明白了Swift背后所做的事情,Objective C可以學(xué)以致用,只不過(guò)要多寫點(diǎn)代碼。
Objective C既然沒(méi)有COW,我們可以自己copy。
比如需要對(duì)數(shù)組進(jìn)行遍歷操作的時(shí)候,在遍歷之前先Copy:
NSArray* iterateArr = [self.arr copy];
for (int i = 0; i < iterateArr.count; i ++) {
NSLog(@"element: %@", iterateArr[i]);
}
比如當(dāng)我們需要修改數(shù)組中的元素的時(shí)候,在開(kāi)始修改之前先Copy:
self.arr = @[@1, @2, @3].mutableCopy;
NSMutableArray* modifyArr = [self.arr mutableCopy];
[modifyArr removeAllObjects];
[modifyArr addObjectsFromArray:@[@4, @5, @6]];
self.arr = modifyArr;
比如當(dāng)我們需要返回一個(gè)可變數(shù)組的時(shí)候,返回一個(gè)數(shù)組的Copy:
- (NSMutableArray*)createSamples
{
[_samples addObject:@1];
[_samples addObject:@2];
return [_samples mutableCopy];
}
只要是針對(duì)共享數(shù)組的操作,時(shí)刻記得copy一份新的內(nèi)存區(qū)域,就可以實(shí)現(xiàn)手動(dòng)COW的效果,這樣Objective C也能在維護(hù)狀態(tài)的時(shí)候,是多線程安全的。
Copy更健康
除了NSArray之外,還有其他集合類NSSet,NSDictionary等,NSString本質(zhì)上也是個(gè)集合,對(duì)于這些狀態(tài)的處理,copy可以讓他們更加安全。
宗旨是避免共享狀態(tài),這不僅僅是出于多線程場(chǎng)景的考慮,即使是在UI線程中維護(hù)狀態(tài),在一個(gè)較長(zhǎng)的時(shí)間跨度內(nèi)狀態(tài)也可能出現(xiàn)意料之外的變化,而copy能隔絕這種變化帶來(lái)的副作用。
當(dāng)然copy也不是沒(méi)有代價(jià)的,最明顯的代價(jià)是內(nèi)存方面的額外開(kāi)銷,一個(gè)含有100個(gè)元素的array,如果copy一份的話,在64位系統(tǒng)下,會(huì)多出800個(gè)字節(jié)的空間。這也是為什么Swift只有在write的時(shí)候才copy,如果只是讀操作,就不會(huì)產(chǎn)生copy額外的內(nèi)存開(kāi)銷。但綜合來(lái)看,這點(diǎn)內(nèi)存開(kāi)銷和我們程序的穩(wěn)定性比起來(lái),幾乎可以忽略不計(jì)。在維護(hù)狀態(tài)的時(shí)候多使用copy,讓我們的函數(shù)符合Functional Programming當(dāng)中的純函數(shù)標(biāo)準(zhǔn),會(huì)讓我們的代碼更加穩(wěn)定。
總結(jié)
學(xué)習(xí)Swift的時(shí)候,如果細(xì)心觀察,可以發(fā)現(xiàn)其他很多地方,也有Swift避免共享同一塊內(nèi)存區(qū)域的語(yǔ)法特性。要能真正理解這些語(yǔ)言背后的機(jī)制,說(shuō)到底還是在于我們對(duì)于memory layout的理解。