block底层原理探究(一):捕获

iOS开发中block是比较常用也是比较好用的语法,平时开发中我们都用的很溜,但它的底层是如何实现的呢?__block原理是什么?__weak是如何解决循环引用问题的?

block的本质

这些问题,我们都可以通过clang命名分析代码得到答案;clang 命令可以将源码改写成C/C++的,通过C/C++ 源码可以很清楚的研究 block底层实现;
具体命令:
clang -rewrite-objc main.m
这个是最基本的命令,还可以增加参数生成具体平台,具体架构的代码,这样生成的代码量会少很多;具体的命令是:
xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m
执行命令后,目录下会生成一个同名的main.cpp文件;

OC代码:

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        int i = 0;
        void (^block)(int) = ^(int a) {
            NSLog(@"HelloWorld-%d",i);
        };
    }
    return 0;
}

生成的main.cpp文件最后面,能找到与之对应的C/C++代码:

int main(int argc, const char * argv[]) {
    /* @autoreleasepool */ { __AtAutoreleasePool __autoreleasepool; 
        int i = 0;
        void (*block)(int) = ((void (*)(int))&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, i));
    }
    return 0;
}

以此为基础可以分析出与block底层的结构为:
__main_block_impl_0结构体

struct __main_block_impl_0 {
  struct __block_impl impl;
  struct __main_block_desc_0* Desc;
  int I;
  __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int _i, int flags=0) : i(_i) {
    impl.isa = &_NSConcreteStackBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
  }
};

__main_block_impl_0有4个部分,3个成员,1个构造函数

  • __block_impl结构体
struct __block_impl {
  void *isa;
  int Flags;
  int Reserved;
  void *FuncPtr;
};

这个结构体,第一个成员就是我们很熟悉的isa指针,这就说明block本质就是OC对象;
最后一个成员FuncPtr是函数指针,这个存储的函数就是block里的代码实现:

static void __main_block_func_0(struct __main_block_impl_0 *__cself, int a) {
  int i = __cself->i; // bound by copy
  NSLog((NSString *)&__NSConstantStringImpl__var_folders__p_gphdp0fd4yv2vlq4f0y1wm500000gn_T_main_a571b5_mi_0,i);
}
  • __main_block_desc_0结构体
static struct __main_block_desc_0 {
  size_t reserved;
  size_t Block_size; // bolck的内存大小
}
  • 捕获的外部变量i
  • __main_block_impl_0 ()构造函数,初始化__block_impl,__main_block_desc_0结构体数据;

总的来说block本质就是:

  • block内部也有isa指针,它是一个OC对象
  • block是封装了函数调用以及函数调用环境的OC对象
  • block是个结构体,具体结构:

block对变量的捕获

我们主要分析block对以下4种变量的捕获表现:

  • 自动变量
  • 静态变量
  • 全局变量
  • 静态全局变量
自动变量

我们平常声明的没有关键字修饰的局部变量默认就是自动变量,只是省略了auto关键字:
上面代码中int i = 0就是自动变量等价为auto int i = 0
从上面生成的代码可以看出,自动变量i被捕获到了__main_block_impl_0结构体中,且是捕获的是值;

静态变量

现在将main函数的i变量改为静态变量:

int main(int argc, const char * argv[]) {
    @autoreleasepool {
       static int i = 0;
        void (^block)(int) = ^(int a) {
            NSLog(@"HelloWorld-%d",i);
        };
    }
    return 0;
}

重新生成C/C++代码

struct __main_block_impl_0 {
  struct __block_impl impl;
  struct __main_block_desc_0* Desc;
  int *i; // 指针 不同于自动变量的int i 
....
}

静态变量i也被捕获了,只是这次捕获的是指针;

全局变量,静态全局变量

依次改为全局变量,静态全局变量,重新生成代码;最终会发现,block并未捕获该变量;这是因为全局变量,静态全局的作用域是全局的,任何地方都能访问该变量;block无需将该变量捕获到结构体找那个也能访问;

不同类型变量的捕获情况:

变量类型 是否捕获 访问方式
自动变量 值传递
静态变量 指针传递
全局变量(静态全局) 直接访问

由于捕获(访问)的方式不一样,外部变量修改后,block内部使用的变量结果不一样:

int global_i = 0;
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        int auto_i = 0;
        static int static_i = 0;
        void (^block)(void) = ^() {
            NSLog(@"%d,%d,%d",auto_i,static_i,global_i);
        };
        auto_i = 1;
        static_i = 1;
        global_i = 1;
        block();
    }
    return 0;
}

以上代码,输出结果 0,1,1;

  • 因为auto变量是以值的方式被捕获,block将外部变量值拷贝一份存储于结构体中;当外部变量修改后,block捕获的变量不受影响;
    这和平常我们将一个变量传参给函数,函数内部更改了变量值,但外部变量不变的道理是一样的:
void change(int i) {
    i = 1;
}

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        int i = 0;
        change(i);
        NSLog(@"%d",i); // 0
    }
    return 0;
}
  • 对于静态变量,block捕获的是变量的引用*i,也就是指针传递;当外部变量的值更改后,block通过*i访问的值也就是更改后的值;
    对应函数参数的例子:
void change(int *i) {
    *i = 1;
}

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        int i = 0;
        change(&i);
        NSLog(@"%d",i); // 1
    }
    return 0;
}
  • 对于全局变量,内存在数据区,全局访问的都是同一个值;只要一个地方改了,其他使用的都会变;

有一个疑问是,block对于自动变量为什么不和静态变量一样处理,将自动变量以指针传递的方式访问呢?归根结底还是因为自动变量作用域的问题,自动变量作用域是当前函数(方法)范围内;当出了作用域后,系统会自动释放;如果block对自动变量以指针方式捕获,block内部的变量指向的内容也释放了;所以对于自动变量,一定是要值传递;

block修改捕获的变量

以上是变量在外部进行了更改,如果在block内部进行更改又是什么情况呢?
block中分别对自动变量,静态变量,全局变量进行修改:

int global_i = 0;

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        int auto_i = 0;
        static int static_i = 0;
        void (^block)(void) = ^() {
            global_i = 1;
            static_i = 1;
            auto_i = 1;
        };
    }
    return 0;
}

编译后,auto_i = 1;这行报错Variable is not assignable (missing __block type specifier)
而静态变量,全局变量均可以修改成功,具体原因同上面分析的一样:

  • 全局变量作用域广,都可以修改;
  • 静态变量通过指针的方式传递修改:
struct __main_block_impl_0 {
  struct __block_impl impl;
  struct __main_block_desc_0* Desc;
  int *i; // 指针
....
}

static void __main_block_func_0(struct __main_block_impl_0 *__cself) {
  int *i = __cself->i; // bound by copy
  (*i) = 1;
}

为了更好的理解通过指针的方式传递修改变量,这里编写了类似的代码:

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // 外部变量
        int i = 1;
        
        // 模拟block结构体__main_block_impl_0
        struct __main_block_impl_0 {
            int *I;
        }blockImp;
        
        blockImp.i = &i; // 地址
        
        // 模拟block调用函数__main_block_func_0 (不同作用域)
        {
            int *i = blockImp.i;
            (*i) = 2; // 可以更改
        }
    }
    return 0;
}
  • 自动变量报错,语法方面是没问题的,只是编译器防止我们出错给的提示;这是因为自动变量是以值传递的形式被捕获到block内部的,block内部的变量和外部变量是独立的;而且block可能会被copy到堆上,__main_block_impl_0结构体及捕获到的变量都会copy到堆上(后面会讲这部分);那么在block的auto_i = 1变量存储在堆上,而外部的自动变量auto_i是存储在栈上的,如果此时在block里更改了变量值(堆),外部变量(栈)的值是不变的;这与编程意图有悖,这个编程意图很显然是要同时改变block内外变量的值;因此block修改auto变量会得到编译错误提示;而提示也给了我们建议:使用__block声明变量;但这样更改值本身是没问题的,
    类似的以下代码并不会报错:
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // 外部变量
        int i = 1;
        
        // 模拟存储block结构体__main_block_impl_0
        struct __main_block_impl_0 {
            int I;
        }blockImp;
        
        blockImp.i = I;
        
        // 模拟block调用函数__main_block_func_0
        {
            int i = blockImp.i;
            i = 2; // (不会报错) 虽然可以更改 但这个i和外部那个i不是一个东西
        }
    }
    return 0;
}

一个很有意思的情况
如果外部的auto整型变量是一个指针,那block捕获到的也是指针,block内部能否修改呢?

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        int *i = 0;
        void (^block)(void) = ^{
            *i = 1;
        };
        block();
    }
    return 0;
}

编译通过,但*i = 1这种赋值是错误的,运行会crash;需要通过地址的方式赋值(因为是auto变量,仍会报错提示加__block):

对象类型的auto变量
对于被捕获的对象类型的auto变量,容易让一些人误解或者说费解;
比如说以下代码:

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        NSMutableArray *arr = [NSMutableArray array];
        void (^block)(void) = ^{
            [arr addObject:@(2)];
            NSLog(@"%@",arr); // 1,2
        };
        [arr addObject:@(1)];
        block();
    }
    return 0;
}

有些小伙伴就会认为block中的[arr addObject:@(2)];这句代码,修改了auto变量,编译会报错提示添加__block
但事实上是正常的,且外部修改了,blokc内部的变量值也改了;
这是因为对象类型的变量有两部分,1.指针(栈),2.指针指向的对象(堆);block捕获的是指针,而[arr addObject:@(2)]更改的指针指向的堆上的值,指针本身未改变;外部变量和block捕获的变量是两个不同的指针,但指向的是同一值;
如果我们直接修改指针,这时才会报错:

类似的,在block内部只是修改自动变量对象(self)的属性(成员变量),也是没问题的,不需要__block;

block的类型

block有三种类型,继承自NSBlock

  • __NSStackBlock__ 栈block 存储于栈区
  • __NSMallocBlock__ 堆block 存储于堆区
  • __NSGlobalBlock__ 全局block 存储于数据区(.data区)
    只要捕获了auto变量的就是NSStackBlock类型,没有捕获auto变量(捕获静态变量)的是NSGlobalBlockNSStackBlock调用copy方法就会从栈区拷贝到堆区成为NSMallocBlock类型;
block类型 环境 内存分配
NSStackBlock 没有捕获auto变量 栈区
NSMallocBlock NSStackBlock调用copy方法 堆区
NSGlobalBlock 没有捕获auto变量 数据区

需要注意的是,在ARC环境下,即使block没有捕获auto变量,block最终也会是NSMallocBlock类型;

void (^block)(int) = ^(int a) {
    NSLog(@"HelloWorld");
};
NSLog(@"%@",[block class]); // MRC: __NSStackBlock__    ARC: __NSMallocBlock__

这是因为在ARC环境下,编译器会根据情况自动将栈区block copy到堆上;如以下情形:

  • block被强引用(赋值给__strong指针时)
  • block作为函数返回值
  • block作为Cocoa API方法名中含有usingBlock的参数
  • block作为GCD方法参数时

因此ARC环境下,以下声明的block都会是NSMallocBlock类型:

@property (nonatomic, strong) void (^block)(void);
@property (nonatomic, copy) void (^block)(void);

ARC环境block为NSStackBlock的情形如下,只是这种场景极少,大部分block都会被赋值给变量;

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        int i = 0;
        NSLog(@"%@",^{NSLog(@"%d",i);});
    }
    return 0;
}

__block修改变量的原理

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        __block int i = 0;
        void (^block)(void) = ^{
            i = 1;
        };
        block();
    }
    return 0;
}

同样转换为C/C++代码,看下__block这个简单的声明到底做了什么事情;

struct __Block_byref_i_0 {
  void *__isa;
  __Block_byref_i_0 *__forwarding;
 int __flags;
 int __size;
 int I;
};

struct __main_block_impl_0 {
  struct __block_impl impl;
  struct __main_block_desc_0* Desc;
  __Block_byref_i_0 *i; // by ref
  __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, __Block_byref_i_0 *_i, int flags=0) : i(_i->__forwarding) {
    impl.isa = &_NSConcreteStackBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
  }
};

static void __main_block_func_0(struct __main_block_impl_0 *__cself) {
    __Block_byref_i_0 *i = __cself->i; // bound by ref
   (i->__forwarding->i) = 1;
}

加了__block后不同点:

  • 外部变量i不再是原本类型变量,而是被转换包装成了一个__Block_byref_i_0的结构体i;原先的变量值存储于结构体内部的同名成员i中;
  • block内部捕获__Block_byref_i_0结构体指针,访问变量的方式为引用访问bound by ref,通过结构体再访问/更改到内部的值;
  • __Block_byref_i_0结构体中有一个指向自己的__forwarding指针;

访问结构体成员值时也是通过这个__forwarding指针“迂回”的访问;
这个__forwarding指针看起来是多余的,因为上面访问值的方式完全可以直接通过结构体本身访问:

static void __main_block_func_0(struct __main_block_impl_0 *__cself) {
    __Block_byref_i_0 *i = __cself->i; // bound by ref
   i->i = 1; // 替换  (i->__forwarding->i) = 1;
}

__forwarding看起来“多此一举”,但实际上设计的很巧妙;

前面也提到过,block被copy到堆上,其捕获的变量也会copy一份到堆上;一段简单的代码可以验证:

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        int i = 0;
        NSLog(@"%p",&i);     // 0x7ffeefbff57c
        void (^block)(void) = ^{ // ARC环境,会copy
            NSLog(@"%p",&i); // 0x100410110
        };
        block();
    }
    return 0;
}
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        int i = 0;
        NSLog(@"%p",&i);     // 0x7ffeefbff57c
        ^{ // ARC环境,没有被赋值,不会copy
            NSLog(@"%p",&i); // 0x7ffeefbff578
        }();
    }
    return 0;
}

可以看出,block被copy到堆的情况,捕获的i变量的地址变小了就是被copy到堆上了;以上代码如果改为__block变量,NSLog的结果是block内外变量地址是相同的;这也也验证了我们前面所述,这也是为什么__block变量能被修改的原因;

__forwarding指针这里的作用就是针对堆的block,原本栈区指向自己,之后指向被copy到堆的__block。然后堆上的变量的__forwarding再指向自己。这样不管__block变量是copy到堆上,还是在栈上,都可以通过(i->__forwarding->i)来访问到变量值。

以上就是__block变量能被修改的原因,简单总结就是两点:

  • __block变量,最终包装成了一个结构体。block捕获的是这个结构体指针;保证了block内外变量的一致性;
  • __forwarding指针解决不同存储段(堆,栈)变量的访问;

推荐阅读更多精彩内容