二級指標與ARC不為人知的特性

從來吃不胖發表於2019-02-27

先看一眼熟知的程式碼

- (void)viewDidLoad {
    [super viewDidLoad];
    
    NSData *data = [@"{\"key\":\"value\"}" dataUsingEncoding:NSUTF8StringEncoding];
    
    NSError *error = nil;
    id dataObj = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
    if (error) {
        NSLog(@"解析JSON出錯。 error : %@",error);
    } else {
        NSLog(@"解析JSON正確。 dataObj : %@",dataObj);
    }
}
複製程式碼

上述程式碼中,出現了NSError的例項。該例項是用來表明發生了某種錯誤。在ARC中由於使用異常處理會造成記憶體管理的不便(可能造成記憶體洩露,或者加入大量樣板程式碼),所以用NSError表明發生了錯誤是一種不錯的選擇,蘋果的API中也大量使用了NSError。

這裡請關注[NSJSONSerialization JSONObjectWithData:data options:0 error:&error]的最後一個引數:error:(NSError **)error;。該方法使用了二級指標作為引數傳入,經由此引數可以將方法中新建立的NSError物件回傳給呼叫者,所以該引數也稱為“輸出引數”。從這種型別的引數入手,後面我們將討論一個很嚴肅的問題~

我們來實現一個類似的方法(也就是方法裡新建立一個物件回傳給呼叫者)

1. 不用二級指標我直接傳個view進方法裡不就可以建立一個view了嗎?

程式碼:

- (void)viewDidLoad {
    [super viewDidLoad];
    
    UIView *thisIsNilView = nil;		// 宣告一個view,但是還有沒建立
    NSLog(@"1. thisIsNilView指向的例項 : %@",thisIsNilView);
    [self createView:thisIsNilView];
    NSLog(@"4. thisIsNilView指向的例項 : %@",thisIsNilView);
}

- (void)createView:(UIView *)view {
    NSLog(@"2. 方法裡的view指向的例項 : %@",view);
    view = [[UIView alloc] initWithFrame:CGRectMake(100, 100, 100, 100)];
    NSLog(@"3. 方法裡的view指向的例項 : %@",view);
}
複製程式碼

看起來很簡單呢,我宣告一個空的thisIsNilView,傳到一個createView:方法裡,方法裡會幫我建立一個view,那麼thisIsNilView不就有值了?

讓我們看看執行結果:

 1. thisIsNilView指向的例項 : (null)
 2. 方法裡的view指向的例項 : (null)
 3. 方法裡的view指向的例項 : <UIView: 0x7f956ee00220; frame = (100 100; 100 100); layer = <CALayer: 0x600000029420>>
 4. thisIsNilView指向的例項 : (null)
複製程式碼

哪裡出問題了?方法裡明明建立出了一個view啊?

我們來探究探究到底是哪裡出了問題。

回想下thisIsNilView是個什麼東西?恩,是個指向UIView的指標(是個指標、是個指標、是個指標),那麼我們來看看指標在方法裡是否正確指向了生成的UIView例項。

我改動了下程式碼:

- (void)viewDidLoad {
    [super viewDidLoad];
    
    UIView *thisIsNilView = nil;
    NSLog(@"1.0 thisIsNilView指向的例項 : %@",thisIsNilView);
    NSLog(@"1.1 thisIsNilView指標的地址 : %p",&thisIsNilView);
    NSLog(@"--------- 開始執行createView:方法 ---------");
    [self createView:thisIsNilView];
    NSLog(@"--------- 執行createView:方法結束 ---------");
    NSLog(@"4.0 thisIsNilView指向的例項 : %@",thisIsNilView);
    NSLog(@"4.1 thisIsNilView指標的地址 : %p",&thisIsNilView);
}

- (void)createView:(UIView *)view {
    NSLog(@"2.0 方法裡的view指向的例項 : %@",view);
    NSLog(@"2.1 方法裡的view指標的地址 : %p",&view);
    view = [[UIView alloc] initWithFrame:CGRectMake(100, 100, 100, 100)];
    NSLog(@"3.0 方法裡的view指向的例項 : %@",view);
    NSLog(@"3.1 方法裡的view指標的地址 : %p",&view);
}
複製程式碼

為了方便檢視結果,加了幾行列印~

 1.0 thisIsNilView指向的例項 : (null)
 1.1 thisIsNilView指標的地址 : 0x16fd35f18
 
 --------- 開始執行createView:方法 ---------
 2.0 方法裡的view指向的例項 : (null)
 2.1 方法裡的view指標的地址 : 0x16fd35ee8
 
 3.0 方法裡的view指向的例項 : <UIView: 0x12de0b6a0; frame = (100 100; 100 100); layer = <CALayer: 0x610000034640>>
 3.1 方法裡的view指標的地址 : 0x16fd35ee8
 --------- 執行createView:方法結束 ---------
 
 4.0 thisIsNilView指向的例項 : (null)
 4.1 thisIsNilView指標的地址 : 0x16fd35f18
複製程式碼

額,好像thisIsNilView這個指標(位於0x16fd35f18這塊記憶體區域中)傳入方法後變成另外一個指標(位於0x16fd35ee8這塊記憶體區域中)了啊。

插個記憶體圖理解下:

第一步:

我是配圖

第二步:

我是配圖

第三步:

我是配圖

第四步:

我是配圖

為何第二步進入方法後會憑空多出一個指標?哦忘了說,指標也是個變數,指標作為引數傳遞的時候,指標“本身”也是值傳遞,也就是說複製了一個“與原指標指向相同記憶體地址”的指標。好像有點繞,其實就是第二步的圖。

回想下C語言基礎中的引數傳遞:基本資料型別是複製一份進行傳遞,但是指標傳遞是引用傳遞,可以修改變數本身的內容。說是這樣說,但是不夠全面。指標傳遞其實也是個複製傳遞,只不過複製的是“指標”,但是“複製後的指標”中的內容(也就是指標指向的地址)還是指向了原來指向的內容。

這個指標複製傳遞還是有那麼點兒繞,我們用指標與int基本型別做個對比:

int a = 10;

int *p = &a;

對應關係:

a 是個 int 型別的變數;

a 的內容是 10;

p 是個 int * 型別的變數(俗稱指標);

p 的內容是 a這個變數在記憶體中的地址(比如0xa);

函式:

void testIntCopy(int b) {
    int c = b;
}

void testPointCopy(int *pointer) {
    printf("%p",pointer);
}
複製程式碼

在testIntCopy中傳入a,那麼將會拷貝一份a的內容:10(數值) 到 b(int型別的變數) 中。之後就可以正常使用了。

在testPointCopy中傳入p,那麼將會拷貝一份p的內容:指向a在記憶體中的地址(如0xa) 到 pointer(int *型別的變數) 中。之後就可以正常使用了,比如修改pointer指向的記憶體中的值。

這樣子理解是不是輕鬆一點?那麼之前第二步的圖就可以理解了。

這說明了一個問題:一級指標作為引數傳遞無法修改原指標指向的值。


2. 那得用二級指標才能在方法裡建立並回傳給呼叫者一個view是嗎?

是不是我們先上個程式碼看看:

- (void)viewDidLoad {
    [super viewDidLoad];
    
    UIView *thisIsNilView = nil;
    NSLog(@"1.0 thisIsNilView指向的例項 : %@",thisIsNilView);
    NSLog(@"1.1 thisIsNilView指標的地址 : %p",&thisIsNilView);
    NSLog(@"--------- 開始執行createViewWithSecRankPointer:方法 ---------");
    [self createViewWithSecRankPointer:&thisIsNilView];
    NSLog(@"--------- 執行createViewWithSecRankPointer:方法結束 ---------");
    NSLog(@"4.0 thisIsNilView指向的例項 : %@",thisIsNilView);
    NSLog(@"4.1 thisIsNilView指標的地址 : %p",&thisIsNilView);
}

- (void)createViewWithSecRankPointer:(UIView **)view {
    NSLog(@"2.0 方法裡的*view指向的例項 : %@",*view);
    NSLog(@"2.1 方法裡的*view指標的地址 : %p",view);
    *view = [[UIView alloc] initWithFrame:CGRectMake(100, 100, 100, 100)];
    NSLog(@"3.0 方法裡的*view指向的例項 : %@",*view);
    NSLog(@"3.1 方法裡的*view指標的地址 : %p",view);
}
複製程式碼

注意方法已經不是原來的方法了,注意方法裡所列印的東西也已經有所變更。

看結果前我們先分析分析這些程式碼究竟幹了什麼:

1. 有一個UIView * 型別的指標: thisIsNilView ,然後應該還有一個指向thisIsNilView這個指標的指標:我們姑且假設它為thisIsNilViewFatherPointer。

2. 我們要進入createViewWithSecRankPointer:方法了!按照上文講的“指標值傳遞”,我們傳遞了thisIsNilViewFatherPointer的值(也就是thisIsNilView的地址)給了createViewWithSecRankPointer:方法。此時方法裡的view(二級指標),應該是個thisIsNilViewFatherPointer指標的拷貝,但指向的還是thisIsNilView這個指標(內容從thisIsNilViewFatherPointer拷貝過來了嘛)。

3. 好的,我既然可以拿到thisIsNilView這個指標了(通過*view),那麼我總算可以修改thisIsNilView這個指標的指向了,讓thisIsNilView指向一個全新建立的UIView例項把!!!

4. 執行完方法了,那麼thisIsNilView這個指標應該指向的是剛才在方法裡新建立的view,那麼我們就完成了一個“輸出引數”了對嗎。

看看執行結果:

 1.0 thisIsNilView指向的例項 : (null)
 1.1 thisIsNilView指標的地址 : 0x16fd75f18
 
 --------- 開始執行createViewWithSecRankPointer:方法 ---------
 2.0 方法裡的*view指向的例項 : (null)
 2.1 方法裡的*view指標的地址 : 0x16fd75f10
 3.0 方法裡的*view指向的例項 : <UIView: 0x15bd07ff0; frame = (100 100; 100 100); layer = <CALayer: 0x174033f20>>
 3.1 方法裡的*view指標的地址 : 0x16fd75f10
 --------- 執行createViewWithSecRankPointer:方法結束 ---------
 
 4.0 thisIsNilView指向的例項 : <UIView: 0x15bd07ff0; frame = (100 100; 100 100); layer = <CALayer: 0x174033f20>>
 4.1 thisIsNilView指標的地址 : 0x16fd75f18
複製程式碼

很好,執行方法完畢後thisIsNilView有值了!而且還是方法裡新建立的UIView例項!

等等!好像哪裡有點不對!

為何方法裡的*view(也就是thisIsNilView指標)和方法外面的thisIsNilView不是同一個?????

根據我們上述4點嚴謹的分析,方法裡的*view應該就是thisIsNilView這個指標無誤!

在實踐結果裡,方法內部出現了一個位於0x16fd75f10記憶體地址中的指標,然後讓這個指標指向了一個新建立的UIView例項,然鵝這和thisIsNilView這個指標(位於0x16fd75f18記憶體地址)有毛線關係?????然鵝出了方法thisIsNilView居然還是指向了那個新建立的物件!!!!!

畫個記憶體圖看看先:

第一步:

我是配圖

第二步:

我是配圖

第三步:

我是配圖

第四步:

我是配圖

這裡真的有兩個很神奇的地方:

1 第二步為何會多出一個指標?

2 第四步為何會把原先指向nil的thisIsNilView指向了新建立的UIView物件?


3. 總算要說說ARC不為人知的特性了

單從上述程式碼時無法解釋為何會產生這種現象的。

在瀏覽官方文件《Transitioning to ARC Release Notes》的時候,偶然發現有這麼一段:

我是配圖

文中提到,二級指標作為引數“通常”都是__autoreleasing修飾的,注意通常這個詞,後面會提到。當實際傳入的引數為__strong修飾的時候,編譯器會建立一個用__autoreleasing修飾的臨時變數tmp,用來和方法引數的修飾符匹配,方法執行完畢後再重新用tmp賦值回error。 (蘋果這麼做主要是為了保證在方法內部建立出來的物件能夠被良好地釋放,因為createViewWithSecRankPointer:方法不能保證呼叫者在拿到這個物件後能夠合理釋放掉) 編譯器的這種行為剛好能夠印證我們上述“很神奇”的兩個地方:

1. tmp變數剛好就是第二步中多出的那個指標0x16fd75f10,用這個臨時變數來儲存新建立的UIView物件

2. error = tmp剛好對應我們的第四步,出了方法後重新賦值給原來的變數thisIsNilView

BUT:我們的方法引數並不是(UIView * __autoreleasing *)這種型別啊,我們是(UIView **)型別呢。其實蘋果文件裡說的“通常”是有依據的:

編譯器會把指向OC物件的指標的二級指標引數自動加上__autoreleasing修飾符。

我們可以通過Xcode自動補全功能一窺究竟:

我是配圖

4. 我們反過來驗證下ARC不為人知的特性

既然文件裡說了,__strong__autoreleasing語義不符,所以編譯器會這麼做,那麼如果我們使用__autoreleasing修飾了thisIsNilView指標呢。

看看修改後的程式碼:

- (void)viewDidLoad {
    [super viewDidLoad];
    
    UIView * __autoreleasing thisIsNilView = nil;
    NSLog(@"1.0 thisIsNilView指向的例項 : %@",thisIsNilView);
    NSLog(@"1.1 thisIsNilView指標的地址 : %p",&thisIsNilView);
    NSLog(@"--------- 開始執行createViewWithSecRankPointer:方法 ---------");
    [self createViewWithSecRankPointer:&thisIsNilView];
    NSLog(@"--------- 執行createViewWithSecRankPointer:方法結束 ---------");
    NSLog(@"4.0 thisIsNilView指向的例項 : %@",thisIsNilView);
    NSLog(@"4.1 thisIsNilView指標的地址 : %p",&thisIsNilView);
}

- (void)createViewWithSecRankPointer:(UIView **)view {
    NSLog(@"2.0 方法裡的*view指向的例項 : %@",*view);
    NSLog(@"2.1 方法裡的*view指標的地址 : %p",view);
    *view = [[UIView alloc] initWithFrame:CGRectMake(100, 100, 100, 100)];
    NSLog(@"3.0 方法裡的*view指向的例項 : %@",*view);
    NSLog(@"3.1 方法裡的*view指標的地址 : %p",view);
}
複製程式碼

直接看看執行結果:

 1.0 thisIsNilView指向的例項 : (null)
 1.1 thisIsNilView指標的地址 : 0x16fde9f18
 
 --------- 開始執行createViewWithSecRankPointer:方法 ---------
 2.0 方法裡的*view指向的例項 : (null)
 2.1 方法裡的*view指標的地址 : 0x16fde9f18
 3.0 方法裡的*view指向的例項 : <UIView: 0x10020c4a0; frame = (100 100; 100 100); layer = <CALayer: 0x170222740>>
 3.1 方法裡的*view指標的地址 : 0x16fde9f18
 --------- 執行createViewWithSecRankPointer:方法結束 ---------
 
 4.0 thisIsNilView指向的例項 : <UIView: 0x10020c4a0; frame = (100 100; 100 100); layer = <CALayer: 0x170222740>>
 4.1 thisIsNilView指標的地址 : 0x16fde9f18
複製程式碼

在語義相符的情況下,傳入的就是&thisIsNilView無誤,編譯器不會新增額外程式碼。

  • 補充一點:createViewWithSecRankPointer:方法就算內部不建立物件,引數也會被編譯器自動加上__autoreleasing。

總結下這篇文章講了什麼

1. 指標作為引數傳遞的時候,指標本身是值傳遞。

2. 為何用一級指標傳入引數無法成為“輸出引數”。

3. 二級指標作為引數傳遞時,ARC為了校準語義,會進行“自動補全”功能。

相關文章