Swift 学习笔记——weak 和 unowned 的区别

Swift 学习笔记——weak 和 unowned 的区别

在 Swift 中,使用 weakunowned 关键字定义变量是解决循环引用的两个方法,今天来研究一下这两个方法的区别。

前置知识

首先简单说一下循环引用是什么,由于一个引用类型的实例可以被多个所有者引用,由此会产生生命周期的管理问题,所以 Swift 使用了ARC 自动引用计数(Automatic Reference Counting)进行内存管理,来追踪引用类型实例的引用计数。简单的说,每当有一个所有者引用一个引用类型的实例,这个引用类型的实例引用计数就会加一,当一个实例的引用计数归零,也就是没有哪个变量再引用这个实例,就销毁这个实例,此时调用 deinit 方法释放内存。

而循环引用,就是两个或多个对象之间互相引用彼此,导致无法释放内存的现象,也就是内存泄漏。最常见的引用类型就是类,举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Parent {
var child: Child?
init() {}
init(child: Child) {
self.child = child
}
deinit {
print("Parent has been deinit")
}
}

class Child {
var parent: Parent
init(parent: Parent) {
self.parent = parent
}
deinit {
print("Child has been deinit")
}
}

我们设计两个类,Parent 类持有一个可选的 Child 类的变量, Child 类持有一个 Parent 类的变量,然后稍微改造一下两个类的 deinit 方法,使得我们可以观察实例是否被销毁,然后执行以下代码:

1
2
3
4
5
var parent: Parent? = Parent() // parent: 1,child: 0,创建引用1
var child: Child? = Child(parent: parent!) // parent: 2,child: 1,创建引用2、4
parent?.child = child // parent: 2,child: 2,创建引用3
parent = nil // parent: 1,child: 2,断开引用1
child = nil // parent: 1,child: 1,断开引用2

引用示意图2

创建两个变量,分别持有两个 ParentChild 类的实例,并且两个实例互相持有,然后将两个变量设置为 nil,在每行代码后标注当前两个实例的引用数量,以及引用关系的变化,观察控制台,并没有执行 deinit 方法,也就是两个实例并没有被销毁,并且由于此时已经没有指向这两个实例的变量,我们无法再访问到这两个实例,除非程序的生命周期结束,否则这两个实例将会一直存在于内存中,这就是循环引用。配合示意图我们可以看出,引用关系3和4没有被断开,构成了循环引用。

如果我们想要取消引用,必须在将 parent 变量设置为 nil 之前,手动的将 parent 实例对 child 实例的引用取消,也就是执行 parent?.child = nil,此时再观察控制台,deinit 方法可以正确执行,实例如同预期一般的被销毁。

weakunowned 就是为了解决循环引用的。

weak

我们可以使用 weak var 来声明一个变量,使用 weak var 将一个实例赋值给一个变量,不会增加这个实例的引用计数,如果第一次声明一个类实例就使用 weak var,会导致这个实例在 init 的同时被 deinit,同时 Xcode 会有编译警告。

1
2
3
weak var parent: Parent? = Parent()
// Swift Compiler Warning:Instance will be immediately deallocated because variable 'parent' is 'weak'
// Swift 编译警告:实例会被立即销毁,因为变量 'parent' 使用 'weak' 进行声明

因为使用 weak var 定义的变量不增加引用计数,同时引用计数归零时实例会调用 deinit,置为 nil,也就是说,一个 weak var 变量指向的实例不会因为有 weak var 变量的引用就无法销毁,一旦这个实例被销毁,变量变为 nil,所以 weak var 变量必须是可选值。同样的,如果变量不是可选值,Xcode 会有编译错误。

1
2
3
weak var parent: Parent = Parent()
// Swift Compiler Error:'weak' variable should have optional type 'Parent?'
// Swift 编译错误:使用 'weak' 声明的变量应该是可选类型 'Parent'

此时我们对 Parent 类进行一点点改变,使用 weak var 声明 child 属性,此时 Parent 类的代码如下:

1
2
3
4
5
6
7
8
9
10
class Parent {
weak var child: Child?
init() {}
init(child: Child) {
self.child = child
}
deinit {
print("Parent has been deinit")
}
}

然后再一次执行和之前一次一样的代码段,注意引用数量的区别:

1
2
3
4
5
var parent: Parent? = Parent() // parent: 1,child: 0,创建引用1
var child: Child? = Child(parent: parent!) // parent: 2,child: 1,创建引用2、4
parent?.child = child // parent: 2,child: 1,创建引用3(由于parent的child属性使用weak var声明,所以此时令parent的child属性持有我们创建的child实例并不会改变child实例的引用计数)
parent = nil // parent: 1,child: 1,断开引用1
child = nil // parent: 0,child: 0,断开引用2

引用示意图2

再次观察引用关系的变化,执行完代码后,引用关系1、2都已经断开了,由于引用关系3并不计入引用计数,此时 child 实例引用计数为0,表现到引用关系图中就是只有一条虚线指向 child 实例,所以 child 实例被销毁,由于 child 实例被销毁,所以引用4也断开,parent 实例销毁,两个实例都被正常销毁。观察控制台输出可以验证我们的理论,child 实例先销毁,parent 实例后销毁。

1
2
3
// Console Output:
// Child has been deinit
// Parent has been deinit

unowned

如果细心的话,你会注意到,要使用 weak 的话,变量必须是一个可选值,但有时候我们不希望变量是可选的,此时就要使用 unowned 来对变量进行标记。变量使用 unowned 进行标记,持有一个实例,同样不会增加这个实例的引用计数

这次我们对两个类再次进行改造。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Parent {
var child: Child?
init() {}
init(child: Child) {
self.child = child
}
deinit {
print("Parent has been deinit")
}
}

class Child {
unowned var parent: Parent
init(parent: Parent) {
self.parent = parent
}
deinit {
print("Child has been deinit")
}
}

Child 类的 parent 属性改为 unowned。此时执行一样的代码段:

1
2
3
4
5
var parent: Parent? = Parent() // parent: 1,child: 0,创建引用1
var child: Child? = Child(parent: parent!) // parent: 1,child: 1,创建引用2、4
parent?.child = child // parent: 1,child: 2,创建引用3
parent = nil // parent: 0,child: 2,断开引用1,此时parent实例被销毁,child引用数也降为1
child = nil // parent: 0,child: 0,断开引用2,此时child实例被销毁

引用示意图3

两个实例均按预想顺序被正确销毁。

1
2
3
// Console Output:
// Parent has been deinit
// Child has been deinit

如果将 Parent 类的 child 属性设置为 unownedChild 类的 parent 属性不做改动,也一样可以正确销毁,只是销毁顺序不一样。

但是我们必须确保引用者的生命周期比被引用者的生命周期更长,在这个例子里,我们必须确保 parent 实例的生命周期比 child 实例的生命周期更长,因为当一个 unowned 声明的变量指向的实例被释放时,这个变量并不会被置为 nil,此时访问这个变量会发生一个运行时错误,运行以下代码:

1
2
3
4
5
6
var parent: Parent? = Parent()
var child: Child? = Child(parent: parent!)
parent?.child = child
parent = nil
print(child?.parent)
// error: Execution was interrupted, reason: signal SIGABRT.

在执行 print 函数的时候,发生了运行时错误,因为当我们把 parent 变量置为 nil 时,parent 实例已经被销毁,此时通过 childparent 属性访问 parent 实例,这块内存已经被标记为无效,所以发生了运行时错误。

闭包中的应用

在 Swift 中,并非只有类是引用类型,函数以及特殊形式的函数——闭包,同样也都是引用类型。

闭包对闭包内元素的引用都会被自动持有,有时候我们在使用闭包的时候会在闭包中调用 self 属性,此时对 self 就形成了循环引用,self 持有闭包,闭包持有 self。最简单的闭包循环引用如下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class A {
var num: Int
lazy var closure: () -> () = {
print(self.num)
}
init(num: Int) {
self.num = num
}
deinit {
print("A has been deinit")
}
}

var a: A? = A(num: 3)
a!.closure()
a = nil
// 并无 deinit 调试信息

这时候我们需要在闭包中显式的将对 self 的引用声明为 weak 或者 unowned,改造如下:

1
2
3
4
5
6
lazy var closure: () -> () = {
[weak self] in
if let num = self?.num {
print(num)
}
}

此时可以正确的销毁 A 的实例,因为闭包对 self 的引用已经不计入 ARC 了。

weak和unowned的选择

简单的来说,对于生命周期可以确定,或者变量不能是可选值的情况下,可以使用 unowned 标记循环中生命周期较长的那个变量。如果生命周期无法确定,就使用 weak 标记。

据说 unowned 的性能要比 weak 稍好,对于性能极其敏感的应用场景可能需要考虑使用 unowned

必须为可选值 必须确定生命周期 相对性能
weak 稍差
unowned 稍好

Swift 学习笔记——weak 和 unowned 的区别
https://wenchanyuan.com/swift_difference_of_weak_and_unowned/
作者
蟾圆
发布于
2020年8月13日
许可协议