记录对Block的结构探究(不涉及libclosure)
Block在Objective-C中的定义与实现
Block,即闭包。在各种语言中或多或少都有着闭包的概念,在swift中Block即swift的closure。实际上闭包是一个定义相对简单的概念,我们可以把它理解为一段延后执行的代码块或一个匿名的函数,它的作用是保存了一个由多段代码组合而成的代码块并在之后以供调用。而在Objective-C中,Block的实现实际上也是一个对象(Block的结构中也存在一个ISA指针,在OC中具有ISA指针的结构体可以在广义上把它们划分为对象)。既然Block的底层实现是对象,那么必然会涉及到OC中的内存管理,因此OC中Block的实现和底层原理相对于swift中的closure也会更加复杂,值得写一篇记录来总结一下。
Block定义
- 首先强调一点,Block在Objective-C中的底层实现是一个对象,也就是说它也有一个ISA指针。
首先在代码中定义两个block,然后将他们转成cpp代码。
定义block的语法是 返回值(^your_block_name)(argus) = ^{};
可以看到我们在这里定义了两个block,分别是myTestBlock1
和myTestBlock2
。通过Clang将他们转成cpp代码后,代码被转换成了这样。
struct __main_block_impl_0
在上图转换后的代码中,可以发现一个
__main_block_impl_0
结构,和一个__main_block_impl_1
结构,我们将代码往上拉,可以发现这两个结构的定义。
可以看到这两个结构非常类似,他们共同拥有一个struct __block_impl
类型的属性impl
,区别的地方是struct __main_block_desc_1
和struct __main_block_desc_0
,从命名可以看出他们的区别并不大,实际上这个类的命名是在编译时根据block定义的顺序来命名的。
最后就是一个struct __main_block_impl_0/1
结构体本身的构造函数,也就是说struct __main_block_impl_0/1
在目前只包含了三个部分的内容。
在构造函数中,可以看到impl.isa = &_NSConcreteStackBlock
这一行代码,可以知道block在定义时是默认定义为stack block,在后期如果有需要才copy成heap block。
同时,我们也注意到impl.FuncPtr = fp
这行代码,fp
是一个void*类型的指针,这行代码将该block需要执行的函数指针赋给了impl.FuncPtr
,也就是说在block执行时是通过该函数指针来调用起需要执行的代码块。
struct __main_block_desc_x
我们先研究不相同的地方,看一看
struct __main_block_desc_x
的结构以及它的作用。
可以看到,这个结构体保存了block的信息,其中第一个值为reserved,即待用值。
第二个值为Block_size
,即block的大小。 可以看到在下面的构造函数中,reserved
被默认置为0,Block_size
被置为sizeof(struct __main_block_impl_0)
struct __block_impl
接着我们来看两个block中都共同拥有的值
struct __block_impl
可以看到,这个就是所有block的基本结构,在每个自定义的block对象中都有一个struct __block_impl
类型的值。
在struct __block_impl
中,有四部分的内容
- isa:isa指向说明了block在内存中的存放位置,即区别栈/堆/全局block
- Flags:标示位,在初始化时被默认置为0
- Reserved:留存位,留待备用
- FuncPtr:函数指针,在初始化时block要执行的代码块的指针被赋予
FuncPtr
以上就是block在OC中的定义,当然如果查看libclosure库,可以看到block的更底层的定义和实现。
Block分类
在开发中,我们常听到block根据它在内存中的分布被分为栈区block、堆区block、全局block。
- NSStackBlock:栈区block,实际上该类block已经很少见了。block在编译时都会被指向成栈区block,但如果没有捕获
__block
修饰的变量,在运行后栈区block基本都被转换成了全局block。目前在Xcode12的测试环境中,只有在例如NSlog函数中直接定义一个匿名的block作为参数,这种情况的block才是栈区block - NSMallocBlock:堆区block,在block中引用了局部变量或被__block修饰的变量,或者对block调用了
block_copy
,此时block会被拷贝到堆上,成为堆区block - NSGlobalBlock:全局block,block内部不引用变量或只引用了全局变量或静态变量
现在将之前定义的block改写一下,并新增一个引用了被__block
修饰变量的block。
进入lldb,可以看到他们的类型分别为
可以看到,引用了局部变量a的block1变成了堆block,引用了全局变量globalA的block2成了全局block,引用了被__block
修饰变量的block3成了堆block。
捕获变量后的block
接着来分别看看捕获变量后的block的底层机构变成了什么,依旧是使用clang将代码转换为cpp代码
可以看到,被__block
修饰的blockedA
变量被封装成了__Block_byref_blockedA_0
类型,先来看看这个是什么。
在struct __Block_byref_blockedA_0
中,有这么几个值
- __isa:该值在初始化时被置为0的指针
- __forwarding: 在初始化时,被
__block
修饰的变量的地址被强转成__Block_byref_blockedA_0
,赋给了__forwarding
,通过在此处by ref的注释我们知道,block在捕获该变量时是通过捕获地址的方式,所以才可以在block内部修改它的内容 - __flags:标示位
- __size:占用大小
- blockedA: 存放被修饰的变量的真实的值,在这里为2
NSMallocBlock
首先看一下它的构造函数。
可以发现与前文中没有捕获变量的block结构已经不一样了。在构造函数传入的参数中多了一个__Block_byref_blockedA_0 *
类型的&blockedA
,和一个长整数。
- 其中的&blockedA,并不是我们在代码中定义的blockedA变量,而是已经被封装成了
__Block_byref_blockedA_0
类型的blockedA,所以在这里传入的是该结构体的地址。也就是说block在这里捕获的是变量的地址 by ref
来看一下现在的__main_block_impl_2
也就是NSMallocBlock的结构。
对应的,还有它的func和desc
在struct __main_block_desc_2
中,多了两个函数指针,分别是copy和dispose。这两个函数与堆block的内存管理有关
以上图片中的代码,将NSMallocBlock的结构大致地展示出来了。但还有一点需要搞清楚,那就是__main_block_impl_2
和__block_byref_blocked_0
之间的关系。
将注意里集中到block存储的代码块,也就是__main_block_func_2
中。
可以看到,block取到引用的变量是通过__main_block_impl_2 -> blockedA -> __forwarding -> blockedA
取到的。也就是说__main_block_impl_2
存放着__Block_byref_blockedA_0
的地址,其中的__forwarding
指针其实是指向自己,也就是它自身存在一个环。
到这里,产生了一个疑问,就是为什么不直接通过__Block_byref_blockedA_0 -> blockedA
取到捕获的值,而是要在中间通过一次__forwarding
?
首先来验证一下是否__forwarding
真的是指向它自己的,因为这看起来似乎有些多此一举。
通过复制struct结构体到我们的代码文件中并使用__brige
转换的方式,我们可以获取到底层结构的值。
运行之后,我们可以看到成功地转换出了__main_block_impl_2
结构体,而它其中的__forwarding
指针也的确是指向它自己…
其实,这是出于内存管理的原因。
当block在栈中时,forwarding指针指向自己,block通过forwarding指针找到捕获的值,这时候直接不通过forwarding指针也是可以的。
但当block被copy到了堆上时,对它捕获的变量(在这里是blockA),也会将__Block_byref_blockedA_0
copy一份到堆上。这时如果直接通过blockedA->blockedA
修改的仍然是栈上的值。
在block被copy到堆时,__Block_byref_blockedA_0
内的forwarding指针会被指向堆上的__Block_byref_blockedA_0
结构体,所以通过blockedA -> forwarding -> blockedA
这种方式可以保证正确地找到真正的blockedA值。
综上,NSMallocBlock的结构大致如下图。
NSStackBlock / NSGlobalBlock
这两类的Block在捕获变量后的变化相对NSMallocBlock来说比较简单
当block不捕获变量时,按源码分析来说它应该是一个stack block,但实测当不捕获变量时stack block会被优化成一个global block。
而当捕获了局部变量(auto 变量)时,block会变成malloc block。然而,我们知道捕获简单的局部变量后,在block内对变量的修改并不能传递出block内部,原因就是block在捕获时是直接捕获了变量的值。
上图是一个捕获了局部变量a的block。可以看到在结构体内部有一个a
属性,在block初始化时直接将a的值捕获进block内部,所以block内部对a的修改并不会传递出block外。事实上,在block内修改局部变量在编译时是会failed的,编译器将提示若要修改需要加上__blcok修饰符
但需要注意的是,以上讨论是基于捕获的变量为基础数据类型。若捕获的变量为对象,则会捕获指针进block结构体中,我们可以通过该对象修改它的属性值,但不可以对对象自身重新赋值。
- 静态局部变量
接着讨论一下捕获静态局部变量的情况。
可以看到,捕获静态局部变量时是直接捕获变量的指针的,也就是说可以在block内直接修改变量的值。
- 全局变量
可以看到,block内部并没有捕获全局变量,对全局变量的访问和修改是直接进行的。
最后,回顾一下四个block的类别
其中,block1引用了局部变量,block2引用了全局变量,block3引用了被__block修饰的局部变量,block4引用了静态局部变量。
总结:
- 不引用变量的block为栈区block,但在实测中这类block被转换为global block
- 引用局部变量或__block变量的block即为malloc block
- 引用静态局部变量或全局变量的block为global block
内存管理
局部对象变量
当捕获局部的对象时,若对象为strong类型,则在捕获时会对该对象进行强引用,引用计数+1.
而当block被copy到堆上时,也会copy一份引用的对象,这时该对象的引用计数又会+1,所以最后引用计数从1变成了3.
静态局部变量
静态局部变量在被block捕获时是直接传递指针,所以block捕获的是对象指针的指针。
所以在最后,捕获对象的引用计数并不会增加。
全局变量
由于使用全局变量时,block是直接访问不进行捕获,所以也不会强引用对象而改变对象的引用计数,即引用计数仍为1.
__block 对象
在笔者在Xcode中尝试的时候,发现__block修饰的变量被捕获后引用计数也仍为1,但显示该block已经为NSMallocBlock了。这里有一个小疑问:为什么引用计数不增加?
总结
block的行为会影响到block本身实际的结构体构成以及它在运行时的内存管理策略,更详细的代码需要阅读libclosure
库来进一步学习。