为什么会有写时复制
为什么引入值类型
在Swift中,Apple引入了值类型,相对于引用类型,值类型更加的高效和安全。
值类型存储在栈上,引用类型存储在堆上,效率比较高
使用值类型,有可能在编译阶段就把问题暴露出来。比如
1
2
3
4
5
6
7let 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 都是值类型。而平时使用的 Int
, Double
, Float
, String
, Array
, Dictionary
,Set
其实都是用结构体实现的,也是值类型。
Swift 中,值类型的赋值为深拷贝(Deep Copy),值语义(Value Semantics)即新对象和源对象是独立的,当改变新对象的属性,源对象不会受到影响,反之同理。
如果声明一个值类型的常量,那么就意味着该常量是不可变的(无论内部数据为 var
/let
)。
这样看来一切都没问题,但是在写代码的时候,这些值类型每次赋值的时候真的是重新在内存中拷贝一份吗?如果一个数组里存了上万个元素,现在把它赋值给另一个变量,就必须要拷贝所有元素,即使这两个数组的内容是完全一致的,那可以预见这对性能会造成多么糟糕的影响。
既然我们能够想到这样的问题,那苹果的工程师肯定也想到了。如何才能避免不必要的复制呢,Swift给出了优化方案:Copy-On-Write(写时复制),即只有当这个值需要改变时才进行复制行为。在Swift标准库中,Array、Dictionary和Set都是通过写时复制来实现的。
什么是写时复制?
1 | var x = [1,2,3] |
这个时候我们打印一下 x 和 y 的内存地址,这里我用的是 lldb 命令fr v -R [object]
来查看对象内存结构。
1 | (lldb) fr v -R x |
由此我们可以看到 x 和 y 的内存地址都是0x0000600001c0ac40
,说明 x 和 y 此时是共享同一个实例。这个时候我们再加上下面的代码:
1 | y.append(4) |
然后再打印一下地址:
1 | (lldb) fr v -R x |
可以看到 x 和 y 的内存地址不在相同了,说明此时它们不再共享同一个实例,y 进行了数据拷贝。
Array 结构体内部含有指向某块内存的引用。这块内存就是用来存储数组中元素。x 和 y 两个数组一开始是共享同一块内存。不过,当我们改变 y 的时候,这个共享会被检测到,内存中的数据被拷贝出来,改变以后赋值给了 y。昂贵的元素复制操作只在必要的时候发生,这就是写时复制。
内部实现
在结构体内部存储了一个指向实际数据的引用,在不进行修改操作的普通传递过程中,都是将内部的引用的引用计数+1,在进行修改时,对内部的引用做一次copy操作,再在这个复制出来的数据上进行真正的修改,从而保持其他的引用者不受影响。
值类型嵌套引用类型
上面我们提到的 Array 内部元素是 Int,两者都是值类型,那么如果 Array 内部的元素是引用类型呢,情况会不会发生变化?我们一起来看一下~
1 | class Student { |
断点1,school1 和 school2 的内存结构
1 | (lldb) fr v -R school1 |
当school1
赋值给school2
后,school1.student
和school2.student
的内存地址都是0x00006000024053e0
,其引用类型实例变量 name
的地址也都是 0x9000000105a02356
,它们共享同一个实例,其引用类型的实例变量也共享。
1.修改结构体内引用类型的实例变量的值
断点2,school1 和 school2 的内存结构
1 | (lldb) fr v -R school1 |
而执行school2.student.name = "小红"
后,school1.student
与 school2.student
的内存地址不变,其实例变量 name
内存地址都发生改变且相同,还是共享同一个实例变量,也就是说,虽然对值类型有所修改,但没有发生拷贝行为。
2.修改结构体内引用类型的值
断点3,school1 和 school2 的内存结构
1 | (lldb) fr v -R school1 |
school2.student
和school2.student.name
的内存地址都发生变化,而school1.student
和school1.student.name
的内存地址不变,说明,此时对结构体进行了拷贝行为,而student
这个引用类型是直接指向另一个实例,而不是对原实例进行修改。
自定义Struct如何实现写时复制
作为一个结构体的作者,你并不能免费获得这种特性,你需要自己进行实现。当你自己的类型内部含有一个或多个可变引用,同时你想要保持值语义,并且避免不必要的复制时,为你的类型实现写时复制是有意义的。
在 Swift 中,我们可以使用 isKnownUniquelyReferenced
函数来检查某个引用只有一个持有者。
1 | struct School { |