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文档中对类类型元素行为的说明展示了同样的边界。

扩展计算器时,请按以下步骤操作:

  1. 在测试每个新增编辑操作前,先复制一个布局。
  2. 确认未修改的布局仍保留之前的计算结果。
  3. 交换角色再重复检查。
  4. 保留一个快照,执行几次后续编辑,再次检查该快照。
  5. 检查所有返回的属性和初始化器参数,确认没有可能逃逸出包装器的可变引用。

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 参考文档中说明了这些要求。

对于小型桌面计算,只要任务允许,就优先使用直接的值属性。必须保留可变类存储时,应通过私有所有权、完整的存储副本、受控编辑和可观察的独立性检查来维持约定。这样便能得到可预测的结果:更改预览的工作设置后,每个保留的布局值仍有自己的计算结果。

来源