Swift Closures
小时光
我的博客
1、What’s the closure?
作为iOS
开发者对于Objective-C
中的Block
一定非常熟悉,在其他开发语言中,也把closure
也称作lambdas
等。简答来说,闭包就是一个独立的函数,一般用于捕获和存储定义在其上下文中的任何常量和变量的引用。
closure
的语法如下:
1 | { (parameters) -> return type in |
closure
能够使用常量形式参数、变量形式参数和输入输出形式的参数,但不能设置默认值。可变形式参数也可以使用,但需要在行参列表的最后使用。元组也可以被用来作为形式参数和返回类型。
实际上全局函数和内嵌函数也是一种特殊的闭包,(关于函数的相关概念可参考官方文档The Swift Programming Language: Functions),闭包会根据其捕获值的情况分为三种形式:
- 全局函数是一个有名字但不会捕获任何值的闭包
- 内嵌函数是一个有名字且能从其上层函数捕获值的闭包
- 闭包表达式是一个轻量级语法所写的可以捕获其上下文中常量或变量值的没有名字的闭包
2、各种不同类型的闭包
如果需要将一个很长的闭包表达式作为函数最后一个实际参数传递给函数,使用尾随闭包将增强函数的可读性。尾随闭包一般作为函数行参使用。如系统提供的sorted
,map
等函数就是一个尾随闭包。
2.1、尾随闭包(Trailing Closure)
尾随闭包虽然写在函数调用的括号之后,但仍是函数的参数。使用尾随闭包是,不要将闭包的参数标签作为函数调用的一部分。
1 | let strList = ["1","2","3","4","5"] |
2.2、逃逸闭包 (Escaping Closure)
当闭包作为一个实际参数传递给一个函数的时候,并且它会在函数返回之后调用,我们就说这个闭包逃逸,一般用 @escaping
修饰的函数形式参数来标明逃逸闭包。
- 逃逸闭包一般用于异步任务的回调
- 逃逸闭包在函数返回之后调用
- 让闭包
@escaping
意味着必须在闭包中显式地引用self
1 | // 逃逸闭包 |
1 | Reference to property 'x' in closure requires explicit use of 'self' to make capture semantics explicit |
修改代码:
1 | requestServer(with: "") { [weak self] (obj, error) in |
- 逃逸闭包的实际使用
如我要设计一个下载图片管理类,异步下载图片下载完成后再返回主界面显示,这里就可以使用逃逸闭包来实现,核心代码如下:
1 | struct DownLoadImageManager { |
下载图片并显示:
1 | let path = "https://gimg2.baidu.com/image_search/src=http%3A%2F%2Finews.gtimg.com%2Fnewsapp_match%2F0%2F12056372662%2F0.jpg&refer=http%3A%2F%2Finews.gtimg.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1618456758&t=3df7a5cf69ad424954badda9bc7fc55f" |
以上的代码虽然能够完成对图片的下载管理,事实上在项目中下载并显示一张图片的处理要复杂的多,这里不做更多赘述,可参考官方的demo: Asynchronously Loading Images into Table and Collection Views
2.3、自动闭包 (Auto Closure)
- 自动闭包是一种自动创建的用来把座位实际参数传递给函数的表达式打包的闭包
- 自动闭包不接受任何参数,并且被调用时,会返回内部打包的表达式的值
- 自动闭包能过省略闭包的大括号,用一个普通的表达式来代替显式的闭包
- 自动闭包允许延迟处理,因此闭包内部的代码直到调用时才会运行。对于有副作用或者占用资源的代码来说很用
如我有个家庭作业管理类,老师需要统计学生上交的作业同时处理批改后的作业,为了演示自动闭包,我用以下的代码来实现:
1 | enum Course { |
其中,autoAddWith
表示学生某个学生交了作业,autoDeleteWith
表示老师批改完了某个学生的作业。一般调用方式为:
1 | let studentManager: StudentManager = StudentManager() |
2.4、自动 + 逃逸 (Autoclosure + Escaping )
如果想要自动闭包逃逸,可以同时使用@autoclosure
和@escaping
来标志。
1 | func autoAddWith(_ student: @autoclosure @escaping() -> StudentModel) { |
3、闭包捕获值
前面简单介绍了尾随闭包、逃逸闭包、自动闭包的概念和基本使用,这里来说闭包是如何捕获值的。在Swift中,值类型变量一般存储于栈(Stack)中,而像func class closure
等引用类型存储于堆(Heap)内存中。而closure
捕获值本质上是将存在栈(Stack)区的值存储到堆(Heap)区。
为了验证closure
可以捕获哪些类型的值,用下面的代码做一个测试:
1 | class Demo: NSObject { |
上面的代码中,无论是常量、变量、还是引用类型调用capturel()
后都可以正常打印数据。 无论是常量、变量、值类型还是引用类型,Closure
都可捕获其值。事实上在Swift中作为优化当Closure
中并没有修改或者在闭包的外面的值时,Swift可能会使用这个值的copy而不是捕获。同时Swift也处理了变量的内存管理操作,当变量不再需要时会被释放。
在来看一个实现递增的例子:
1 | func makeIncrementer(_ amount: Int) -> () -> Int { |
在上面的代码中,incrementerClosure
中捕获了total
值,当我返回incrementerClosure
时,理论上包裹total
的函数就不存在了,但是incrementerClosure
仍然可以捕获total
值。可以得出结论:即使定义这些变量或常量的原作用域已经不存在了,但closure
依旧能捕获这个值。
1 | let incrementerTen = makeIncrementer(10) // () -> Int |
在上面的代码中,调用了递增闭包incrementerTen
每次+10,当我新建一个incrementerSix
闭包时就变成了+6递增,也就说产生了一个新的变量引用。
当调用alsoIncrementerTen
后,返回的值是50,这里可以确定Closure
是引用类型,是因为alsoIncrementerTen
引用了incrementerTen
他们共享同一个内存。如果是值类型,alsoIncrementerTen
返回的结果会是10,而不是50;
根据上面的代码关于闭包捕获值做出总结:
closure
捕获值本质上是将存在栈(Stack)区的值存储到堆(Heap)区- 当
Closure
中并没有修改或者在闭包的外面的值时,Swift可能会使用这个值的copy而不是捕获 Closure
捕获值时即使定义这些变量或常量的原作用域已经不存在了closure
依旧能捕获这个值- 如果建立了一个新的闭包调用,将会产生一个新的独立的变量的引用
- 无论什么时候赋值一个函数或者闭包给常量或变量,实际上都是将常量和变量设置为对函数和闭包的引用
4、Closure循环引用
Swift中的closure
是引用类型,我们知道Swift中的引用类型是通过ARC
机制来管理其内存的。在Swift中,两个引用对象互相持有对方时回产生强引用环,也就是常说的循环引用。虽然在默认情况下,Swift能够处理所有关于捕获的内存的管理的操作,但这并不能让开发者一劳永逸的不去关心内存问题,因为相对于对象产生的循环引用Closure
产生循环引用的情况更复杂,所以在使用Closure
时应该更小心谨慎。那么在使用Closure
时一般哪些情况会产生循环引用问题呢?
4.1、Closure
捕获对象产生的循环引用
当分配了一个Closure
给实例的属性,并且Closure
通过引用该实例或者实例的成员来捕获实例,将会在Closure
和实例之间产生循环引用。
这里我用学生Student
类来做演示,假设现在学生需要做一个单项选择题,老师根据其返回的答案来判断是否正确。我将对照Objective-C
中的Block
来做一个对比,在Xcode中编写如下代码:
1 | typedef NS_ENUM(NSInteger, AnswerEnum) { |
1 | @interface Teacher : NSObject |
其实上面的代码,不用运行在Build的时候就会警告Capturing 'self' strongly in this block is likely to lead to a retain cycle
。
那么在Swift中使用closure
是否同样也会产生循环引用呢?我把Objective-C
代码转换成Swift
:
1 | enum Answer { |
Student
类有两个属性:name
表示学生姓名,replyClosure
表示学生回答问题这一动作并返回答题结果。
1 | // 调用并运行代码 |
运行上面的代码,通过打印结果可以看到Student
类并没有调用deinit
方法,此处说明Student
在被初始化后内存并没有释放。实际上在judgeClosure
内部,只要我调用(捕获)了student
,无论是任何操作,该部分内存都不能有效释放了。那么为什么会造成这种现象呢?下面做逐步分析:
- 当我调用了闭包之后,闭包才会捕获值,在执行
student.replyClosure = judgeClosure
之后,在内存中他们的关系是这样的:
在Swift中,class、func、closure
都是引用类型,因此在上面的代码中,student
和judgeClosure
都指向各种对象的strong reference
。
同时由于在闭包中捕获了student
,因此judgeClosure
闭包就有了一个指向student
的强引用。最后当执行student.replyClosure = judgeClosure
之后,让replyClosure
也成了judgeClosure
的强引用。此时student
的引用计数为1,judgeClosure
的引用计数是2。
- 当超过作用域后,
student
和judgeClosure
之间的引用关系是这样的:
此时,只有Closure
对象的引用计数变成了1。于是Closure
继续引用了student
,student
继续引用了他的对象replyClosure
,而这个对象继续引用着judgeClosure
。这样就造成了一个引用循环,所以就会出现内存无法正常释放的情况。
4.2、closure属性的内部实现捕获self产生的循环引用
同样的这里我先利用Objective-C
的代码来举例,修改Student
类的代码如下:
1 | @implementation Student |
同样的在Build时编译器会警告,Capturing 'self' strongly in this block is likely to lead to a retain cycle
。
在Swift中虽然编译器不会警告,但也会产生同样产生循环引用问题。修改Student
中定义replyClosure
代码如下:
1 | lazy var replyClosure: (Answer) -> Void = { _ in |
为了保证在replyClosure
内部调用self
时replyClosure
闭包已经正确初始化了,所以采用了lazy
懒加载的方式。修改调用的代码为:
1 | Student(name: "Tom").replyClosure(.B) |
运行代码,打印结果:
1 | ==========Student init==========Kate Bell |
由于Student
实例和replyClosure
是互相强持有关系,即使超出了作用域他们之间依然存在着引用,所以内存不能有效释放。此时他们之间的引用关系是:
在Objective-C
中一般采用弱引用的方式解决Block
和实例循环引用的问题,这里对Block
和类实例之间产生循环引用的原因不做赘述,关于Objective-C
中Block
的更多使用细节可查阅Objective-C高级编程: iOS与OS X多线程和内存管理一书和苹果官方文档Getting Started with Blocks的内容。那么在Swift中该如何处理循环引用呢?在Swift中需要根据closure
和class
对象生命周期的不同,而采用不同的方案来解决循环引用问题。
5、无主引用(unowned)
5.1、使用unowned处理closure和类对象的引用循环
为了更易理解,我修改Teacher
类的代码:
1 | class Teacher { |
这里只考虑student
和teacher
之间的引用关系,此时student和
closure`之间存在着强引用关系,他们的引用计数都是2,在内存中他们之间的引用关系为:
超过作用域后,由于互相存在强引用student
和clousre
的引用计数并不为0,所以内存无法销毁,此时他们之间的引用关系为:
对于这种引用关系在前面的ARC就已经说过,把循环的任意一方变成unowned
或weak
就好了。我将student
设置为无主引用,代码如下:
1 | let judgeClosure = { [unowned student] (answer: Answer) in |
使用无主引用后,他们之间的引用关系如下图所示:
运行代码并打印:
1 | // 运行 |
可以看到student
和teacher
都可以正常被回收了,说明closure
的内存也被回收了。当closure
为nil
时,student
对象就会被ARC
回收,而当student
为nil
时,teacher
也就失去了他的作用会被ARC回收其内存。
5.2、unowned
并不能解决所有的循环引用问题
虽然unowned
能解决循环引用问题,但并不意味着,遇到的所有closure
循环引用问题都可以用无主引用(unowned)来解决:
5.2.1、示例代码一
同样用Student
对象来举例,在”Kate Bell”学生回答完问题后,另一个Tom
又回答了问题,他选择了C答案,代码如下:
1 | var student = Student(name: "Kate Bell") |
运行代码,程序会Crash并报错,error: Execution was interrupted, reason: signal SIGABRT.
来分析一下为什么会这样:
- 代码中首先创建了一个名为
Kate Bell
的学生对象,judgeClosure
捕获了这个student
对象 - 当
student = Student(name: "Tom")
之后,由于judgeClosure
是按照unowned
的方式捕获的,此时judgeClosure
内的student
对象实际上已经不存了 - 名为
Tom
的student
对象引用了replyClosure
闭包 - 调用
student.replyClosure(.C)
的时候,replyClosure
之前捕获的student
对象已经不存在,此时就产生了Crash
5.2.1、示例代码二
那么,如果我将 student.replyClosure = judgeClosure
移动到最前面呢?修改代码如下:
1 | var student = Student(name: "Kate Bell") |
可以看到,名为"Kate Bell"
的student
对象正常销毁了,但是Tom
学生对象并没有正常销毁,这是由于replyClosure
闭包在其内部捕获了self
造成的循环引用。此时他们之间的引用关系为:
对于这种情况使用unowned
并不能解决循环引用问题,所以只能采用另一种解决循环引用的方案弱引用(weak),来告诉closure
当closure
所捕获的对象已经被释放时,就不用在访问这个对象了。
6、弱引用(weak)
6.1、使用weak处理closure和类对象之间的引用循环
为了解决上面的的循环引用问题,我把replyClosure
的代码修改为:
1 | lazy var replyClosure: (Answer) -> Void = { [weak self] _ in |
重新执行代码,可以看到Tom
学生对象可以正常释放了:
1 | // ==========Student init==========Kate Bell |
让self
为弱引用后,student
之间的引用关系是:
当我使用了weak
时,就意味这这个对象可能为nil
,而在closure
里捕获和使用一个Optional
的值可能会发生一些不可预期的问题,此处需要做unwrap
操作:
1 | lazy var replyClosure: (Answer) -> Void = { [weak self] _ in |
当离开作用域后,student
和closure
的引用计数都为0,他们的内存就会合理的释放,他们之间的引用关系如下图所示:
关于closure
和类对象之间的循环问题,如何判断两者之间是否会产生循环引用,要根据一个类对象是否真的拥有正在使用的closure
。如果类对象没有持有这个closure
,那么就不必考虑循环引用问题。
6.2、 weak并不能解决所有的循环引用问题
虽然unowned
和weak
能够解决Closure
和类实例之间的循环引用问题,但这并不表示在任何Closure
中都可以使用这种方案来解决问题。相反有时候滥用弱引用还会给带来一些诡异的麻烦和内存问题。
6.2.1、滥用弱引用可能会造成一些不必要的麻烦
这里我同样用Student
类来举例,为学生添加一个写作业的任务:
1 | func doHomeWork() { |
打印结果:
1 | ==========Student init========== |
为什么完成的作业是nil
呢?实际上这里并不需要使用弱引用,因为async
方法中使用的closure
并不归student
对象持有,虽然closure
会捕获student
对象,但这两者之间并不会产生循环引用,反而因为弱引用的问题学生对象被提前释放了,但是如果这里我使用了强制拆包就又可能导致程序Crash。所以正确的理解closure
和类对象的引用关系并合理的使用weak
和unowned
才能从本质上解决问题。
6.2.2、使用withExtendedLifetime改进这个问题
那么有没有方法可以避免在错误的使用了weak
之后造成的问题呢?这里可以使用Swift提供的withExtendedLifetime
函数,它有两个参数: 第一个参数是要延长生命的对象,第二个对象是clousre
,在这个closure
返回之前,第一个参数会一直存活在内存中,修改async closure
里的代码如下:
1 | let queue = DispatchQueue.global() |
重新编译代码,打印结果:
1 | ==========Student init========== |
6.2.3、改进withExtendedLifetime语法
虽然withExtendedLifetime
能过解决弱引用问题,如果有很多地方有要这样访问对象这样就很麻烦。这里有一个解决方案是对withExtendedLifetime
做一个封装,,给Optional
做一个扩展(extension)处理:
1 | extension Optional { |
调用的代码:
1 | func doHomeWork() { |
最终打印的结果和之前一样,并且我还可以其他地方调用:
1 | ==========Student init========== |
本文主要介绍了closure
基本概念、closure
的类型、closure
和类对象之间的内存问题及其解决方法,如果您发现我的理解有错误的地方,请指出。
本文参考:
The Swift Programming Language: Closures
The Swift Programming Language: Automatic Reference Counting