二级指针与ARC不为人知的特性

  • 戴奕
  • 22 Minutes
  • January 7, 2017

先看一眼熟知的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
- (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了吗?

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- (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
2
3
4
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实例。

我改动了下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- (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
2
3
4
5
6
7
8
9
10
11
12
13
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);

函数:

1
2
3
4
5
6
7
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是吗?

是不是我们先上个代码看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- (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
2
3
4
5
6
7
8
9
10
11
12
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指针呢。

看看修改后的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- (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
2
3
4
5
6
7
8
9
10
11
12
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无误,编译器不会添加额外代码。

总结下这篇文章讲了什么

1. 指针作为参数传递的时候,指针本身是值传递。

2. 为何用一级指针传入参数无法成为“输出参数”。

3. 二级指针作为参数传递时,ARC为了校准语义,会进行“自动补全”功能。