了解objc中块内存管理的一个边缘情况

由于EXC_BAD_ACCESS下面的代码将崩溃

 typedef void(^myBlock)(void); - (void)viewDidLoad { [super viewDidLoad]; NSArray *tmp = [self getBlockArray]; myBlock block = tmp[0]; block(); } - (id)getBlockArray { int val = 10; //crash version return [[NSArray alloc] initWithObjects: ^{NSLog(@"blk0:%d", val);}, ^{NSLog(@"blk1:%d", val);}, nil]; //won't crash version // return @[^{NSLog(@"block0: %d", val);}, ^{NSLog(@"block1: %d", val);}]; } 

代码在启用了ARC的iOS 9中运行。 我试图找出导致崩溃的原因。

通过po tmp in lldb我找到了

 (lldb) po tmp ( ,  ) 

而在不会崩溃的版本

 (lldb) po tmp ( ,  ) 

因此,我想出的最可能的原因是当ARC发布NSStackBlock时崩溃发生。 但为什么会这样呢?

首先,您需要了解如果要将块存储在声明它的范围之外,则需要复制它并存储副本。

其原因在于优化,其中捕获变量的块最初位于堆栈上,而不是像常规对象那样动态分配。 (让我们忽略暂时不捕获变量的块,因为它们可以实现为全局实例。)所以当你编写块文字时,比如foo = ^{ ...}; ,这有点像向foo分配指向同一范围内声明的隐藏局部变量的指针,类似于some_block_object_t hiddenVariable; foo = &hiddenVariable; some_block_object_t hiddenVariable; foo = &hiddenVariable; 在同步使用块的情况下,此优化减少了对象分配的数量,并且永远不会超出创建它的范围。

就像指向局部变量的指针一样,如果你将指针指向它所指向的东西的范围之外,你有一个悬空指针,并且取消引用它会导致未定义的行为。 如果需要,在块上执行复制会将堆栈移动到堆,其中它像所有其他Objective-C对象一样进行内存管理,并返回指向堆副本的指针(如果块已经是堆块或全局块) ,它只返回相同的指针)。

特定编译器是否在特定情况下使用此优化是一个实现细节,但您不能假设它是如何实现的,因此如果将块指针存储在一个比当前范围更长的位置,则必须始终复制(例如,在实例或全局变量中,或在可能超出范围的数据结构中)。 即使您知道它是如何实现的,并且知道在特定情况下不需要复制(例如,它是一个不捕获变量的块,或者必须已经完成复制),您不应该依赖它,并且当你将它存放在一个比当前范围更长的地方时,你应该总是复制,这是一种很好的做法。

将块作为参数传递给函数或方法有点复杂。 如果将块指针作为参数传递给声明的编译时类型是块指针类型的函数参数,那么如果该函数的范围超出其范围,则该函数将负责复制它。 因此,在这种情况下,您无需担心复制它,而无需知道函数的function。

另一方面,如果将块指针作为参数传递给声明编译时类型为非块对象指针类型的函数参数,则该函数不会对任何块复制负责,因为所有它知道它只是一个常规对象,如果存储在比当前范围更长的地方,则需要保留。 在这种情况下,如果您认为函数可能存储超出调用结束的值,则应在传递之前复制该块,然后传递该副本。

顺便说一下,对于块指针类型被分配或转换为常规对象指针类型的任何其他情况也是如此; 应该复制块并分配副本,因为任何获得常规对象指针值的人都不会进行任何块复制注意事项。


ARC使情况有些复杂化。 ARC规范指定了隐式复制块的一些情况。 例如,当存储到编译时块指针类型的变量(或ARC要求保留编译时块指针类型的值的任何其他位置)时,ARC要求复制传入值而不是保留,因此程序员不必担心在这些情况下显式复制块。

除了作为初始化__strong参数变量或读取__weak变量的一部分完成的保留外,每当这些语义调用保留块指针类型的值时,它都具有Block_copy的效果。

但是,作为例外,ARC规范不保证仅在复制参数时传递块。

当优化器看到结果仅用作调用的参数时,可以删除这些副本。

因此,是否要将作为参数传递的块显式复制到函数中仍然是程序员必须考虑的事情。

现在,Apple最新版本的Clang编译器中的ARC实现具有一个未记录的function,它将隐式块副本添加到作为参数传递块的某些位置,即使ARC规范不需要它。 (“未记录”,因为我无法找到任何Clang文档。)特别是,当将块指针类型的表达式传递给非块对象指针类型的参数时,它似乎总是在防御性地添加隐式副本。 事实上,正如CRD所certificate的,当从块指针类型转换为常规对象指针类型时,它还会添加隐式副本,因此这是更一般的行为(因为它包含参数传递大小写)。

但是,当将块指针类型的值作为varargs传递时,看起来当前版本的Clang编译器不会添加隐式副本。 C varargs不是类型安全的,调用者不可能知道函数期望的类型。 可以说,如果Apple想要在安全方面出错,因为无法知道函数的期望,他们也应该在这种情况下添加隐式副本。 但是,既然这件事都是无证件的,我不会说这是一个错误。 在我看来,程序员不应该依赖于仅作为参数传递的块,而这些块首先被隐式复制。

简答

您发现了编译器错误,可能是重新引入的错误,您应该在http://bugreport.apple.com上报告。

更长的答案

这并不总是一个错误,它曾经是一个function ;-)当Apple首次引入块时,他们还引入了优化它们如何实现它们; 然而,与对代码基本透明的普通编译器优化不同,它们要求程序员在各个地方调用特殊函数block_copy() ,以使优化工作。

多年来,Apple取消了对此的需求,但仅限于使用ARC的程序员(尽管他们也可以为MRC用户这样做),而今天优化应该就是这样,程序员不再需要帮助编译器。

但是你刚刚发现编译器错误的情况。

从技术上讲,你有一个类型丢失的情况,在这种情况下,已知为块的东西作为id传递 – 减少已知的类型信息,特别是涉及变量参数列表中的第二个或后续参数的类型丢失。 当您使用po tmp查看数组时,您会看到第一个值是正确的,尽管存在类型丢失,但编译器会正确地获取该数组,但它在下一个参数上失败。

数组的文字语法不依赖于可变参数函数,并且生成的代码是正确的。 但是initWithObjects:确实存在,而且它出错了。

解决方法

如果向第二个(以及任何后续)块添加转换为id ,则编译器会生成正确的代码:

 return [[NSArray alloc] initWithObjects: ^{NSLog(@"blk0:%d", val);}, (id)^{NSLog(@"blk1:%d", val);}, nil]; 

这似乎足以唤醒编译器。

HTH