Fork me on GitHub

Swift写时复制

为什么会有写时复制

为什么引入值类型

在Swift中,Apple引入了值类型,相对于引用类型,值类型更加的高效和安全。

  1. 值类型存储在栈上,引用类型存储在堆上,效率比较高

  2. 使用值类型,有可能在编译阶段就把问题暴露出来。比如

    1
    2
    3
    4
    5
    6
    7
    let arr1 = NSMutableArray.init(array: ["a","b","c"], copyItems: false)
    arr1.removeObject(at: 0)
    //以上编译不会报错,由于arr1是引用类型,存储的是指针,指针不变就没问题,对指向的对象没有限制

    let arr2 = ["a","b","c"]
    arr2.remove(at: 0)
    //以上编译会报错,由于arr2是值类型

值类型,即每个实例保持一份数据拷贝。

在 Swift 中,典型的有 struct,enum,以及 tuple 都是值类型。而平时使用的 IntDoubleFloatStringArrayDictionarySet 其实都是用结构体实现的,也是值类型。

Swift 中,值类型的赋值为深拷贝(Deep Copy),值语义(Value Semantics)即新对象和源对象是独立的,当改变新对象的属性,源对象不会受到影响,反之同理。

如果声明一个值类型的常量,那么就意味着该常量是不可变的(无论内部数据为 var/let)。

这样看来一切都没问题,但是在写代码的时候,这些值类型每次赋值的时候真的是重新在内存中拷贝一份吗?如果一个数组里存了上万个元素,现在把它赋值给另一个变量,就必须要拷贝所有元素,即使这两个数组的内容是完全一致的,那可以预见这对性能会造成多么糟糕的影响。

既然我们能够想到这样的问题,那苹果的工程师肯定也想到了。如何才能避免不必要的复制呢,Swift给出了优化方案:Copy-On-Write(写时复制),即只有当这个值需要改变时才进行复制行为。在Swift标准库中,Array、Dictionary和Set都是通过写时复制来实现的。

什么是写时复制?

1
2
3
var x = [1,2,3]
var y = x
// 断点

这个时候我们打印一下 x 和 y 的内存地址,这里我用的是 lldb 命令fr v -R [object] 来查看对象内存结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
(lldb) fr v -R x
(Swift.Array<Swift.Int>) x = {
_buffer = {
_storage = {
rawValue = 0x0000600001c0ac40 {
......省略无用信息
}
}
}
}
(lldb) fr v -R y
(Swift.Array<Swift.Int>) y = {
_buffer = {
_storage = {
rawValue = 0x0000600001c0ac40 {
......省略无用信息
}
}
}
}

由此我们可以看到 x 和 y 的内存地址都是0x0000600001c0ac40,说明 x 和 y 此时是共享同一个实例。这个时候我们再加上下面的代码:

1
2
y.append(4)
// 断点

然后再打印一下地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
(lldb) fr v -R x
(Swift.Array<Swift.Int>) x = {
_buffer = {
_storage = {
rawValue = 0x000060000126b180 {
......省略无用信息
}
}
}
}
(lldb) fr v -R y
(Swift.Array<Swift.Int>) y = {
_buffer = {
_storage = {
rawValue = 0x0000600002301b60 {
......省略无用信息
}
}
}
}

可以看到 x 和 y 的内存地址不在相同了,说明此时它们不再共享同一个实例,y 进行了数据拷贝。

Array 结构体内部含有指向某块内存的引用。这块内存就是用来存储数组中元素。x 和 y 两个数组一开始是共享同一块内存。不过,当我们改变 y 的时候,这个共享会被检测到,内存中的数据被拷贝出来,改变以后赋值给了 y。昂贵的元素复制操作只在必要的时候发生,这就是写时复制。

内部实现

在结构体内部存储了一个指向实际数据的引用,在不进行修改操作的普通传递过程中,都是将内部的引用的引用计数+1,在进行修改时,对内部的引用做一次copy操作,再在这个复制出来的数据上进行真正的修改,从而保持其他的引用者不受影响。

值类型嵌套引用类型

上面我们提到的 Array 内部元素是 Int,两者都是值类型,那么如果 Array 内部的元素是引用类型呢,情况会不会发生变化?我们一起来看一下~

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Student {
var name: String
init(name: String) {
self.name = name
}
}

struct School {
var student = Student(name: "小明")
}

let school1 = School()
var school2 = school1
print(school1.student.name)
print(school2.student.name)
// 断点1

school2.student.name = "小红"
print(school1.student.name)
print(school2.student.name)
// 断点2

school2.student = Student(name: "小李")
print(school1.student.name)
print(school2.student.name)
// 断点3

输出结果:
小明
小明
小红
小红
小红
小李

断点1,school1 和 school2 的内存结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
(lldb) fr v -R school1
(Copy_On_WriteTest.ViewController.School) school1 = {
student = 0x00006000024053e0 {
name = {
_guts = {
_object = {
_object = 0x9000000105a02356
}
}
}
}
}
(lldb) fr v -R school2
(Copy_On_WriteTest.ViewController.School) school2 = {
student = 0x00006000024053e0 {
name = {
_guts = {
_object = {
_object = 0x9000000105a02356
}
}
}
}
}

school1 赋值给school2 后,school1.studentschool2.student的内存地址都是0x00006000024053e0,其引用类型实例变量 name 的地址也都是 0x9000000105a02356 ,它们共享同一个实例,其引用类型的实例变量也共享。

1.修改结构体内引用类型的实例变量的值

断点2,school1 和 school2 的内存结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
(lldb) fr v -R school1
(Copy_On_WriteTest.ViewController.School) school1 = {
student = 0x00006000024053e0 {
name = {
_guts = {
_object = {
_object = 0x9000000105a0235c
}
}
}
}
}
(lldb) fr v -R school2
(Copy_On_WriteTest.ViewController.School) school2 = {
student = 0x00006000024053e0 {
name = {
_guts = {
_object = {
_object = 0x9000000105a0235c
}
}
}
}
}

而执行school2.student.name = "小红" 后,school1.studentschool2.student 的内存地址不变,其实例变量 name 内存地址都发生改变且相同,还是共享同一个实例变量,也就是说,虽然对值类型有所修改,但没有发生拷贝行为。

2.修改结构体内引用类型的值

断点3,school1 和 school2 的内存结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
(lldb) fr v -R school1
(Copy_On_WriteTest.ViewController.School) school1 = {
student = 0x00006000024053e0 {
name = {
_guts = {
_object = {
_object = 0x9000000105a0235c
}
}
}
}
}
(lldb) fr v -R school2
(Copy_On_WriteTest.ViewController.School) school2 = {
student = 0x0000600002405400 {
name = {
_guts = {
_object = {
_object = 0x9000000105a02362
}
}
}
}
}

school2.studentschool2.student.name 的内存地址都发生变化,而school1.studentschool1.student.name 的内存地址不变,说明,此时对结构体进行了拷贝行为,而student 这个引用类型是直接指向另一个实例,而不是对原实例进行修改。

自定义Struct如何实现写时复制

作为一个结构体的作者,你并不能免费获得这种特性,你需要自己进行实现。当你自己的类型内部含有一个或多个可变引用,同时你想要保持值语义,并且避免不必要的复制时,为你的类型实现写时复制是有意义的。

在 Swift 中,我们可以使用 isKnownUniquelyReferenced 函数来检查某个引用只有一个持有者。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct School {
private var student = Student(name: "小明")

var studentName: String {
get {
return student.name
}
set {
if isKnownUniquelyReferenced(&student) {
student.name = newValue
}
else {
student = Student(name: newValue)
}
}
}
}
-------------本文结束感谢您的阅读-------------

本文作者:乔羽 / FightingJoey

发布时间:2019年01月22日 - 17:27

最后更新:2019年03月04日 - 14:34

原始链接:https://fightingjoey.github.io/2019/01/22/开发/Swift写时复制/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

坚持原创技术分享,您的支持将鼓励我继续创作!