现在位置: 首页 > Swift 教程 > 正文

Swift 值语义与写时复制

值语义(value semantics)说的是:一个值被复制之后,两个副本互不影响。

Swift 的结构体、枚举和标准库容器默认具备值语义,但结构体内部一旦持有引用类型,共享状态就可能悄悄出现。

这一篇从官方定义讲起,再拆开写时复制(copy-on-write)的原理和它带来的陷阱。


值语义与引用语义

《The Swift Programming Language》给出的定义是:值类型在被赋给变量或常量、或被作为参数传给函数时,它的值会被复制。

引用类型则相反:赋值或传参时不会复制,而是共享同一个实例。

换句话说,判断一个类型属于哪一种,只需要看三个操作:初始化、赋值、传参。

操作值类型(struct / enum)引用类型(class)
初始化创建一个独立的值创建一个实例,变量持有它的引用
赋值复制值,两份互不影响复制引用,两个变量指向同一个实例
传参传入副本,函数内修改不影响调用方传入引用,函数内修改调用方可见

下面用同一个坐标模型对比这两种行为。

实例

import Foundation

// 值类型:坐标
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

没有可变性时两者无法区分

一个常被忽略的事实是:当类型完全不可变时,值类型和引用类型在行为上无法区分。

因为没有任何操作能改变值本身,观察者永远看不到「两个副本」和「同一个实例」之间的差别。

值语义与引用语义的差别,只有在发生修改的那一刻才会显现。

实例

// 全部属性都是 let 的结构体
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)是一种延迟复制的策略:先共享,等真正要写入时再复制。

值语义与引用语义对比:var b = a 之后,值类型 b 是独立副本,改 b 不影响 a;引用类型 a 与 b 指向同一对象,改 b 影响 a。底部是 Array 的写时复制流程:赋值只复制缓冲区引用,首次写入且引用计数大于 1 时才真正复制出私有缓冲区

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。

思路是:用一个引用类型做缓冲区,结构体在写入前检查缓冲区是否被共享,是则先复制。

实例

import Foundation

// 引用类型缓冲区
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 却指向同一个对象。

实例

let sharedScore = Score(point: 60)
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 参数把调用方的变量借给函数,函数内的修改直接作用在原存储上,不产生副本。

实例

// 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 会在运行时终止程序,因为这种重叠访问的结果无法预期。

实例

var stepSize = 1

func increment(_ number: inout Int) {
    number += stepSize      // 读取全局变量
}

// 同时对 stepSize 进行「写」(作为 inout 传入)和「读」,运行时陷阱
increment(&stepSize)

运行这段代码会以类似下面的信息终止,地址每次不同:

Simultaneous accesses to 0x..., but modification requires exclusive access.

把同一个变量同时作为两个 inout 参数,则是编译期就能发现的错误。

实例

func balance(_ x: inout Int, _ y: inout Int) {
    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。