背景
每次看完 狮子书🦁️ 的 Block 部分,过一段时间记忆就会开始模糊…
想达到熟悉源码实现的程度,恐怕还需要反复翻看和一定场景的锻炼 ,而大部分开发日常也碰不到需要源码的场景。
不过 Block 作为 iOS 日常开发高频使用的东西,无论如何还是需要想办法去加深记忆的。于是就有了这篇笔记,加入了一些自己的 图解,图像理解起来不会那么抽象,也许能帮助我们(可能只是菜鸡的我)更好的记忆。
文章主要按下面顺序来做:
- Block 语法
- Block 使用
- Block 实现 - Block 如何做到捕获变量
- Block 实现 - Block 为什么不能直接对捕获的自动变量赋值?
- Block 实现 - __Block 说明符的相关实现
- Block 存储域 - NSConcreteStackBlcok / NSConcreteMallocBlock 的关系
- Block 存储域 - 为什么要有 MallocBlock?
- Block 存储域 -
__Block 变量
如何从栈复制到堆 - Block 存储域 - 为什么
__Block 变量
要有成员变量__forwarding
? - Block 实现 - 截获对象的引用和释放
本文内容基本根据 《Objective-C 高级编程 - iOS 与 OS X 多线程和内存管理》而来,推荐所有 iOS 开发者阅读 📖
Block 语法
对于如何定义一个 Block,首先一定要去理解 Block 的 BN 范式 (Backus-Naur Form):
1 | Block_literal_expression ::= ^ block_decl compound_statement_body |
关键在于记忆 block_decl
的定义 ,就能比较理解容易 Block 的语法了。
如果不太明白 BN 范式 的意思,尝试看看 BNF范式(巴科斯范式) 会有助于我们理解,其实就是这副图的关系:
再结合 Block 具体的 4 种写法:
^
返回值类型
参数列表
表达式
^
返回值类型
参数列表表达式
^
返回值类型参数列表
表达式
^
返回值类型 参数列表表达式
上面可以看到, Block 语法中是有省略写法的,对于省略的情况主要遵循 2 点规则:
- 如果省略返回值,那么 return 什么类型就返回什么类型,多个 return 则需要相同类型。
- 如果省略参数列表,即参数类型为
void
可以省略,代表不使用参数。
日常比较多的一种写法是 同时省略,返回值是void,参数也是 void ,可以这么写:
1 | ^{ printf("hello"); } |
值得注意的是 Block 同时省略返回值和参数时,返回类型也只取决于 return 的类型 ,例如这么写:
1 | NSInteger num = ^{ return 8;}(); |
掌握了上面的东西,在手写 Block 的能力上估计能有所进步了,虽然大家肯定还是补全用的多 : )
Block 使用
在使用上,是可以直接把 Block 当作一个变量来使用的:
- 自动变量 (局部变量)
- 函数参数
- 静态变量 (静态局部变量)
- 静态全局变量
- 全局变量
创建 Block 作为变量使用的语法:
1 | returnType (^blockName)(parameterTypes) = ^returnType(parameters) {...} |
举个例子🌰 :
1 | //将 Block 赋值为 Block 类型变量 |
typedef 声明 Block 类型变量
Block 在作为函数参数和返回值时,写起来比较冗长:
1 | //Block 作为函数参数 |
我们能通过 typedef 来简化 Block 的使用:
1 | typedef int (^blk_t)(int) |
Block 的指针类型变量
Block 类型变量可以使用指向 Block 类型变量的指针来访问:
1 | typedef int (^blk_t)(int) |
Block 截获变量
通过实例看一下什么是 Block 截获变量:
1 | int val = 10; |
结果的输出:
val = 10
说明代码中的 val / fmt 在执行 Block 语法时的 瞬间值被截获(被保存),然后在执行时会使用截获的变量。
Block 的实现
截获变量 作为 Block 的重要特性,下面的篇幅,也将回答 3 个截获变量问题:
- 为什么可以截获变量
- 为什么 Block 内给截获变量赋值会报错
- Block 如何实现修改截获的变量
在文件 main.m 的代码:
1 |
|
通过命令:
1 | clang -rewrite-objc main.m |
在转换后的文件 main.cpp
中,找到 main 方法关联的实现:
1 | struct __block_impl { |
对于上面代码里的 名称规则
,以 __main_block_impl_0
为例介绍一下:
Block 结构
根据上面代码,整理出来 Block 的结构和对应的关系:
生成一个类型为 __main_block_impl_0
结构体变量 block
,构成也比较简单,由 2 部分组成:
- 类型为
__block_impl
的结构体impl
- 类型为
__main_block_desc_0
的结构体Desc
如图:
这里最重要的就是 impl
了,它是一个 __block_impl 结构体:
Desc
是一个 __main_block_desc_0 结构体:
而 Block 通过构造函数,将 __main_block_func_0
和 __main_block_desc_0_DATA
作为参数传入,来生成结构体实例:
1 | __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int flags=0) { |
Block 执行调用
对于 Block 的执行调用为下面的代码:
1 | ((void (*)(__block_impl *))((__block_impl *)block)->FuncPtr)((__block_impl *)block); |
这里需要注意,就是__main_block_impl_0
类型的 block 被强转换为了__block_impl
。
这在C语言是可行的,因为__block_impl
位于__main_block_impl_0
的最顶部,就相当于__block_impl
的变量直接排列在__main_block_impl_0
的顶部。
上面代码去掉多余的转换部分就变成:
1 | (*block->impl.FuncPtr)(block) |
其实就是简单使用 函数指针 调用函数 __main_block_func_0
。
再看一下对应函数 __main_block_func_0
的实现定义:
1 | static void __main_block_func_0(struct __main_block_impl_0 *__cself) { |
函数实现就是 Block 语句内的实现,且 block 本身作为 参数 __cself 进行了传递。
1 | ((void (*)(__block_impl *))((__block_impl *)block)->FuncPtr)((__block_impl *)block); |
Block 为什么能截获自动变量值
下面开始来分析 Block 的重点 – 截获自动变量值。
因为需要基于实际分析,仍然会把所有代码都贴出来…但建议大家不要直接全看,看多了确实头晕..
等需要对照具体的实现代码去理解,再来看。
重点关注在 Block 截获自动变量值与未截获的 差异。
将这一段代码 :
1 |
|
进行转换后,得到:
1 | struct __main_block_impl_0 { |
经过对比,我们可以发现,__main_block_impl_0
的实现已经发生了变化,代码如下:
1 | struct __main_block_impl_0 { |
上面的代码里,多出来了 Block 内要用到的 2 个变量:
然后通过 __main_block_impl_0
初始化的构造函数,对 fmt
/ val
进行截取和存储:
1 | void (*block)(void) = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, fmt, val)); |
Block 使用截取的自动变量值
因为截取自动变量后的 Block 执行调用
没有变化,仍然是通过函数指针调用函数,并传递 __main_block_impl_0
结构体实例作为参数,我们就不再做分析了。
重点放到新的函数实现上:
1 | static void __main_block_func_0(struct __main_block_impl_0 *__cself) { |
通过代码发现,函数执行时使用的自动变量值,是通过访问 __cself
上捕获的自动变量,再赋值给函数内部的局部变量,用于在执行时使用。
Block 捕获和使用自动变量的流程
总结一下 Block 捕获和使用自动变量的流程:
Block 为什么不能直接对截获的自动变量赋值?
当我们尝试对捕获的自动变量进行 赋值操作,一般都会提示编译错误:
1 | int a = 0 ; |
这是为什么呢?
在截获自动变量值的 __main_block_func_0
实现里,能看到系统自动加上的注释: bound by copy。Block 对它引用的局部变量做了只读拷贝,也就是说block引用的是局部变量的副本。
自动变量 val 虽然被捕获进来了,但是 Block仅仅捕获了val的值,并没有捕获val的内存地址。在 __main_block_func_0
中修改自动变量的值,依旧不能改写 Block 外面的自动变量值。
基于上面原因,Block 内的修改无法影响外部变量的值。
所以在编译层面检测出被截获的自动变量的赋值操作时,就会报编译错误。
另外会提示错误的还有这种情况:
1 | const char text[] = "hello"; |
block 不能直接对 C 语言数组做的截获。
Block 如何做到修改自动变量值
想要在 Block 中做到修改变量,在函数内可以使用:
- 静态全局变量
- 全局变量
因为 Block 的匿名函数部分的实现还是变换成 C 语言函数,在其中访问 静态全局变量/全局变量 并没有任何改变,可以直接使用。
除此之外,可以用两种方式在 Block 修改变量,一种是 静态变量,一种是利用 __block 说明符 修饰。
静态变量在 Block 中的实现
在 Block 中想修改变量值,除了 静态全局变量/全局变量
之外,使用静态变量也能办到修改变量值。
1 |
|
只是这里捕获的变量稍微有些不同,由于源码和上面最开始的差不多,就不再全部贴上来了,省略了部分代码。
1 | struct __main_block_impl_0 { |
之前我们说到 Block 不能直接修改捕获的自动变量原因,就是因为只是捕获了值,并没有保存指针,所以无法修改外部的变量。
采用静态变量之后,Block 在捕获的时候传递了指针地址。修改的时候也是使用指针变量。
所以使用静态变量可以做到在 Block 内修改变量的值。
__block 说明符的实现
除了静态变量,使用 __block 修饰也可以在 Block 内修改变量的值:
1 | int main(int argc, const char * argv[]) { |
经过转换,代码如下:
1 | struct __Block_byref_val_0 { |
__Block 变量使用的结构体
看到一下子这么多代码,有点头晕,实际上最核心的就在于 __Block_byref_val_0
结构体的增加与使用。
整理代码,我们发现被 __block
修饰的 val
, 类型从 int
变成了 __Block_byref_val_0
结构体:
生成 __Block_byref_val_0
结构体变量 val
:
1 | __attribute__((__blocks__(byref))) __Block_byref_val_0 val = { |
Block 进行捕获,则传递 val 指针作为参数:
1 | void (*block)(void) = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, fmt, (__Block_byref_val_0 *)&val, 570425344)); |
__Block 变量的访问和修改
Block 内对 val 做访问和修改:
1 | static void __main_block_func_0(struct __main_block_impl_0 *__cself) { |
通过 __cself->val
取出来变量,并且注释变为了bound by ref
,说明是引用传递。
(val->__forwarding->val)
很容易看出是通过访问 val 结构体指针来做修改的:
不过确实存在疑问:
block 变量的实现,为什么要通过 `(val->forwarding->val)` 来访问和修改 ?
直接 val->val
应该也是可以的,通过 __forwarding
要多访问一步。
这里就涉及到 Block 的存储域问题了。
Block 存储域
一般 Block 根据存储位置分为3种:_NSConcreteStackBlock
,_NSConcreteMallocBlock
,_NSConcreteGlobalBlock
。
- _NSConcreteGlobalBlock
没有捕获变量,或者只用到全局变量/全局静态/静态变量的 Block,生命周期从创建到应用程序结束。与全局变量一样,GlobalBlock 类对象设置在程序的数据区域(.data区) - _NSConcreteStackBlock
有捕获外部变量,但没有被强引用的 Block 。生命周期由系统控制。StackBlock 类对象设置在栈上。 - _NSConcreteMallocBlock
有被强引用或者 copy 修饰的属性的 Block 会被复制一份到堆中成为 MallocBlock,生命周期由程序员控制。MallocBlock 类对象设置在由 malloc 函数分配到内存块(即堆)中。
NSConcreteStackBlock 和 NSConcreteMallocBlock 的关系
NSConcreteMallocBlock 是由 NSConcreteStackBlock 复制而来。
栈 Block 通过什么复制到堆 Block
赋值 Block 使用 objc_retainBlock
,而 objc_retainBlock
实际上就是 Block_copy
函数。
一般来说,编译器会自动判断。
为什么要有 NSConcreteMallocBlock ?
NSConcreteMallocBlock 的使用,是为解决这么一种情况:
设置在栈上的 Block,即 StackBlock,当 StackBlock 所属的变量作用域结束,该 Block 和栈上的 __block 变量也会随之被废弃。
StackBlock 作为栈对象的好处是内存分配快,有确切的生命周期,因为函数执行完就会自动销毁。但也因此无法法在函数之外再使用它。
Blocks 于是提供了将 Block
/__block 变量
从栈上复制到堆上来解决这个问题,即使 Block 语法记述的变量作用域结束,堆上的 Block 还可以继续存在和被使用。
将栈 Block 复制的到堆上面创建堆 Block 对象,就可以通过引用计数来管理它。
而在 ARC 下,大多数情况编译器会恰当的判断,自动生成将 Block 从栈上复制到堆上的代码。
所以 NSConcreteMallocBlock 也是 Block 超出变量作用域还能存在的原因。
概念说起来可能不具体,下面通过 MRC / ARC 的两种情况来看一下 NSConcreteStackBlock 废弃的情况 。
MRC 下 NSConcreteStackBlock 废弃的情况
要理解为什么需要 MallocBlock ,就要理解 NSConcreteStackBlock 和栈上的 __block 变量被废弃的情况。
因为上面说到了 ARC 下会做对 Block 一些自动的处理,现在来做一个对比,如果在 MRC 下不处理 Block,和 ARC 下处理 Block 的结果。
对下面代码分别在 ARC/MRC 下来进行测试:
1 | typedef int (^blk_t)(int); |
先在 ARC 下运行 ,下面的图里可以看到在 func 函数返回的为 NSConcreteMallocBlock :
运行完成的结果如图:
可以看到在 ARC 下,main 函数里的 blk 为 NSConcreteMallocBlock ,total 的结果为18。正常。
然后使用 -fno-objc-arc
对文件做处理,变成 MRC 运行:
从上面的图里看到在 func 函数返回的为 NSConcreteStackBlock 。
运行完成的结果如图:
我们发现 blk 的类型 isa 变成了 0x0
,确实属于 NSConcreteStackBlock 随着变量作用域结束而被废弃的情况。 total 得到也是一个奇怪的值。
解决方式就是把 StackBlock 变成 MallockBlock ,所以 ARC 下,编译器自动判断后帮我们做了一次转换。
ARC 下 NSConcreteStackBlock 废弃的情况
大多数情况下 ARC 下会自动复制到堆上,但也存在例外,这时需要 手动copy 将 Block 复制到堆上。
需要手动 copy 的场景
有一种情况需要我们手动 copy ,是编译器不能判断的状况:
- 向方法或函数传递 Block
下面是一个 Block 作为 参数传递 的例子:
1 | id getBlockArray() |
将代码在 ARC 下运行,发现数组里除了第 0 个是 NSMallocBlock ,后面的都是 NSStackBlock :
接下来在 main 取数组里的 Block ,会发现崩溃了,原因是: StackBlock 随着函数作用域的结束而跟着被废弃了。
怎么解决 StackBlock 废弃呢?
那就是把 Block 复制到堆上,变成 MallocBlock 之后,即使超出了函数的作用域也可以被访问和使用。
对应到这段代码,就是代码手动调用 copy :
1 | NSArray *blockList =[[NSArray alloc] initWithObjects: |
这也验证了需要 NSConcreteMallocBlock 在超出函数作用域时使用的原因,否则当 StackBlock 废弃,再去访问就会发生异常。
三种 Block 的 copy 效果
另外这里直接总结一下三种 Block 调用 copy 的效果:
Block 类型 | 存储域 | 复制的效果 |
---|---|---|
_NSConcreteGlobalBlock | 程序的数据区域 | 什么也不做 |
_NSConcreteStackBlock | 栈 | 从栈复制到堆 |
_NSConcreteMallocBlock | 堆 | 引用计数增加 |
__block 变量从栈上复制到堆上
当 Block 被复制到堆上,对应使用的 __Block 变量
都是会一起复制到堆上的。
需要注意的是多个 Block 持有同 1 个 Block 变量
的情况是如何复制的。
例如:
栈上的 block1和 block2 同时都用到了栈上的 __block 变量
。
block1 复制到堆上,__block 变量
也跟着被复制到了堆上,并且堆上的__block 变量
被堆上的 block1 持有。如图:
接着 block2 也复制到堆上,__block 变量
此时不会再复制 1 份,被复制到堆上的 block2 持有 __block 变量
,增加 __block 变量
的引用计数。__block 变量
同时被堆上的 block1 和 block2 一起所持有。
__block 结构体使用 forwarding 成员变量的原因
之前提到了,使用 __block 变量 的修改变量值没直接用 var->var
的方式,而是使用 var->__forwarding->var
绕了一下来做访问。
例如下面的情况:
1 | { |
代码里 2 处的 ++val
最终同样都用 val.__forwarding->val
来做修改。
重点在于,实际上这 2 处是不同的 __block 变量
类型:
Block 语法内的,因为被 copy 了,所以 Block 执行时是
堆上的 __block 变量
。Block 语法外的,仍旧还是
栈上的 __block 变量
。
当栈上的 Block 被复制到堆上,此时可以 同时 访问栈上 __block 变量
和堆上__block变量
。
如果直接使用 val->val
来访问,那么此时 2 个不同的 __block 变量
,指向的是栈上和堆上不同的地址,显然会产生 错误的结果。
关键的解决方式,就是使用 __forwarding
来做指向:
如图,有了 __forwarding
来做指向的话,那么在栈上的__block 变量
,最终也会指向 堆上的__block 变量
,来保证访问到的都是同一个变量,即此时访问栈 __block 变量
实际访问的是堆 __block 变量
。
截获对象的使用和废弃
被 MallocBlock 所截获的对象(这里指 OC 对象),就算没有使用 __block 修饰,由于被堆上的 Block 持有,也会延长对象的生命周期,使该对象能够超出其变量作用域而存在。等到堆上的 Block 释放才会调用 dispose 函数去释放对象。
例如下面的情况:
1 | blk_t blk ; |
输出:
1 | array count = 1 |
array
按照道理应该是随着变量作用域结束就会被废弃了,但是调用 blk([NSObject new])
发现 array
仍旧存在,且运行正常。
原因就是 Block 在堆上 ,对象因为被堆上的 Block 持有了,所以还能超出其变量作用域存在。
copy / dispose 函数
截获对象的一个关键点,就是用 copy 函数和 dispose 函数来进行引用和释放:
- 栈上的 Block 复制到堆时,会调用 copy 函数
- 堆上的 Block 被废弃时,会调用 dispose 函数
截获对象和截获 __block 变量时,copy / dispose 函数的内部实现是不一样的。
截获 __block 变量:
1 | // __block 变量使用 BLOCK_FIELD_IS_BYREF (值为8) |
截获对象:
1 | // 对象使用 BLOCK_FIELD_IS_OBJECT (值为3) |
栈上 Block 复制到堆
在下列情况,栈上的 Block 会自动复制到堆:
- 调用Block 的 copy 实例方法
- Block 作为函数返回值返回时
- 将 Block 赋值给附有 __strong 修饰符 id 类型的类或Block 类型成员变量时
- 在方法名含有有 usingBlock 的 cocoa 框架方法或 GCD 的 API 传递 Block 时 (会在方法内部调用 copy)
总结
这次将 Block 的相关知识梳理了一下,主要针对 Block 的实现,__block 变量
的实现, Block/__block 变量
怎么从栈复制到堆,以及回答为什么需要堆 Block,为什么 __block 变量
的结构体要通过 __forwarding
访问等问题。
在整理和画图的同时,相比只读一遍文字和代码,记忆更加深刻了。建议大家也可以在读书时画图整理关系。
如有错误,欢迎指正 !