Swift 结构体在赋值后可能会共享可变对象。要让副本彼此独立,请使用值类型属性,或通过写时复制隐藏类存储。本指南为 Mac 桌面预览构建一个合成的瓦片行计算器,并检查编辑一个副本是否会让其他副本保持不变。
为什么复制结构体仍会改变原值?
假设桌面预览要计算一行瓦片的宽度。你复制其设置来尝试另一种瓦片宽度,并期望原预览保持不变。如果两个设置值都包含指向同一个可变对象的引用,这一期望就无法实现。
Swift 将结构体的存储值赋给另一个变量时,会复制这些值。对于类属性,该存储值是一个引用:被引用的对象仍然是共享的。包装器本身仍是结构体,但其可观察行为可能包含共享修改。这些规则源于 Swift 文档对值类型和引用类型的区分。
下面这个完整示例展示了问题。它只使用 Swift 标准库,因此不需要导入任何模块。
final class TileDimensions {
var width: Int
init(width: Int) {
self.width = width
}
}
struct SharedLayout {
let dimensions: TileDimensions
}
let original = SharedLayout(
dimensions: TileDimensions(width: 40)
)
let edited = original
edited.dimensions.width = 60
assert(original.dimensions === edited.dimensions)
assert(original.dimensions.width == 60)
print(original.dimensions.width) // 60
将属性或包装器声明为 let,并不会冻结对象的可变属性。它只能防止替换该引用或结构体值。Swift 的结构体和类章节明确区分了常量引用与不可变对象状态。
将这些对象放入数组也会保留它们共享的身份。数组副本会独立管理各自的元素,但类类型的元素本身是引用。替换元素只会影响一个数组;修改两个数组共同引用的对象,则两个数组都能观察到变化。Apple 在 Array 文档中说明了这一区别。
哪种设计能让计算使用独立的值?
选择实现方式前,先定义约定:执行 var edited = original 后,对任一布局进行任何受支持的编辑,都必须保证另一个布局计算出的宽度不变。类型的使用者不应需要了解其存储在内部是否共享。
下面的决策参考将这一约定应用于本地预览模型。这是一项基于文档所述复制规则得出的设计建议。
| 情况 | 适用设计 | 保持独立性的条件 |
|---|---|---|
| 设置由数字、字符串或其他具有值语义的组件构成 | 在结构体中直接存储这些值 | 检查嵌套组件是否也提供相互独立的可观察值 |
| 必须保留现有的类支持存储 | 使用私有写时复制存储 | 每次修改前都必须分离共享的可变状态 |
| 只在指定操作中需要单独快照 | 提供显式复制操作 | 说明普通赋值仍会共享对象,除非包装器也强制保证独立性 |
| 程序中的多个部分有意共同编辑一个对象 | 保留引用语义 | 在 API 中说明共享对象的行为 |
对于三个整数设置,直接将属性放在结构体中是最简单的选择。下面的类支持实现适用于改造现有存储设计,或学习边界如何运作;它并不能证明这种小型计算有性能优势。
写时复制允许两个值共享存储,直到其中一个发生改变。Apple 在 Explore Swift performance中介绍了将类包装在结构体中并应用写时复制,作为结合独立分配的存储与值语义的一种方式。仅仅包装类并不会实现这种行为。
单独提供显式的 copy() 方法也会留下漏洞:调用者仍可直接使用普通赋值,而不调用该方法。如果赋值属于你的值语义约定,包装器的修改路径就必须自动保证独立性。
如何实现并检查写时复制?
使用一个私有存储对象,在包装器内部构造它,并且只公开计算结果和受控的修改方法。在更改存储之前,调用 isKnownUniquelyReferenced。如果无法确认存储是唯一引用,就用独立副本替换该存储。Apple 专门在实现写时复制的文档中说明了这一流程。
这个示例计算使用说明性输入:三个瓦片,每个宽 40 点,相邻瓦片之间间隔 10 点。共有两个间隔,因此行宽为 3 * 40 + 2 * 10 = 140 点。将瓦片宽度增加到 60 后,行宽为 200 点。
要在 Mac 上运行完整程序,先在终端中运行 swift --version,确认 Swift 工具链可用;这也是 Swift 入门指南的建议。将以下代码保存为 Layout.swift,然后在该文件所在目录运行 swift Layout.swift。程序会在内存中完成计算并打印结果。
struct TileLayout {
private final class Storage {
let columns: Int
var tileWidth: Int
var gap: Int
init(columns: Int, tileWidth: Int, gap: Int) {
self.columns = columns
self.tileWidth = tileWidth
self.gap = gap
}
func copy() -> Storage {
Storage(
columns: columns,
tileWidth: tileWidth,
gap: gap
)
}
}
private var storage: Storage
init(columns: Int, tileWidth: Int, gap: Int) {
precondition((1...1000).contains(columns))
precondition((0...1000).contains(tileWidth))
precondition((0...1000).contains(gap))
storage = Storage(
columns: columns,
tileWidth: tileWidth,
gap: gap
)
}
var rowWidth: Int {
storage.columns * storage.tileWidth
+ (storage.columns - 1) * storage.gap
}
private mutating func ensureUniqueStorage() {
if !isKnownUniquelyReferenced(&storage) {
storage = storage.copy()
}
}
mutating func setTileWidth(_ width: Int) {
precondition((0...1000).contains(width))
ensureUniqueStorage()
storage.tileWidth = width
}
mutating func setGap(_ gap: Int) {
precondition((0...1000).contains(gap))
ensureUniqueStorage()
storage.gap = gap
}
}
var original = TileLayout(columns: 3, tileWidth: 40, gap: 10)
var edited = original
edited.setTileWidth(60)
assert(original.rowWidth == 140)
assert(edited.rowWidth == 200)
original.setGap(5)
assert(original.rowWidth == 130)
assert(edited.rowWidth == 200)
let snapshot = edited
edited.setGap(20)
assert(snapshot.rowWidth == 200)
assert(edited.rowWidth == 220)
print(original.rowWidth, snapshot.rowWidth, edited.rowWidth)
// Expected output: 130 200 220
程序不需要导入任何模块或外部软件包。接受的范围是有意设置的示例限制:列数为 1 到 1,000,宽度和间隔为 0 到 1,000 点。这样可确保整数计算处于有界范围内。超出范围的参数会触发前置条件失败;程序不会默默将其限制到边界值。如果之后这些设置器要接收来自桌面界面的可编辑文本,请在调用设置器前验证输入,并为用户提供修改输入的方式。
这些检查涵盖三个可观察的要求:编辑复制出的布局后,原布局保持不变;编辑原布局后,副本保持不变;保留的常量快照在后续编辑时仍保留原计算结果。测试关注的是行为,不要求特定的内存分配数量。研究环境中没有可用的 Swift,因此示例在该环境中经过检查但未执行;所示输出是预期结果。
包装器扩展时,哪些条件必须继续成立?
示例的独立性来自其完整的 API 边界。存储是私有的,没有初始化器接受外部仍被持有的存储对象,也没有 getter 返回存储对象。两个修改方法都会经过同一个分离操作。getter 只计算结果,不会更改存储。
添加功能时,请追踪每条可能改变可观察状态的新路径。新增的设置器、下标或编辑回调都需要与现有方法一样处理。即使 getter 本身是只读的,如果它返回可变子对象,调用者仍可绕过分离操作。
存储副本还必须涵盖与该值相关的所有可变状态。在这里,Storage.copy() 会复制三个整数,足以满足需求。如果存储以后包含一个类类型的可变子对象,将该子对象赋给新存储仍会保留另一个共享引用。应适当地复制该子对象,或将其替换为本身已提供值语义的组件。Array文档中对类类型元素行为的说明展示了同样的边界。
扩展计算器时,请按以下步骤操作:
- 在测试每个新增编辑操作前,先复制一个布局。
- 确认未修改的布局仍保留之前的计算结果。
- 交换角色再重复检查。
- 保留一个快照,执行几次后续编辑,再次检查该快照。
- 检查所有返回的属性和初始化器参数,确认没有可能逃逸出包装器的可变引用。
final 存储类可防止子类添加行为,但不会使其属性变为不可变。同样,将操作标记为 mutating 只允许它更新结构体;真正维持本示例独立性的是分离逻辑。
如何复制 Foundation 对象?
如果现有模型存储了 NSMutableString 之类的 Foundation 对象,实现存储分离时应使用具体类型文档所述的复制行为。Swift 赋值不会自动调用 Foundation 的复制方法。
Foundation 的 NSCopying协议定义了功能性复制,而其确切含义取决于具体类。其文档也允许在类及其内容均不可变时返回原对象。因此,对象身份本身并不能普遍地用来判断副本是否符合你的值语义约定。
如果需要从具有可变形式和不可变形式的类型中获得可变结果,请查阅 NSMutableCopying。Apple 说明了用于此目的的 mutableCopy(with:) 和便捷方法 mutableCopy()。没有这种区分的类应改为遵循 NSCopying。
对于瓦片计算器,实际边界很简单:作为输入接收的 Foundation 对象不能保留外部可变别名,让它继续修改你声称彼此独立的设置。接收对象时创建合适的独立快照,编辑前适当地分离,并返回值快照而不是内部可变对象。在依赖具体类的复制操作之前,先确认它如何处理任何嵌套的可变内容。
写时复制会让类型符合 Sendable 吗?
值独立性和并发符合性需要分别考虑。Apple 的 Sendable 文档要求,经检查的可发送结构体,其存储成员也必须可发送。仅仅因为外层类型是结构体,私有的可变存储类并不会因此满足该要求。
唯一性检查也不等于同步。Apple 警告,同时访问时 isKnownUniquelyReferenced 可能返回 true,并要求在此类使用周围进行适当同步。它检查的是强引用;弱引用或无主引用仍可能存在。因此,存储对包装器外部不可访问与唯一性检查同样重要。请参阅标准库函数记录的限制。
请将此示例限制在单个同步计算上下文中。如果后续版本需要跨并发域传递设置,简单的替代方案是使用包含可发送值属性的结构体,并通过检查确认其 Sendable 符合性。添加 @unchecked Sendable 会把正确性责任交给实现者;它不会补上缺失的协调机制。Apple 在 Sendable 参考文档中说明了这些要求。
对于小型桌面计算,只要任务允许,就优先使用直接的值属性。必须保留可变类存储时,应通过私有所有权、完整的存储副本、受控编辑和可观察的独立性检查来维持约定。这样便能得到可预测的结果:更改预览的工作设置后,每个保留的布局值仍有自己的计算结果。
来源
- Swift 中的值类型和引用类型 — Swift.org。
- 结构体和类 — 《Swift 编程语言》,Swift 项目。
- Array — Apple 开发者文档。
- 探索 Swift 性能 — Apple Developer。
- isKnownUniquelyReferenced(_:) — Apple 开发者文档。
- 入门 — Swift.org。
- NSCopying — Apple 开发者文档。
- NSMutableCopying — Apple 开发者文档。
- Sendable — Apple 开发者文档。