从运行时看Block——披着函数外衣的结构体

Posted ZeroOnet

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了从运行时看Block——披着函数外衣的结构体相关的知识,希望对你有一定的参考价值。

前言

在我们的程序代码中,block的使用不可谓不多,比如UIView的动画块、AFNetworking的网络请求的失败与成功的回调、反向传值、Masonry的实现与使用,乃至尽可能的封装系统API并使用block来完成调用的BlocksKit(透露了一些关于block本质的信息)。诚然,文章不是用来介绍它的使用是多么简单、灵活、广泛的。

有人说程序员有三种境界:会用API、了解API的实现、知悉API的设计原因。笔者刚走在第二步的路上,境界三还有比较远的距离。不过在文章中会有一些猜测式的观点,如有失偏颇,还望斧正!

“那么接下来,就让我们一起走进block,了解它背后的故事!”

block:我是一个来自Cstruct

关于block,我们就书写的代码层面用到了三种形式:是否捕获外部变量、捕获的外部变量是否在block内被修改。它们实际上也依次对应了不同的block类型,文章这里就不在说明了。这一小节我们先从运行时源码来看block的数据结构,下一节以实际的代码通过clang -rewrite-objc编译指令将其编译成C++代码来验证。

// 源码目录:objc/include/Block_private.h
struct Block_layout 
    // 表明block的类型,泛型指针
    void *isa;
    // 包含的引用数量
    volatile int32_t flags; // contains ref count
    // 保留字段
    int32_t reserved;
    // **block实现的函数指针地址**
    void (*invoke)(void *, ...);
    // 共有三种block的描述体
    // 这里的问题是为什么block的默认描述体是类型1?
    struct Block_descriptor_1 *descriptor;
    // imported variables
    // 将结构体需要访问的外部变量复制到结构体中,即捕获到的变量
;

block的三种描述体类型声明如下:

struct Block_descriptor_1 
    // 保留字段
    uintptr_t reserved;
    // block的空间大小
    uintptr_t size;
;

struct Block_descriptor_2 
    // requires BLOCK_HAS_COPY_DISPOSE
    // 由命名可以看出:第一参数是复制的目的地,第二个参数是复制源
    void (*copy)(void *dst, const void *src);
    // 释放捕获的外部变量
    void (*dispose)(const void *);
;

struct Block_descriptor_3 
    // requires BLOCK_HAS_SIGNATURE
    const char *signature;
    const char *layout;     // contents depend on BLOCK_HAS_EXTENDED_LAYOUT
;

以上是我们能够在职能上较为明确的从运行时源码分辨出关于block的描述信息,具体的匹配关系并不能做出判断。因此,我们需要实际写出不同类型的block来做出更加明确的分析。

block:其实,我没穿多少衣服

// 没有捕获外部变量的匿名block
^
    printf("test 2");
();

// 使用上述提供的编译指令执行后,文件目录中会多出一个C++文件,关于上面的这个block,会有很多中间体代码产生

// 这个block实现类型的声明数量会随着block的个数增加而增加,其中关于block的描述体也是如此
struct __main_block_impl_2 
    // 这是实现体的声明,任何类型的block都会有这样的一个成员变量
    struct __block_impl impl;
    // 描述体成员变量类型会根据block具体类型变动
    struct __main_block_desc_2* Desc;
    // 构造函数
    __main_block_impl_2(void *fp, struct __main_block_desc_2 *desc, int flags=0) 
        // 这里的类型与OC中不是一一对应的,就笔者写出的在OC中明确是三种类型block,这里的isa值都是&_NSConcreteStackBlock。在OC中,这里应当是全局`block`,存储在全局区/静态区,堆和栈都可以使用,在MRC和ARC下均可正常工作。
        impl.isa = &_NSConcreteStackBlock;
        impl.Flags = flags;
        impl.FuncPtr = fp;
        Desc = desc;
    
;

// block实现类型
struct __block_impl 
    // 可以理解的是block的类型判定是以其具体实现为依据
    void *isa;
    // 分别对应于运行时Block_layout的各个成员变量
    int Flags;
    int Reserved;
    void *FuncPtr;
;

在实际的block声明和调用处,你会发现它被换成了如下的一段代码:

((void (*)())&__main_block_impl_2((void *)__main_block_func_2, &__main_block_desc_2_DATA))();

这里面还有两个类型的声明是在编译时生成的:

// block的具体实现兑现为一个静态的函数,这里的__cself就相当于方法中的self,从命名上揣测一下:callerSelf(调用者自身)
static void __main_block_func_2(struct __main_block_impl_2 *__cself) 
    printf("test 2");


// block的描述体
static struct __main_block_desc_2 
    size_t reserved;
    size_t Block_size;
 __main_block_desc_2_DATA =  0, sizeof(struct __main_block_impl_2);

从结果上来看,转换后的代码其实是编译通不过的,因为它企图将结构体地址强制转换为函数指针然后调用它。不过在这里它不是我们关注的焦点,且也无法去思考编译器的转换原理,我们能够大致理解这样的一个表现形式就可以了。

下面看看捕获外部变量且可以通过block名称调用的实现过程:

int i = 0;

void (^test)() = ^ 
    printf("i = %d", i);
;

test();

// 转换结果
int i = 0;

// 需要注意的是i为值传递,同样这句代码编译也是会报错的
void (*test)() = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, i));

// 这句代码是可以编译通过的
((void (*)(__block_impl *))((__block_impl *)test)->FuncPtr)((__block_impl *)test);

同样,这里贴出中间体的声明:

struct __main_block_impl_0 
    // 以下两者和匿名且未捕获外部变量的block实现一致
    struct __block_impl impl;
    struct __main_block_desc_0* Desc;
    // 捕获的变量直接成为block实现体的成员变量
    int i;
    __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int _i, int flags=0) : i(_i)  // 使用C++中的参数初始化列表对i进行赋值
        impl.isa = &_NSConcreteStackBlock;
        impl.Flags = flags;
        impl.FuncPtr = fp;
        Desc = desc;
  
;

// block的具体实现
static void __main_block_func_0(struct __main_block_impl_0 *__cself) 
    // 传入__cself就是为了能够在函数实现内部访问捕获的外部变量
    int i = __cself->i; // bound by copy

    printf("i = %d", i);

看到这里,相信你最关心的问题是被__block修饰的变量在block内部会有怎样的改变与调整。根据上面捕获变量的简单分析,可以猜测:block的实现结构体将增加捕获变量的引用或者指针,从而达到在内部修改的目的。

测试一下:

__block int j = 0;

void (^test1)() = ^ 
    j = 12;
;

test1();

// 转换结果
// __attribute__((__blocks__(byref))) 修饰的作用笔者不太理解
__attribute__((__blocks__(byref))) __Block_byref_j_0 j = (void*)0,(__Block_byref_j_0 *)&j, 0, sizeof(__Block_byref_j_0), 0;

void (*test1)() = ((void (*)())&__main_block_impl_1((void *)__main_block_func_1, 
&__main_block_desc_1_DATA, 
(__Block_byref_j_0 *)&j, 
570425344));

((void (*)(__block_impl *))((__block_impl *)test1)->FuncPtr)((__block_impl *)test1);

事实上,并非我们想象的那般简单,这里面又生成了一个新的数据结构:

struct __Block_byref_j_0 
    void *__isa;
    __Block_byref_j_0 *__forwarding;
    int __flags;
    int __size;
    int j;
;

接着我们从它的构造上来试着理解前两个成员变量的含义:

__isa = (void *)0 (0x0):猜测为保留字段,在测试捕获实际对象时,赋值情况相同;

__forwarding = (__Block_byref_j_0 *)&j:存放的是变量本身的地址。

__main_block_impl_1的声明为:

struct __main_block_impl_1 
    struct __block_impl impl;
    struct __main_block_desc_1* Desc;
    __Block_byref_j_0 *j; // by ref
    __main_block_impl_1(void *fp, struct __main_block_desc_1 *desc, __Block_byref_j_0 *_j, int flags=0) : j(_j->__forwarding) 
        impl.isa = &_NSConcreteStackBlock;
        impl.Flags = flags;
        impl.FuncPtr = fp;
        Desc = desc;
    
;

所以内部修改捕获变量就可以这样表示:

static void __main_block_func_1(struct __main_block_impl_1 *__cself) 
    __Block_byref_j_0 *j = __cself->j; // bound by ref

    (j->__forwarding->j) = 12;

这里之所以不是直接捕获变量的引用而是利用一个中间的数据结构来间接修改是为了能够满足更通常的情况,这里的纯量性数值不会涉及到内存管理问题。而在实际的使用场景的场景中,就会考虑到如何将对象拷贝到block中,使用完如何回收其内存等问题。为了能够更好的满足单一职能设计原则,对于Apple这样的设计原因我们在某种程度上能够理解。以下是在block内部将可变字符串置为nil的转化后的实际代码:

struct __Block_byref_str_1 
    void *__isa;
    __Block_byref_str_1 *__forwarding;
    int __flags;
    int __size;
    // 对象的拷贝
    void (*__Block_byref_id_object_copy)(void*, void*);
    // 对象的内存释放
    void (*__Block_byref_id_object_dispose)(void*);
    NSMutableString *str;
;

// 此时就可以理解为什么是说__block的作用是将栈中对象的指针拷贝到堆中
__attribute__((__blocks__(byref))) __Block_byref_str_1 str = (void*)0,
(__Block_byref_str_1 *)&str, 
33554432, 
sizeof(__Block_byref_str_1), 
__Block_byref_id_object_copy_131, 
__Block_byref_id_object_dispose_131, 
((NSMutableString *(*)(id, SEL, NSString *))(void *)objc_msgSend)((id)objc_getClass("NSMutableString"), 
sel_registerName("stringWithString:"), 
(NSString *)&__NSConstantStringImpl__var_folders_10_86_rr_q12pd36tb1pkm6sw180000gn_T_main_7a902c_mi_0);

总结

方法的调用和使用block是我们在开发中会遇到的一个选择节点,特别是在使用代理时。通过上面的一些较为浅显的剖析,可以看到就算是使用一个简单的block,都会有一系列的中间变量生成。特别地,block中涉及到对象的修改时,执行block的过程中会有更多的消耗。所以,我们应当思考一个问题:block与方法的使用性能孰优孰劣?

在阅读源码的过程中,理解实现机制是一方面,而其中引发的思考,或关乎与设计原因,或是性能上的疑虑(纵使可能是瞎操心)应当成为我们的关注焦点。毕竟我们不能因为阅读源码而阅读,也并非是为了应付一些面向于底层实现的面试问题,毕竟思考本身才是最有价值的……

————————————–我是一条华丽的分隔线—————————————

事实上也确实证明我是瞎操心,我们都知道的是方法的调用首先会到类的方法缓存中去查询,如果没有命中则开始寻找方法最初的实现,命中之后又需要将其实现加入到方法缓存中(这里先忽略方法解析和消息转发这两个过程),然后开始执行调用。而block的操作实现直接通过函数指针发起调用,性能上反而可能会优于方法(测试用例有限,不能做出绝对的判定),这或许也是Apple推崇使用block的原因。

以下示例,尝试分别通过方法和block的调用向数组里面添加元素,添加次数依次为100、1000、10000、100000:

#import "Test.h"

@implementation Test

- (void)testMethod 
    [self.array removeAllObjects];

    NSLog(@"--------method start----------");

    for (int i = 0; i < 100000; i ++) 
        [self test];
    

    NSLog(@"----------method end-----------");

    NSLog(@"count:%zd", self.array.count);


- (void)test 
    [self.array addObject:[NSObject new]];


- (void)testBlock 
    [self.array removeAllObjects];

    NSLog(@"============block start===========");

    for (int i = 0; i < 100000; i ++) 
        self.block();
    

    NSLog(@"============block end=============");

    NSLog(@"count:%zd", self.array.count);


- (void (^)())block 
    if (!_block) 

        __weak typeof(self) weakSelf = self;
        _block = ^ 
            __strong typeof(weakSelf) strongSelf = weakSelf;

            [strongSelf.array addObject:[NSObject new]];
        ;
    

    return _block;


- (NSMutableArray *)array 
    if (!_array) 
        _array = [NSMutableArray array];
    

    return _array;


@end

你会发现一个现象是:数据量很小时,block的性能表现会优于(虽然只是量级较小)方法;当受到大数据量的冲击时,比如这里的100000,方法又略胜一筹,这还是建立在block实例是懒加载的前提之上。如果此时block是每次调用都需要重新创建实例的话,方法的优势会很明显。所以在我们需要实现表视图的cell反向传值时,使用的是delegate而不是block

以上是关于从运行时看Block——披着函数外衣的结构体的主要内容,如果未能解决你的问题,请参考以下文章

车威Buzz | 年内即将国产的三菱Eclipse Cross,会是披着欧蓝德外衣的EVO吗?

暑假爆零欢乐赛SRM08题解

block原理

格而知之15:我所理解的Block

VFS,super_block,inode,dentry—结构体图解

Objective-C 基础之— Block本质+源码剖析