闭包捕捉(closure capture)浅析

根据Swift官方文档,闭包(closure)会自动捕捉其所在上下文中的外部变量,即使是定义这些变量的上下文已经消失。寥寥数字,其实已经将闭包捕

根据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,小吻子,来扶爷试玩个波。非常感谢他们,也欢迎各路大神继续留言讨论批评指正。

未登录用户
全部评论0
到底啦