当对象作为字典键时,保持用于相等性判断和哈希计算的属性稳定。在 Mac 上进行本地 Swift 计算时,应根据查找的含义选择对象标识、不可变 ID 或值快照。

本指南使用合成矩形和完全保存在内存中的小型面积计算。实际任务是:允许矩形的标签或尺寸变化,同时不丢失其关联的字典条目,也不把查找成功误认为计算结果已更新。

为什么更改对象会破坏字典查找?

Swift 的 Dictionary 是一种哈希表,其键遵循 Hashable。基于键的下标返回可选值,因为请求的键可能不存在。哈希有助于定位条目;相等性则决定哪些键视为等价。参见 Apple 的 Dictionary 文档。

Hashable 合约将这些操作关联起来:相等的实例必须按相同顺序向 Hasher 提供相同的关键组成部分。实现自定义键时,应在 hash(into:) 中使用定义相等性的属性。Apple 在 Hashable 中说明了这一要求。

引用类型的键还涉及另一个方面。赋值类实例时,传递的是指向现有对象的引用,而不是创建独立对象。将引用声明为 let 并不会冻结其可变属性。这些语言规则记录在 Structures and Classes 中。

假设有一个矩形类,它根据可编辑标签进行比较和哈希计算。插入对象时标签为 "Tile A",随后将标签更改为 "Tile B"。此时,存储的引用暴露出的键定义状态已经不同。字典原有的存放位置与后续查找不再有稳定一致的依据。

根据哈希表和相等性合约,可以得出这条设计规则:键在字典中期间,应保持其定义相等性的状态稳定。Apple 针对 Foundation 对象明确提出了相应要求:对象属于使用其哈希进行存放的集合期间,其哈希不得改变。参见 NSObjectProtocol.hash。

不要围绕“每次违反约定的变更都会立即返回 nil”这一假设来编写测试。键合约被破坏后,看似成功的查找不足以证明设计有效。应测试预期的相等性规则和稳定行为。

哪种键适合这项任务?

编写遵循协议的实现之前,先确定字典条目代表什么。对于矩形面积工具,以下三个问题会导向不同的设计:

条目应代表什么? 适用的键 矩形发生变化时会怎样?
这个确切的矩形对象 基于对象标识的类键 条目仍与该实例关联
一个由当前对象状态之外的标识确定的逻辑矩形 不可变 ID 值 条目仍与该 ID 关联
针对特定尺寸的计算结果 不可变尺寸快照 尺寸变化会产生不同的查找键

此表是根据 Swift 的标识、值和哈希规则整理的设计参考,也区分了两项责任:保持关联可查找,以及保持计算结果为最新。稳定的标识键解决第一项;重新计算或完整的输入快照则解决第二项。

当条目属于某个实例时,使用对象标识

若内存中的关联应在对象经过编辑后仍跟随该对象,可使用 === 定义相等性,并对其 ObjectIdentifier 进行哈希计算。Swift 的标识运算符用于测试引用是否指向同一个实例。Apple 将 ObjectIdentifier 记录为可哈希标识符,其比较在实例生命周期内有效。参见 Structures and Classes 和 ObjectIdentifier。

以下是一个完整的同步程序。你需要一台安装了 Swift 命令行工具的 Mac;程序只使用自动导入的 Swift 标准库。将其保存为 IdentityKeys.swift,然后在终端中运行 swift IdentityKeys.swift。这些示例已检查,但由于研究环境中没有 Swift,无法实际运行。

final class Rectangle: Hashable {
    var label: String
    var width: Int
    var height: Int

    init(label: String, width: Int, height: Int) {
        self.label = label
        self.width = width
        self.height = height
    }

    static func == (lhs: Rectangle, rhs: Rectangle) -> Bool {
        lhs === rhs
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(ObjectIdentifier(self))
    }
}

func demonstrateIdentityKeys() {
    let rectangle = Rectangle(label: "Tile A", width: 4, height: 3)
    let alias = rectangle
    let separate = Rectangle(label: "Tile A", width: 4, height: 3)

    var areas: [Rectangle: Int] = [
        rectangle: rectangle.width * rectangle.height
    ]

    rectangle.label = "Tile B"
    assert(areas[alias] == 12)
    assert(areas[separate] == nil)

    rectangle.width = 5
    assert(areas[rectangle] == 12) // Still the previous calculation.

    areas[rectangle] = rectangle.width * rectangle.height
    assert(areas[rectangle] == 15)
    assert(areas.count == 1)

    print("Renamed lookup: 12")
    print("Recalculated area: \(areas[rectangle]!)")
}

demonstrateIdentityKeys()

预期输出为 Renamed lookup: 12,随后是 Recalculated area: 15。这些断言表达了实用的合约:别名可以找到现有条目,单独创建的矩形无法找到它,编辑原始对象也不会创建另一个键。

宽度变更后,存储的面积会有意保持为 12,直到程序赋值 15。对象标识保持稳定,但字典不会追踪算术依赖,也不会重新计算值。请决定应用是在编辑后立即更新值,还是在下次计算前移除该值。

基于标识的相等性是作用于整个类型的决定。现在,这个类上的每次 == 比较都表示“同一个实例”。如果模型已经按尺寸或其他值定义了相等性,就应使用单独的键设计,而不是仅为某个字典改变这种含义。

也可以直接以 ObjectIdentifier 为字典键,但标识符本身无法提供矩形供后续操作使用。只要仍用标识符进行比较,就应确保相关对象保持存活,并移除不再需要的条目。其文档所述的生命周期限制意味着,这是一种有适用范围的内存内标识机制。参见 ObjectIdentifier。

当条目代表计算结果时,使用值快照

对于可复用的面积结果,尺寸本身通常适合作为键。两个尺寸均为 4 乘 3 的矩形,即使是标签不同的独立对象,也可以共用同一个结果。尺寸变化时,应查找不同的条目。

Swift 结构体具有值语义;当其存储属性遵循 Hashable 时,编译器可以合成 Hashable。因此,只包含不可变整数的快照可直接作为此项计算的键。参见 Structures and Classes 和 Hashable。

将下面这个完整替代方案单独保存为 SnapshotKeys.swift,并运行 swift SnapshotKeys.swift。其中的合成输入是较小的正整数,因此示例不需要通用数值输入接口。

final class EditableRectangle {
    var label: String
    var width: Int
    var height: Int

    init(label: String, width: Int, height: Int) {
        self.label = label
        self.width = width
        self.height = height
    }
}

struct AreaInputs: Hashable, Sendable {
    let width: Int
    let height: Int
}

func demonstrateSnapshotKeys() {
    let rectangle = EditableRectangle(
        label: "Tile A", width: 4, height: 3
    )
    var areas: [AreaInputs: Int] = [:]

    let original = AreaInputs(
        width: rectangle.width, height: rectangle.height
    )
    areas[original] = original.width * original.height

    rectangle.label = "Tile B"
    rectangle.width = 5

    let current = AreaInputs(
        width: rectangle.width, height: rectangle.height
    )
    assert(areas[original] == 12)
    assert(areas[current] == nil)

    areas[current] = current.width * current.height
    assert(areas[current] == 15)
    assert(areas.count == 2)

    print("Original area: \(areas[original]!)")
    print("Current area: \(areas[current]!)")
}

demonstrateSnapshotKeys()

预期输出先是 Original area: 12,然后是 Current area: 15。两个结果都仍可查找,因为程序插入了两个不同的快照。旧结果仍准确描述旧尺寸。

请将所有影响计算的输入纳入快照。标签与矩形面积无关,因此这里不包含标签。如果后续计算还依赖其他设置,也应将该设置加入键中。否则,不同计算可能会错误地共用一个条目。这是计算设计的要求,与键是否符合 Hashable 无关。

仅仅用结构体包装可变类引用,并不会自动生成该对象的冻结快照。应像此示例一样,将相关值复制到快照中。还应决定何时丢弃旧快照,以免反复编辑累积出不再需要的结果。

什么时候应使用不可变 ID?

如果多个对象实例应访问同一个逻辑条目,就使用 ID。对于此合成工具,不可变的整数 ID 可能已经足够:将结果存入 [Int: Int],通过矩形的 let id: Int 进行访问,并将可编辑标签和尺寸保留在键之外。

在这种设计下,ID 为 7 的两个对象会有意访问同一个字典条目。你的构造规则必须确保不同的逻辑矩形不会意外获得相同 ID。更新现有键时,其关联值会被替换,详见 Dictionary。

基于 ID 的面积表在尺寸发生变化后仍需要更新策略。如果要保留每一组不同输入的结果,请使用快照。如果只想保留每个逻辑矩形的最新结果,那么 ID 加上显式重新计算会更合适。

Foundation 字典或 actor 能解决可变键问题吗?

Foundation 的插入合约有所不同。NSMutableDictionary.setObject(_:forKey:) 要求键遵循 NSCopying,并在插入时复制键。如果键的复制实现能提供稳定的键状态,那么键副本可以形成快照。后续查找必须匹配该存储副本。参见 Apple 的 setObject(_:forKey:)。

不要据此推断 Swift 原生字典会独立复制作为键的类实例。Swift 类赋值具有引用语义。对于自定义 Foundation 键,应同时核实复制、相等性和哈希行为;Foundation 的哈希要求仍然适用。

并发是另一个独立问题。actor 会协调对其隔离可变状态的访问;Sendable 则描述符合其要求、可以跨并发域传递的值。二者都不会改变键的相等性含义,也无法修复插入后其定义状态发生变化的键。参见 Swift 的并发章节和 Apple 的 Sendable 文档。

快照示例显式声明了 Sendable,因为其中存储的整数满足该协议对值类型的要求。如果之后将面积计算移入 actor,应传递完整快照,并让 actor 持有结果字典。可变矩形可以继续留在其现有隔离域中。这些同步示例不需要 actor。

在依赖查找之前检查合约

用一组小型、确定的步骤验证所选设计:

  1. 插入条目并确认初次查找结果。
  2. 更改一个不参与相等性和哈希计算的属性;确认仍能找到同一条目。
  3. 将别名与单独创建的对象或快照进行比较;确认结果符合预期的相等性含义。
  4. 更改一项计算输入;确认结果已刷新、已移除,或通过新快照访问。
  5. 检查字典计数,确认替换和插入的行为符合预期。

如果现有实现会改变定义键的状态,应先修正键模型,再依赖后续查找。对于这项小型本地计算,从单独的合成输入列表重建结果,比将后续字典操作当作修复机制更清楚。请保留足够的原始输入以便重新计算,避免悄悄丢弃无法再找到的条目。

对于与某个活动对象的关联,选择对象标识;对于一个逻辑项目,选择不可变 ID;对于一项计算,选择值快照。然后明确结果更新策略。作出这些决定后,即使可编辑矩形发生变化,查找仍能保持明确含义。

来源