闭包捕捉(closure capture)浅析

 

根据Swift官方文档,闭包(closure)会自动捕捉其所在上下文中的外部变量,即使是定义这些变量的上下文已经消失。寥寥数字,其实已经将闭包捕捉说的足够清晰明了,只是其中隐含的诸如捕捉的具体含义、捕捉的时机、被捕捉变量的特性和捕捉列表的意义等细节,如不详加研究,使用闭包还是会错误百出,难以挥洒自如。

本文中所有代码均在playground中运行,若欲在实际项目中测试,需做部分修改,但基本逻辑和结论不变
—————————— 本文部分结论和例子根据部分读者意见做了修正更新,感谢他们 ———————————–

一、捕捉的含义

闭包捕捉等同于copy

闭包捕捉某个变量就意味着copy一份这个变量,值类型的变量直接复制值,引用类型的变量复制引用。复制后的变量名同被捕捉的变量,复制后的变量仍为变量,常量仍为常量。

看例子

值类型捕捉

结构体Pet的实例方法printNameClosure()返回一个捕捉了self实例本身的闭包,Pet为值类型,因此//1行代码执行完成后,闭包cl复制了一份存储在变量pet中名为旺旺的Pet实例,那么当存储在变量pet的Pet实例改名为强强时,闭包cl所捕捉的Pet实例不变,名字仍为旺旺,因此输出结果为:

我想您应该会有疑问,为什么上述示例代码不写的更简洁些:

事实上,上述示例代码中的闭包cl并未捕捉任何变量,关于闭包捕捉发生的时机下文中会有详细介绍。

引用类型捕捉

这次Pet类型为类,是引用类型,因此//1行代码执行完成后,闭包cl复制了一份变量pet所指向的名为旺旺的Pet实例引用,此时变量pet与闭包cl捕捉的pet指向同一Pet实例,那么当变量pet所指向的Pet实例改名为强强时,闭包cl所捕捉的Pet实例名字也改为强强,因此输出结果为:


二、引用类型变量被捕捉后的特性

引用类型变量被捕捉意味着变量所指向的类的引用被复制,也即引用计数会加一,因此为强持有。

因为引用类型变量捕捉的强持有特性,有时候会产生引用环,导致内存泄漏,解决办法官网文档已有,这里不再赘述。

闭包cl捕捉了变量pet所指向的Pet实例,而引用类型闭包捕捉为强持有,因此变量pet所指向的Pet实例的引用计数为2,那么当在//1行设置变量petnil时,pet所指向的Pet实例的引用计数减为1,并不销毁,因此输出结果为:


三、闭包捕捉发生的时机

1)当闭包所使用外部变量的作用域未结束时,闭包只是简单使用外部变量,并不捕捉。

看例子:

函数someFunc()的内部函数printNameBlock()返回一个闭包,被返回的闭包定义在局部上下文2中,并使用了局部上下文1中的变量pet。虽然//1行变量cl存储了内部函数printNameBlock()返回的闭包,但这个闭包从初始化到销毁整个生命周期中,并未脱离其使用的外部变量pet的作用域即局部上下文1,那么闭包cl并不捕捉外部变量pet。因此当//3行设置petnil时,变量pet所指向的Pet实例变量被销毁,最终的输出结果为:

2)如果闭包所使用的外部变量的作用域结束,而闭包或因被返回,或作为参数传递给其他函数而仍然存在时,闭包自动捕捉其使用的外部变量。

看闭包被返回的例子

闭包被返回

上述示例与1)中示例相比,仅在内部函数printNameBlock()局部上下文2中新增加了一个变量pet2,指向局部上下文1中变量pet所指向的名为旺旺的Pet实例,内部函数printNameBlock()返回的闭包使用了变量pet2,当这个闭包被返回时,其使用的外部变量pet2的作用域即局部上下文2也同时结束,因此变量pet2被捕捉。那么//1行执行结束后,闭包cl捕捉了变量pet2,则变量pet所指向的名为旺旺的Pet实例的引用为2,当在//3行设置petnil时,其指向的名为旺旺的Pet实例的引用只是降为1而已,并不销毁,因此最后的输出结果为:

之所以仍然输出旺旺升天了,是因为//4行someFunc()调用结束后,闭包cl被销毁,其捕捉的变量随即也都被销毁。

闭包被传递

在异步请求时,任务常常被包装为闭包,作为参数提交给GCD或NSOperationQueue执行。

上例中的Pet是一个struct,是个值类型,其实例方法changNameTo(_:)使用异步修改了自己的名字,异步的任务闭包使用了局部上下文1中的self即实例本身,但是否捕捉self,还取决于异步任务执行结束时,局部上下文1是否结束。在上述示例中,//1行使得闭包任务睡眠1s,因此保证了闭包任务执行结束时,局部上下文2已经结束,也即局部上下文1已经结束,因此闭包任务捕捉了self实例本身,//2行睡眠了3秒,保证了//3行输出Pet实例名字时,异步任务已经执行完成,但由于传入异步任务的闭包捕捉了self
,因此并不能达到修改Pet名字的目的,输出结果为:


稍微修改下,让局部上下文2睡眠1s。


由于//1行代码让局部上下文2睡眠1s,因此导致异步任务执行结束时,self所在的局部上下文1仍在,那么异步任务闭包并不捕捉self,因此可以达到修改Pet名字的目的,那么输出结果为:


如果将上述两个例子都改为同步,那么,根据同步的性质,同步任务闭包一定不捕捉self:


由于是同步任务,意味着局部上下文2一直会等到局部上下文3返回才返回,也即在局部上下文3执行时,局部上下文2一直未结束,因此同步任务闭包并不捕捉self,则结果为:


需要注意的是,Swift中对值类型的使用达到了空前的程度,因此我们常用struct定义model,如果在model我们还异步请求数据,那么根据闭包的捕捉特性,可能请求了两年也不会有结果。如果改为引用类型,则没有这种隐患,因此如果模型需使用异步请求数据,定义时选择引用类型更合适。

3)闭包中如果定义了捕捉列表,闭包在定义时立即capture捕捉列表中所有变量,并将捕捉的变量一律改为常量,供自己实用。

闭包捕捉一般发生在所使用的外部常量所在上下文结束时,但如果闭包定义了捕捉列表,闭包在初始化时立即捕捉捕捉列表中的变量,并将捕捉的变量一律改为常量,这也是捕捉列表应有的意义。

对比两个示例,一个添加了捕捉列表,一个没有。


闭包cl所使用的外部变量i的局部作用域一直未结束,因此闭包cl只是简单的使用变量i,并不捕捉,无论i如何变,cl调用时都会使用自己被调用时刻i的最新值,因此输出结果为:


如果添加捕捉列表:


闭包cl所使用的外部变量i的局部作用域虽未结束,但由于闭包cl定义了捕捉列表,因此闭包cl在其定义完成时,即捕捉了变量i,copy了一份i,由于i是值类型,copy后与变量i不再有任何关系,因此输出结果:


当然由于捕捉列表中捕捉的变量均被改为常量,在闭包内无法修改捕捉变量的值:


上述示例在闭包内部修改了捕捉变量i的值,但由于捕捉列表中的变量在捕捉后均被改为常量,因此会报错。

四、结论

经过上述分析,closure capture主要有四个特性,
1)闭包capture某个变量等于copy一份这个变量,值类型的变量直接复制值,引用类型的变量直接复制引用值,与函数中参数传递类似,复制后的变量名同被捕捉的变量。

2)如果闭包所使用的外部变量的作用域未结束,闭包只是简单使用这些外部变量,并不捕捉。
3)闭包捕捉发生在闭包所使用的外部变量的作用域结束,而闭包或因被返回,或作为参数传递给其他函数而仍然存在时。
第2和第3点讲的都是闭包捕捉的时机,其实可以总结为一句话,闭包捕捉发生在其所使用的外部变量即将销毁的时刻,也即你再不捕捉我就没了。这也意味着,当闭包捕捉多个外部变量,而这些外部变量的作用域不同时,闭包按照各个外部变量作用域结束的先后次序进行变量捕捉,并非一次性捕捉。

4)闭包中如果定义了捕捉列表,闭包在定义时立即capture捕捉列表中所有变量,并将捕捉的变量一律改为常量,供自己实用。

闭包捕捉苹果官方文档中介绍的非常简略,上述只是所有的特性也是我多番实验得出的结论,对于理解closure capture暂时应该是够了。

五、鸣谢

由于文章写的有点仓促,部分结论未经严谨论证就直接摆出来了,幸好部分网友大牛及时指正,真是无与伦比的感谢。他们是strider,小吻子, 来扶爷试玩个波。非常感谢他们,也欢迎各路大神继续留言讨论批评指正。

1 1 收藏 评论

相关文章

可能感兴趣的话题



直接登录
跳到底部
返回顶部