为什么运行时 nil / NULL 块会导致总线错误?

74

我开始大量使用块并很快发现,nil 块会导致总线错误:

typedef void (^SimpleBlock)(void);
SimpleBlock aBlock = nil;
aBlock(); // bus error

这似乎与Objective-C通常忽略对空对象发送消息的行为相违背:

NSArray *foo = nil;
NSLog(@"%i", [foo count]); // runs fine
因此在我使用块之前,必须进行通常的nil检查:
if (aBlock != nil)
    aBlock();

或者使用虚拟块:

aBlock = ^{};
aBlock(); // runs fine

还有其他选项吗?nil块为什么不能简单地成为nop(无操作)?

4个回答

150

我希望能够更详细地解释一下,给出一个更完整的答案。首先,让我们考虑这段代码:

#import <Foundation/Foundation.h>
int main(int argc, char *argv[]) {    
    void (^block)() = nil;
    block();
}

如果你运行这个程序,你会在block()这一行看到一个崩溃,类似于以下内容(在32位架构上运行-这很重要):

EXC_BAD_ACCESS(code=2,address=0xc)

那么,为什么会这样呢?好吧,0xc是最重要的位。崩溃意味着处理器尝试读取内存地址0xc处的信息。这几乎肯定是完全不正确的事情。那里可能没有任何东西。但是它为什么要尝试读取这个内存位置呢?嗯,这归功于块实际上在底层是如何构建的。

当定义一个块时,编译器实际上会在栈上创建一个结构,形式如下:

struct Block_layout {
    void *isa;
    int flags;
    int reserved;
    void (*invoke)(void *, ...);
    struct Block_descriptor *descriptor;
    /* Imported variables. */
};

然后,该块是指向此结构的指针。这个结构的第四个成员invoke是有趣的部分。它是一个函数指针,指向块实现所在的代码。因此,处理器在调用块时尝试跳转到那段代码。请注意,如果您数一下invoke成员之前结构中的字节数,您会发现在十进制中有12个字节,在十六进制中为C。

因此,当调用块时,处理器取块的地址,加上12,并尝试加载该内存地址中保存的值。然后它尝试跳转到该地址。但是,如果块为nil,则尝试读取地址0xc。显然这是一个错误的地址,所以我们得到了段错误。

现在,它必须像这样崩溃,而不是像Objective-C消息调用那样默默地失败,实际上是一种设计选择。由于编译器正在决定如何调用块,因此它必须在每次调用块时注入nil检查代码。这将增加代码大小并导致性能下降。另一种选择是使用一个跳板来进行nil检查。但是,这也会造成性能损失。Objective-C消息已经通过跳板进行了处理,因为它们需要查找将实际被调用的方法。运行时允许懒惰注入方法和改变方法实现,因此已经通过跳板进行处理了。在这种情况下执行nil检查的额外开销并不重要。

有关更多信息,请参见我的博客文章


40

Matt Galloway的回答非常完美!很好的阅读材料!

我只想补充一点,有一些方法可以让生活更轻松。您可以定义一个类似于以下内容的宏:

#define BLOCK_SAFE_RUN(block, ...) block ? block(__VA_ARGS__) : nil

这个函数可以接受0到n个参数,以下是一个使用示例:

typedef void (^SimpleBlock)(void);
SimpleBlock simpleNilBlock = nil;
SimpleBlock simpleLogBlock = ^{ NSLog(@"working"); };
BLOCK_SAFE_RUN(simpleNilBlock);
BLOCK_SAFE_RUN(simpleLogBlock);

typedef void (^BlockWithArguments)(BOOL arg1, NSString *arg2);
BlockWithArguments argumentsNilBlock = nil;
BlockWithArguments argumentsLogBlock = ^(BOOL arg1, NSString *arg2) { NSLog(@"%@", arg2); };
BLOCK_SAFE_RUN(argumentsNilBlock, YES, @"ok");
BLOCK_SAFE_RUN(argumentsLogBlock, YES, @"ok");

如果你想获取块的返回值,并且不确定该块是否存在,那么最好直接输入以下内容:

block ? block() : nil;

通过这种方式,您可以轻松定义回退值。在我的例子中是“nil”。


1
VA_ARGS 在 .mm 文件中会出现问题。 - BergP

9

注意:我不是Blocks方面的专家。

Blocks虽然是Objective-C对象,但调用block不是消息,尽管您仍可以尝试[block retain]一个nil块或其他消息。

希望这些说明(和链接)能够帮助您。


谢谢,有趣的链接。我知道调用块不同于发送消息,但从概念上讲,如果空块像空对象一样宽容就好了。 - zoul
我考虑过“nilBlock”方法,但不幸的是类型会成为阻碍——为每种块类型创建单独的nil值并不好玩。 - zoul
我不知道是否可以对块进行子类化,但添加一个执行内部检查的消息 [block call] 可能会有所帮助。不确定块与 ObjC 对象有多接近。 - Stephen Furlani
这是另一个好主意,但我担心一旦你想传递一些原始类型的参数,它会变得棘手。 - zoul
3
我通常只是用 block ? block() : nil; 这样简洁的方式,这对我来说已经足够了,并且在你所做的事情方面是透明的。 - Patrick Pijnappel
显示剩余2条评论

2
这是我的简单而又优美的解决方案...也许可以编写一个含有这些c可变参数的通用运行函数,但我不知道如何编写。
void run(void (^block)()) {
    if (block)block();
}

void runWith(void (^block)(id), id value) {
    if (block)block(value);
}

这样写更好吗?block ? block() : nil; - Tibin Thomas

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接