汇编分析闭包本质
闭包和闭包表达式完全是两个概念:
闭包表达式: 闭包表达式是定义函数的一种方式;
-
闭包: 闭包是一个函数和它所捕获的变量/常量环境组合起来,称作闭包.
一般指定义在函数内部的函数
一般它捕获的是外部函数的局部变量/常量
typealias Fn = (Int) -> Int
func getFn() -> Fn {
var num = 10
func plus(_ i: Int) -> Int {
num += i
return num
}
return plus //加断点1
}
var fn = getFn()
fn(1)
fn(2)//加断点2
print("Debug stop")//加断点3
var fn1 = getFn()
fn1(1)
fn1(2)//加断点4
print("Debug stop")//加断点5
每次调用getFn
都确实会重新申请一段堆空间给num
用吗?现在我们通过汇编来验证一下。
首先,执行var fn = getFn()
是getFn
被第一次调用
看一下这次swift_allocObject
所分配的空间地址,以及内部的num
变量的值
然后继续运行至断点2
然后继续运行至断点3
,查看此时的对内存
继续运行程序,执行var fn1 = getFn()
会再次调用getFn
,并走到断点1
然后继续运行至断点4
和断点5
,查看此时的对内存
这样我们就看出了规律,每调用一次
getFn
函数,就会分配一段新的堆空间来处理num
的值,相互之间互不交叉,独立了开来。每个对象之间互不影响.他们都有自己的内存空间,都有自己的捕获对象.
我们可以把闭包想象成一个实例对象,这样更容易理解.
每次对变量进行捕获时到底每次分配了多少堆空间?
因为堆空间的分配,是在函数swift_allocObject
内完成的,所以到底分配了多少,应该跟该函数所传入的参数有关联。在汇编里面,函数的参数一般是在调用该函数指令之前,存放在rdi、rsi、rdx、rcx、r8、r9
等几个寄存器里面(如果还有更多的参数,编译器会使用栈上的空间来协助存储),那么我们来看一下swift_allocObject
的参数
可以看到这里有个入参是0x18
(也就是24),而我们上面的分析结果告诉我们,num
就存放在swift_allocObject
函数所分配堆空间的17-24个字节上面,而前面16个字节里面,前8个字节存放类型信息,后8个字节存放引用计数,堆空间里面的东西其实本质上就是实例对象,那么就必然会有类型信息和引用计数,相信不难理解。
因此这个参数0x18
应该就是函数swift_allocObject
用来告诉系统所需要申请的堆空间的大小。因为在iOS/OS X系统里面,分配堆空间至少是16
的倍数,所以实际上swift_allocObject
结束后得到的堆空间应该是32
字节,只不过实际上只用到了其中的24
个字节。实际上追踪swift_allocObject
的调用堆栈结果如下
frame #0: 0x00007fff6975ace0 libsystem_malloc.dylib`malloc
frame #1: 0x00007fff68ec0ca9 libswiftCore.dylib`swift_slowAlloc + 25
frame #2: 0x00007fff68ec0d27 libswiftCore.dylib`swift_allocObject + 39
frame #3: 0x00000001000011ce SwiftTest`getFn() at :0
frame #4: 0x0000000100000e09 SwiftTest`main at main.swift:392:10
frame #5: 0x00007fff695a4cc9 libdyld.dylib`start + 1
frame #6: 0x00007fff695a4cc9 libdyld.dylib`start + 1
var fn = getFn()
中的这个fn
到底是什么?因为fn(x)
的运行结果说明,plus
函数被调用,并且能够使用堆内存上的num
变量。下面我们就探索一下,它们之间是如何关联的。
没有变量捕获时的fn
结构
汇编阅读小技巧:
0xXXXX(%rip) 寻址的结果通常是全局变量(数据段)的内存地址。
0xXX(%rbp) 寻址的结果通常是函数局部变量(栈空间)的内存地址。
0xXX(%rax) 寻址的结果通常是堆空间(通常通过alloc系列函数动态申请)的内存地址。
- 函数的返回值一般放在
rax、rdx
寄存器里面
全局变量 0x10000bad0
堆空间 0x102908550
局部变量(栈空间) 0x00007ffeefbff408
func getFn() -> (Int, Int) -> Int {
func sum(_ v1: Int, _ v2: Int) -> Int {
v1 + v2 //加断点2
}
return sum
}
var fn = getFn()
print(MemoryLayout.stride(ofValue:fn))
16
实际上根据Swift注释也可以看出,这段空间就是fn
变量的内存空间。因为movq
一次只能操作8
个字节,所以需要对这段16
字节内存空间连续两次操作才能完成赋值。而且寄存器rax
此时存的只就是sum
函数的地址,下面的rdx
暂时为0
结合上面汇编过程,总结一下:普通函数类型的变量,占用16个字节,它的前8个字节直接存放的就是函数地址,后8个字节是0
接下来我们再用汇编窥探一下闭包变量的内存:
func getFn() -> (Int) -> Int {
var num = 0
func plus(_ i: Int) -> Int {
num += i
return num
}
return plus
}
var fn = getFn()
print(MemoryLayout.stride(ofValue: fn))
print(MemoryLayout.size(ofValue: fn))
16
16
走到断点3
从图中的轨迹路线,可以判断:
- 当
getFn
返回的时候,rax
里面存放了一个叫partial apply forwarder for plus
的函数的地址,不知道它是不是plus
的地址,但至少是跟plus
有关的一个函数。rdx
里面存放的是swift_allocObject
函数动态申请的堆空间地址,这段堆空间的作用之前已经证明过了,里面的一段空间是用来存放从栈空间捕获过来的num
变量的值的。
所以当getFn
返回之后,fn
所收到的值到底是什么也就一清二楚了
- plus
函数在哪里?它是如如何被调用到的?
我们来分析一下fn(1)
这句代码,我们知道fn
里面头8
个字节放了一个partial apply forwarder for plus
函数地址,后8
个字节放的是捕获变量的那段堆地址,那么这句代码要做的必然(并且也只能)是去调用partial apply forwarder for plus
这个函数,但是跟直接通过函数名调用一个函数不同(通过函数名方式,是直接对函数地址调用,例如 call 0x10000476b
),但这里的fn
是一个全局变量,是一个变量哦,可以把它理解成一个盒子,里面可以装不同的东西,你要使用里面的内容,就必须打开盒子,所以这是一种间接调用,在汇编里面,间接函数调用是用 callq *[内存地址]
这种格式来表示,例如callq *rax
,表示根据寄存器rax
里面存储的指针,找到指定内存,从里面读取8
个字节的内容,作为目标函数地址,然后进行调用。
==r13放入的是堆空间的地址值==
Si 进入到callq 函数内部 ==r13给了rsi==
partial apply forwarder for plus
函数的汇编代码可以清晰地看出,最后那句就是跳转到真正的plus
函数,也就是说,plus
函数的地址实际上是被包裹在了partial apply forwarder for plus
函数函数内部,并且直接进行跳转的。
至此,我们就弄清楚了,最简单的闭包产生的条件,以及闭包的内存结构,通过下图总结一下
继续si
jump到plus函数
现在我们就通过汇编语言分析了闭包的底层是如何捕获变量,以及如何访问堆空间地址的.总结如下:
调用testClosure
函数会向堆空间申请一段内存用来存放捕获的变量/常量,testClosure
函数的返回值占用16个字节,其中前8个字节存放的是一个函数地址,这个函数内部会直接调用sum
函数;后8个字节存放的是向堆空间申请的内存地址;当我们通过闭包调用函数时,会传入两个参数.第一个参数就是调用方法传入的常规参数;第二个参数就是堆空间的内存地址.闭包就是通过传入的堆空间的内存地址访问变量的.
到目前为止我们搞清楚了闭包的本质以及闭包是如何捕获变量,如何访问堆空间内存的.现在我们思考一下,闭包捕获外层函数的变量时,是什么时候开始捕获的?
闭包对于外界变量的捕获,到底发生在什么时候
typealias Fn = (Int) -> Int
func getFn() -> Fn {
var num = 10
func plus(_ i: Int) -> Int {
num += i
return num
}
num = 15
return plus //断点1
}
var fn = getFn()
fn(1)
运行之后结果显示,实际捕获的是15
根据汇编情况分析,我们发现,编译器实际上是将return
之前,将num
的所有赋值都捕获一次,所以最终生成的闭包捕获到的有效值是num
在return
之前的最后一次赋值。
typealias Fn = (Int) -> Int
func getFn() -> Fn {
var num = 10
func plus(_ i: Int) -> Int {
num += i
return num
}
num = 14
return {$0} //断点1
}
var fn = getFn()
什么动态申请堆空间也没有了,也就是说,编译器认为既然返回的东西跟plus
没有任何关系了,那就没有必要再去费劲把力的分配各种堆空间来给你准备一个闭包了,直接伸略
通过上面的对比可以发现,只有==当返回的函数访问了外层函数的变量时,才会捕获变量==.所以捕获变量的根本就是看函数的返回值.所以,现在可以下结论:闭包什么时候捕获变量? 当函数返回之前捕获.
看看下面代码运行结果是什么:
typealias Fn = (Int) -> (Int, Int)
func getFn() -> (Fn, Fn){
var num1: Int = 0
var num2: Int = 0
func plus(_ i: Int) -> (Int, Int){
num1 += I
num2 += i << 1
return (num1, num2)
}
func minus(_ i: Int) -> (Int, Int){
num1 -= I
num2 -= i << 1
return (num1, num2)
}
return (plus, minus)
}
var (plus, minus) = getFn()
print(plus(2))//2,4
print(minus(4))//-2, -4
print(plus(6))//4, 8
print(minus(3))//1,2
从结果上可以看到,plus(), minus()
都共用一个num1, num2
.我们看看它的汇编:
从上面两幅图可以看到,调用
getFn()会向堆空间申请两块内存分别存放
num1,
num2.并且把
num1,
num2的地址一起放到另一个内存中,然后再存放到闭包对象中.之前闭包只有一个返回值的时候,闭包对象的内存中直接存放的就是用来捕获的堆空间地址.