剑客
关注科技互联网

闭包捕捉(closure capture)浅析

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

本文中所有代码均在playground中运行,若欲在实际项目中测试,需做部分修改,但基本逻辑和结论不变

————– 本文部分结论和例子根据部分读者意见做了修正更新,感谢他们 ————–

捕捉的含义

闭包捕捉等同于copy

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

看例子

值类型捕捉

import UIKit
struct Pet {
var name: String
init(name: String) {
self.name = name
}

func printNameClosure() -> () -> Void {
return {
print(self.name)
}
}
}

var pet: Pet = Pet(name: "旺旺")
let cl = pet.printNameClosure() //1
pet.name = "强强"
cl() //2

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

旺旺

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

...//此行以前代码不变
var pet: Pet = Pet(name: "旺旺")
let cl = {
print(pet.name)
}
pet.name = "强强"
cl()

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

引用类型捕捉

import UIKit
class Pet {
var name: String
init(name: String) {
self.name = name
}

func printNameClosure() -> () -> Void {
return {
print(self.name)
}
}
}

var pet: Pet = Pet(name: "旺旺")
let cl = pet.printNameClosure() //1
pet.name = "强强"
cl() //2

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

强强

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

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

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

import UIKit
class Pet {
var name: String
init(name: String) {
self.name = name
}

func printNameClosure() -> () -> Void {
return {
print(self.name)
}
}
}

var pet: Pet? = Pet(name: "旺旺")
let cl = pet?.printNameClosure()
pet = nil //1
cl!()

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

旺旺

闭包捕捉发生的时机

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

看例子:

import UIKit
class Pet {
var name: String
init(name: String) {
self.name = name
}

deinit {
print("/(name)升天了!")
}
}
//someFunc函数内部是一个局部上下文
func someFunc() {
//局部上下文1
var pet: Pet? = Pet(name: "旺旺")
func printNameBlock() -> () -> Void {
//局部上下文2
return {
print(pet?.name)
}
}

let cl = printNameBlock()//1
cl() //2
pet = nil //3
cl()
}

someFunc() //4

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

Optional("旺旺")
旺旺升天了!
nil

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

看闭包被返回的例子

闭包被返回

import UIKit
class Pet {
var name: String
init(name: String) {
self.name = name
}

deinit {
print("/(name)升天了!")
}
}
//someFunc函数内部是一个局部上下文
func someFunc() {
//局部上下文1
var pet: Pet? = Pet(name: "旺旺")
func printNameBlock() -> () -> Void {
//局部上下文2
let pet2 = pet
return {
print(pet2?.name)
}
}

let cl = printNameBlock()//1
cl() //2
pet = nil //3
cl()
}

someFunc() //4

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

Optional("旺旺")
Optional("旺旺")
旺旺升天了

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

闭包被传递

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

import UIKit
struct Pet {
//局部上下文1
var name: String
init(name: String) {
self.name = name
}

mutating func changeNameTo(name: String) {
//局部上下文2
//异步
let dispatchQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)
dispatch_async(dispatchQueue) {
//局部上下文3
self.name = name
sleep(1) //1
}
}
}

var pet = Pet(name: "旺旺")
pet.changeNameTo("强强")
sleep(3) //2
print(pet.name) //3

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

,因此并不能达到修改Pet名字的目的,输出结果为:

旺旺

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

import UIKit
struct Pet {
//局部上下文1
var name: String
init(name: String) {
self.name = name
}

mutating func changeNameTo(name: String) {
//局部上下文2
//异步
let dispatchQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)
dispatch_async(dispatchQueue) {
//局部上下文3
self.name = name
}
sleep(1) //1
}
}

var pet = Pet(name: "旺旺")
pet.changeNameTo("强强")
sleep(3) //2
print(pet.name) //3

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

强强

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

import UIKit
struct Pet {
//局部上下文1
var name: String
init(name: String) {
self.name = name
}

mutating func changeNameTo(name: String) {
//局部上下文2
//同步
let dispatchQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)
dispatch_sync(dispatchQueue) {
//局部上下文3
self.name = name
sleep(1) //1
}
}
}

var pet = Pet(name: "旺旺")
pet.changeNameTo("强强")
sleep(3) //2
print(pet.name) //3

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

强强

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

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

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

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

var i = 18
let cl = { //并未添加捕捉列表
print(i)
}

i = 100
cl()

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

如果添加捕捉列表:

var i = 18
let cl = { [i] in //添加了捕捉列表
print(i)
}

i = 100
cl()

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

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

var i = 18
let cl = { [i] in //添加了捕捉列表
i = 56 //此行报错
print(i)
}

i = 100
cl()

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

结论

经过上述分析,closure capture主要有四个特性,

1)闭包capture某个变量等于copy一份这个变量,值类型的变量直接复制值,引用类型的变量直接复制引用值,与函数中参数传递类似,复制后的变量名同被捕捉的变量。

2)如果闭包所使用的外部变量的作用域未结束,闭包只是简单使用这些外部变量,并不捕捉。

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

第2和第3点讲的都是闭包捕捉的时机,其实可以总结为一句话,闭包捕捉发生在其所使用的外部变量即将销毁的时刻,也即你再不捕捉我就没了。这也意味着,当闭包捕捉多个外部变量,而这些外部变量的作用域不同时,闭包按照各个外部变量作用域结束的先后次序进行变量捕捉,并非一次性捕捉。

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

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

鸣谢

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

分享到:更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址