Consume noncopyable types in Swift
SwiftUI & UI Frameworks 进阶 20m

在 Swift 中消费不可复制类型

Consume noncopyable types in Swift

2024年6月10日

在 Apple 官方观看视频

一句话判断

Swift 的 noncopyable 类型今年补上了泛型和 extension 支持的关键拼图,让”编译器帮你防止资源重复使用”从理论变成了日常可用的工具。

这场 Session 讲了什么

Swift 去年引入了 noncopyable 类型(用 ~Copyable 标记),让你可以定义”不能被复制的类型”。今年 Session 的重点是”如何在实际项目中消费(consume)noncopyable 类型”——也就是当你的泛型参数、协议、extension 遇到 noncopyable 类型时该怎么处理。

Session 从值类型和引用类型的复制行为回顾开始,用银行转账(BankTransfer)的例子展示了为什么复制有时是危险的:转账被意外执行两次,或者因为某个地方持有了副本导致 deinit 不执行。然后用 ~Copyable 解决了这个问题——编译器在编译时就阻止你复制一个不应该被复制的值。

今年新增的关键能力包括:泛型参数可以声明为 ~Copyable、noncopyable 类型可以有 extension、noncopyable 类型可以遵循协议(有条件限制)。这些补全了 noncopyable 类型体系的基础设施,让它在真实项目中可用。

值得深挖的点

所有权三件套:consume、borrowing、inout

Noncopyable 类型引入了三个所有权关键字,它们不只是语法糖——每一个都对应不同的内存语义。

consume 是最激进的:它把值从变量里”拿走”,变量之后变成未初始化状态。适合”用完即弃”的场景——比如执行一个转账,执行完这个 transfer 对象就不应该再存在。

borrowing 是只读借用:你有权读取这个值,但不能修改或消费它。这和普通的 let 参数很像,但区别在于编译器会保证不会有人在你借用期间消费掉这个值。对于 noncopyable 类型来说,这个保证非常重要——没有它,你可能在读一个已经被别人拿走的值。

inout 是可变借用:你可以修改值,但调用者保证在函数执行期间不会有其他引用。这个和已有的 inout 语义一致,但扩展到了 noncopyable 类型。

这三个关键字的选择不是随意的。format(disk:) 不需要消费磁盘,所以用 borrowing。execute(transfer:) 执行完转账就不应该保留 transfer,所以用 consuming。编译器会强制你做正确的选择——如果你声明了 consuming 但没有实际消费值,编译器会报错。

泛型 + noncopyable:真正的挑战

单个 noncopyable 类型不难理解,但当你把 noncopyable 放进泛型上下文时,事情变得复杂。Optional<T> 能不能包装一个 noncopyable 的 TArray<T> 能不能存储 noncopyable 元素?

答案分别是”现在可以了”和”还不行”。Swift 标准库的 Optional 今年被扩展为支持 ~Copyable 的泛型参数。这意味着你可以写 Optional<BankTransfer> ——一个可能存在也可能不存在的转账。

Array 还不支持,因为 Array 的实现大量依赖复制语义(扩容时复制元素到新缓冲区)。这也是为什么 noncopyable 类型目前更适合”独占所有权”的资源管理模式,而不是数据容器模式。

泛型函数现在可以写 <T: ~Copyable> 来接受 noncopyable 参数。这意味着你可以写通用的 useAndDiscard(_ value: consuming T) 函数,它既接受 copyable 也接受 noncopyable 的值。对于 copyable 的值,consume 只是创建一个副本然后丢弃——没有实际损失。对于 noncopyable 的值,consume 真正转移所有权。

代码片段

用 ~Copyable 防止银行转账被重复执行

场景:设计一个只能执行一次的转账类型。

struct BankTransfer: ~Copyable {
    var amount: Decimal
    var isPending: Bool = true

    // deinit 确保未执行的转账被取消
    deinit {
        if isPending {
            cancel()
        }
    }

    mutating func run() {
        guard isPending else { return }
        // 执行转账逻辑...
        isPending = false
    }
}

// 消费 transfer,之后不能再用
func execute(transfer: consuming BankTransfer) {
    transfer.run()
    // transfer 在这里被销毁
}

坑:consuming 参数意味着调用者把值的所有权交给你了。调用之后,调用者手里的变量变成未初始化状态。

Borrowing 参数:只读访问不消费

场景:格式化一个磁盘,格式化后磁盘还要继续用。

struct FloppyDisk: ~Copyable {
    var data: Data

    // borrowing:只读访问,不拿走磁盘
    func format() -> Bool {
        // 读取磁盘信息进行格式化
        // 完成后磁盘仍然存在
        return true
    }
}

var myDisk = FloppyDisk(data: ...)
myDisk.format()  // 磁盘还在,可以继续用

坑:borrowing 参数不能被 mutate 或 consume。如果你需要在借用期间修改值,用 inout

泛型函数接受 noncopyable 参数

场景:写一个通用的”使用并丢弃”函数。

// T 可以是 copyable 也可以是 noncopyable
func useAndDiscard<T: ~Copyable>(_ value: consuming T) {
    // 使用 value...
    // value 在函数结束时被销毁
}

// 对 copyable 类型:创建副本消费,原值不受影响
let name = "hello"
useAndDiscard(name)
print(name)  // 仍然有效,String 是 copyable 的

// 对 noncopyable 类型:所有权真正转移
let transfer = BankTransfer(amount: 100)
useAndDiscard(transfer)
// transfer 在这里已经是未初始化的,不能再用

坑:~Copyable 作为泛型约束写在冒号后面,表示”不要求 Copyable”。如果省略 ~Copyable,泛型参数默认要求 Copyable。

最佳实践

新项目:如果你的代码涉及”独占所有权”的资源管理——文件句柄、网络连接、数据库事务、加密密钥——考虑用 ~Copyable 类型来包装它们。这比手动添加 isClosed 标志和 assertion 更安全,因为编译器在你写出”使用已关闭的资源”这类 bug 时直接报错。

已有项目~Copyable 是渐进式的。你不需要一次性把所有类型都标记为 noncopyable。建议从资源管理类开始:把你的 FileHandle、DatabaseConnection 这类类型标记为 ~Copyable,加上 deinit 做清理。调用方代码需要改成 consume/borrowing 语义,但这是有限范围的重构。泛型代码的改动更少——只要把 <T> 改成 <T: ~Copyable> 就能同时接受两种类型。

还有什么值得关注

  • Optional 现在支持包装 noncopyable 类型,Result 的支持也类似。
  • Noncopyable 类型可以有 extension,但 extension 不能添加 Copyable conformance——这是不可逆的。
  • 协议可以通过 <Self: ~Copyable> 来支持 noncopyable 的遵循者,标准库正在逐步适配。
WWDC 2024