在 Swift 中消费不可复制类型
Consume noncopyable types in Swift
2024年6月10日
一句话判断
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 的 T?Array<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 不能添加
Copyableconformance——这是不可逆的。 - 协议可以通过
<Self: ~Copyable>来支持 noncopyable 的遵循者,标准库正在逐步适配。