Swift 值语义与写时复制
值语义(value semantics)说的是:一个值被复制之后,两个副本互不影响。
Swift 的结构体、枚举和标准库容器默认具备值语义,但结构体内部一旦持有引用类型,共享状态就可能悄悄出现。
这一篇从官方定义讲起,再拆开写时复制(copy-on-write)的原理和它带来的陷阱。
值语义与引用语义
《The Swift Programming Language》给出的定义是:值类型在被赋给变量或常量、或被作为参数传给函数时,它的值会被复制。
引用类型则相反:赋值或传参时不会复制,而是共享同一个实例。
换句话说,判断一个类型属于哪一种,只需要看三个操作:初始化、赋值、传参。
| 操作 | 值类型(struct / enum) | 引用类型(class) |
|---|---|---|
| 初始化 | 创建一个独立的值 | 创建一个实例,变量持有它的引用 |
| 赋值 | 复制值,两份互不影响 | 复制引用,两个变量指向同一个实例 |
| 传参 | 传入副本,函数内修改不影响调用方 | 传入引用,函数内修改调用方可见 |
下面用同一个坐标模型对比这两种行为。
实例
// 值类型:坐标
struct PointValue {
var x: Int
var y: Int
}
// 引用类型:坐标
final class PointReference {
var x: Int
var y: Int
init(x: Int, y: Int) {
self.x = x
self.y = y
}
}
var p1 = PointValue(x: 1, y: 2)
var p2 = p1 // 赋值:复制出一个独立的值
p2.x = 100 // 修改 p2,p1 完全不受影响
var q1 = PointReference(x: 1, y: 2)
var q2 = q1 // 赋值:两个变量指向同一个实例
q2.x = 100 // 修改 q2,q1 看到的是同一份数据
print("值类型 p1:\(p1.x), \(p1.y)")
print("值类型 p2:\(p2.x), \(p2.y)")
print("引用类型 q1:\(q1.x), \(q1.y)")
print("引用类型 q2:\(q2.x), \(q2.y)")
执行以上代码输出:
值类型 p1:1, 2 值类型 p2:100, 2 引用类型 q1:100, 2 引用类型 q2:100, 2
传参时遵循同一套规则。
实例
func moveValue(_ point: PointValue) -> PointValue {
var copy = point
copy.x += 1
return copy
}
// 传引用:参数和实参是同一个实例
func moveReference(_ point: PointReference) {
point.x += 1
}
var value = PointValue(x: 0, y: 0)
let moved = moveValue(value)
print("传参后 value.x = \(value.x),返回值 moved.x = \(moved.x)")
var reference = PointReference(x: 0, y: 0)
moveReference(reference)
print("传参后 reference.x = \(reference.x)")
执行以上代码输出:
传参后 value.x = 0,返回值 moved.x = 1 传参后 reference.x = 1
没有可变性时两者无法区分
一个常被忽略的事实是:当类型完全不可变时,值类型和引用类型在行为上无法区分。
因为没有任何操作能改变值本身,观察者永远看不到「两个副本」和「同一个实例」之间的差别。
值语义与引用语义的差别,只有在发生修改的那一刻才会显现。
实例
struct ImmutablePointValue {
let x: Int
let y: Int
}
// 全部属性都是 let 的类
final class ImmutablePoint {
let x: Int
let y: Int
init(x: Int, y: Int) {
self.x = x
self.y = y
}
}
var a = ImmutablePointValue(x: 1, y: 2)
var b = a // 复制
var c = ImmutablePoint(x: 1, y: 2)
var d = c // 共享
// 没有任何办法修改 a、b、c、d 内部的数据,
// 所以「复制」和「共享」在这里的可观察行为完全一致
print("a = (\(a.x), \(a.y))")
print("b = (\(b.x), \(b.y))")
print("c = (\(c.x), \(c.y))")
print("d = (\(d.x), \(d.y))")
print("c 与 d 是同一个实例:\(c === d)")
执行以上代码输出:
a = (1, 2) b = (1, 2) c = (1, 2) d = (1, 2) c 与 d 是同一个实例:true
值类型没有身份(identity)的概念,不能用 === 比较,这也让它在不可变时更简单。
注意:这里说的「无法区分」只针对可观察行为。引用类型仍然可以用
===判断身份,只是在全不可变的场景下,这个身份差异不影响任何业务结果。
struct 不等于值语义
写 struct 只是让类型在语法上按值复制,并不自动带来完整的值语义。
因为复制的是结构体的存储内容:如果里面存的是一个引用,复制出来的只是这个引用,底层对象依然共享。
反过来,一个完全不可变的类,在使用上也可以表现得像值。
要判断一个 struct 是否真的具备值语义,得看它的每一个存储属性是否也具备值语义。
实例
// 但它的 score 属性是引用类型,共享状态依然存在
final class Score {
var point: Int
init(point: Int) {
self.point = point
}
}
struct Student {
var name: String
var score: Score
}
let sharedScore = Score(point: 60)
var studentA = Student(name: "小明", score: sharedScore)
var studentB = studentA // 结构体被复制,但 score 只是复制了引用
print("两个 score 是同一个实例:\(studentA.score === studentB.score)")
执行以上代码输出:
两个 score 是同一个实例:true
标准库的 Array 也是一个 struct,但它是真正具备值语义的。
秘密在于它内部的缓冲区实现了写时复制,下一次修改时会把共享的缓冲区复制一份私有副本。
写时复制(COW)的原理
写时复制(copy-on-write,简称 COW)是一种延迟复制的策略:先共享,等真正要写入时再复制。
以 Array 为例,它内部持有堆上的缓冲区,缓冲区带一个引用计数。
把数组赋给另一个变量时,只复制缓冲区的引用并把计数加一,元素一个都不会被复制。
只有当某个副本要写入、而缓冲区的引用计数大于 1 时,才会真正复制出一份私有缓冲区。
如果引用计数等于 1,说明没有别人在用,写入直接原地进行,没有任何额外开销。
| 条件 | 说明 |
|---|---|
| 底层缓冲区被多处共享 | 缓冲区的引用计数大于 1 |
| 发生写入操作 | append、下标赋值、mutating 方法等 |
两个条件同时满足才会真正复制;只读遍历、只赋值不修改,都不会产生副本。
实例
var list1 = [1, 2, 3]
var list2 = list1
// 写入时才真正复制:list2 拿到自己的缓冲区
list2.append(4)
print("list1 = \(list1)")
print("list2 = \(list2)")
执行以上代码输出:
list1 = [1, 2, 3] list2 = [1, 2, 3, 4]
注意:COW 是一种优化,不是语义承诺。它保证的是行为上互不影响,至于内部是否共享缓冲区,属于实现细节。
自己实现写时复制
标准库用 isKnownUniquelyReferenced(&) 判断缓冲区是否唯一,我们也可以用它给自定义类型加上 COW。
思路是:用一个引用类型做缓冲区,结构体在写入前检查缓冲区是否被共享,是则先复制。
实例
// 引用类型缓冲区
final class Box<T> {
var value: T
init(_ value: T) {
self.value = value
}
}
// 具备写时复制能力的结构体
struct RunoobBox<T> {
private var box: Box<T>
init(_ value: T) {
box = Box(value)
}
var value: T {
get { return box.value }
set {
// 缓冲区被别人共享时先复制一份,否则直接原地修改
if !isKnownUniquelyReferenced(&box) {
box = Box(newValue)
} else {
box.value = newValue
}
}
}
}
var x = RunoobBox("www.runoob.com")
var y = x // 此时 x 和 y 共享同一个 Box
y.value = "RUNOOB" // y 写入时发现缓冲区不唯一,先复制
print("x = \(x.value)")
print("y = \(y.value)")
执行以上代码输出:
x = www.runoob.com y = RUNOOB
注意 isKnownUniquelyReferenced 的参数必须是类实例的可变引用,所以要写成 &box 传入,而且传入的那个引用本身要处于可写状态。
值类型嵌套引用类型的陷阱
把值类型和引用类型混在一起,最容易出问题的是「改了一个,另一个也跟着变」。
下面这个例子里,Student 是结构体,但它的 score 属性是引用类型。
复制结构体时,name 是独立的,score 却指向同一个对象。
实例
var studentA = Student(name: "小明", score: sharedScore)
var studentB = studentA // 结构体被复制
studentB.name = "小红" // 改名字:A 不受影响
studentB.score.point = 100 // 改分数:底层还是同一个 Score 实例
print("A:\(studentA.name) 的分数是 \(studentA.score.point)")
print("B:\(studentB.name) 的分数是 \(studentB.score.point)")
执行以上代码输出:
A:小明 的分数是 100 B:小红 的分数是 100
名字是独立的,分数却串了,这正是「struct 不等于值语义」的实际后果。
要避开这个陷阱,有三种常见做法。
| 做法 | 说明 |
|---|---|
| 让内部引用类型不可变 | 所有属性用 let,共享也就无所谓 |
| 内部类型也实现 COW | 写入时检测共享并复制,像标准库容器那样 |
| 在复制时手动深拷贝 | 复制结构体时同步复制内部对象,成本较高 |
inout 与独占访问
值类型默认按值传递,但这不代表每次传参都一定复制。
inout 参数把调用方的变量借给函数,函数内的修改直接作用在原存储上,不产生副本。
实例
func addBonus(_ score: inout Int) {
score += 10
}
var runoobScore = 60
addBonus(&runoobScore)
print("加分后:\(runoobScore)")
// 对结构体也是原地修改
struct RunoobCounter {
var count = 0
mutating func increase() {
count += 1
}
}
func increaseTwice(_ counter: inout RunoobCounter) {
counter.increase()
counter.increase()
}
var counter = RunoobCounter()
increaseTwice(&counter)
print("计数:\(counter.count)")
执行以上代码输出:
加分后:70 计数:2
独占访问(exclusive access)是 inout 带来的约束:同一块内存在同一时刻只能有一个写访问。
如果一边写某个变量、一边又去读它,Swift 会在运行时终止程序,因为这种重叠访问的结果无法预期。
实例
func increment(_ number: inout Int) {
number += stepSize // 读取全局变量
}
// 同时对 stepSize 进行「写」(作为 inout 传入)和「读」,运行时陷阱
increment(&stepSize)
运行这段代码会以类似下面的信息终止,地址每次不同:
Simultaneous accesses to 0x..., but modification requires exclusive access.
把同一个变量同时作为两个 inout 参数,则是编译期就能发现的错误。
实例
let total = x + y
x = total / 2
y = total - x
}
var playerOneScore = 42
var playerTwoScore = 30
balance(&playerOneScore, &playerTwoScore) // 两个不同变量,合法
print("玩家一:\(playerOneScore),玩家二:\(playerTwoScore)")
// balance(&playerOneScore, &playerOneScore) // 编译错误:inout 参数不能互相别名
执行以上代码输出:
玩家一:36,玩家二:36
把那行注释打开会得到编译错误:
error: inout arguments are not allowed to alias each other
此外,结构体的 mutating 方法执行期间,实例也处于独占访问状态,期间不能通过其它途径访问同一个实例。
常见问题
下面几个问题在理解值语义时最常出现。
结构体赋值一定会复制吗
不一定。复制是语义上的保证,但编译器可以做优化。
像 Array 这样内部实现了 COW 的类型,赋值时并不会立刻复制元素,只在写入时才复制。
为什么修改数组的副本会突然变慢
因为这一次写入触发了真正的缓冲区复制。
如果副本很多、数组很大,第一次写入的开销就会比较明显;之后的写入由于缓冲区已经私有,又会恢复成原地修改。
值语义和线程安全是一回事吗
不是。值语义意味着副本互不影响,但不代表共享的引用类型就是线程安全的。
如果结构体内部持有可变的引用类型,多个副本仍会并发访问同一个对象,该加锁还是要加锁。
什么时候该用 class
需要身份(用 === 区分)、需要继承、或者确实需要共享同一份可变状态时,用 class。
其余情况优先用 struct,同时留意内部的引用类型属性,必要时为它补上 COW。
