一次二级指针引发的血案

Posted by wbq on October 30, 2020

原因

最近开发过程中因为自己的错误操作导致了一个很傻问题。感觉是一个基于内存管理,Block,指针的综合问题。整个理解下来,对上述概念有些了新的认识,觉得值得记录一次。首先非常鸣谢一道题考你对__autoreleasing和__block的理解的作者,本次记录的问题,基本也是基于作者提供的理解思路。

提问

我把日常的业务稍微精简了成了以下代码:

“网络请求,把error作为二级指针传入ViewModel的网络请求方法中,异步回写二级指针的值,最后回调打印error”

流程看似很简单,也很愉快。但其实下面的代码是最后的error是打印是”NULL”。

@interface ViewModel : NSObject
@end
@implementation ViewModel

- (void)getNetWorkWithError:(NSError **)error completeBlock:(void(^)(void))block {
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        *error = [NSError errorWithDomain:@"domain" code:1 userInfo:nil];
        block();
    });
}

@end

void netWork() {
    NSLog(@"Hello, World!");
    NSError *error;
    ViewModel *viewModel = [ViewModel new];
    [viewModel getNetWorkWithError:&error completeBlock:^(){
        NSLog(@"%@",error);
    }];
}


int main(int argc, const char * argv[]) {
    @autoreleasepool {
        netWork();
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop run];
    }
    return 0;
}

怎么改

先说答案,有两个思路

1.第一种,error的修饰符直接改成static

NSError *error; 改成static NSError *error;

2.第二种,改两处,NSError *error__block修饰,block必须先创建再传入参数。

``` 12NSLog(@”Hello, World!”); __block NSError *error;

ViewModel *viewModel = [ViewModel new];

void(^bb)(void) = ^{

1
		NSLog(@"%@",error);

}; [viewModel getNetWorkWithError:&error completeBlock:bb]; }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19


但是不管以上哪两种方法`viewModel`的对象方法必须改成`- (void)getNetWorkWithError:(NSError * __strong *)error completeBlock:(void(^)(void))block`。



这样就可以正确打印我们想要的效果。



## 原因

1.首先第一种思路,原因不必多说了,将error提升到全部静态变量,在代码的任何地方都能轻易地访问到本尊,也就是没有我接下去讲的那么多破事了(block不capture静态变量),所以一句话带过,要是你对代码有洁癖,不想随意开辟全局空间,那就看看第二种思路。



2.首先,编译器会把``__block`` 修饰``error``对象变成一个结构体,而对于第二种写法为什么要先申明好`block`,再作为参数塞入呢,主要是因为`` void(^bb)(void) = ^{NSLog(@"%@",error);};``,`arc`环境下基本没有`stackBlock`,看到的`block`基本都是`mallocBlock`或者`globalBlock`。所以这句话因为`block`通过`copy` 捕获到的`error`上升到堆空间,所以如果开始这么写的显然是不对的。

[viewModel getNetWorkWithError:&error completeBlock:^(){ NSLog(@”%@”,error); }];

1
2
3
4
5


通过clang转换cpp可得知:

((void ()(id, SEL, NSError **, void ()()))(void )objc_msgSend)((id)viewModel, sel_registerName(“getNetWorkWithError:completeBlock:”), &(error.__forwarding->error), ((void ()())&__netWork_block_impl_0((void *)__netWork_block_func_0, &__netWork_block_desc_0_DATA, (__Block_byref_error_0 *)&error, 570425344)));

1
2
3
4
5
6
7
8
9
10
11
12
13
14
首个参数(error的二级指针),入参时还是栈空间地址。执行到下方`block`参数时,才上升到堆空间。这么写,两个error的地址其实不是一个,所以在这里对调用的时机有了更严格的要求。



3.关于二级指针为什么默认是``__autorelease``对象,然后这里必须改成 `__strong`修饰,原因我用自己的话总结了一下,

首先第一点,我们先搞清楚,为什么在这种需求下,必须这么写。因为用了``__autorelease``修饰的外部参数,是用到了一个传递回写的机制([pass-by-writeback](https://clang.llvm.org/docs/AutomaticReferenceCounting.html#arc-ownership-restrictions-pass-by-writeback) 官方clang文档 4.3.3明确有提到), 类似于这样(引用自[iOS开发Tips:objective-c指针解引用](https://www.jianshu.com/p/1dc7c31fa06f)):

```ruby
__strong NSObject *o;
__autoreleasing NSObject *temp = o;  
[self method2:&temp]; 
o = temp;

编译器帮我生成一个临时的变量传入方法,最后回写到我们原来的对象上。

这就是解释了,为什么采用默认写法会有问题:

1
2
3
4
- (void)getNetWorkWithError:(NSError **)error completeBlock:(void(^)(void))block {
		NSLog(@"error is %p", &*error);
		//...延迟操作error...
}

在方法内部和方法外部打印一下指针的地址,就一目了然了,不是一个对象指针对象。

因此默认写法肯定就没用了。

1
2
3
4
__strong NSObject *o;
__autoreleasing NSObject *temp = o;  
[self method2:&temp]; //temp在方法内部延迟操作。
o = temp;//在这里赋值时temp还是nil,导致o始终是nil

所以先说结论,改成_strong修饰二级指针之后,编译器不会再出现回写操作,我特意打了地址试了一下。

到此,我们可以知道,error__block修饰以及被copy到堆上,禁掉回写逻辑,从始至终都只有一个指针对象在传来传去,所以这段逻辑到此就没什么问题了。

后续

现在可以来讨论一下__autorelease了,苹果为什么要这么设计。一道题考你对__autoreleasing和__block的理解 clang原文其实已经讲得非常清楚了,但是第一次读还是觉得有点绕,用自己的话解释一下。

当方法有返回参数的时候,默认都是__autorelease的,这个很好理解,尤其是在mrc环境下当你return一个对象 给外部调用时,默认应该是autorelease状态的,当你返回出去的时候retainCount是 + 1 ,+ 2 ,+3 …那你让谁来处理呢?这显然是不合理的。

所以,二级指针也是调用方法返回参数的其中一种方法,同样应该保持同样的性质。

而同在ARC和MRC两种环境下用这种方式互相调用的,苹果也有进一步的优化了。

2205796-ae292967248756e9.png

我觉得这张图非常好,最近刚看到,绝对过目不忘。

鸣谢:

黑幕背后的Autorelease

一道题考你对__autoreleasing和__block的理解

clang原文