要在不改变插入顺序的前提下保持合成 Swift 值唯一,只需扫描输入数组一次,用集合记录已接受的键,并将每个值的首次出现追加到输出数组中。本指南将构建一个可在 Mac 上运行的小型本地计算示例。

任务是处理一份有序的合成形状值列表,移除重复值,并根据保留的条目计算总数。所有操作都在内存中完成;示例不会读取外部输入,也不会保存计算结果。

选择集合之前,先定义“唯一”的含义

Swift 数组会保留顺序,也允许重复值。集合只包含唯一值,但没有定义顺序。因此,将数组转换为集合再转回数组,并不能保留首次出现的顺序。之后再排序会建立一种排序顺序,这种顺序可能与插入顺序不同。Swift 的集合类型文档说明了这些集合的行为。

在此示例中,每个合成值都包含一个形状名称和一个尺寸。默认规则规定,只有这两个属性都相同的条目才算重复。因此,尺寸为 12 的圆形和尺寸为 20 的圆形仍是不同的值。

规则问题 本次计算的规则
什么决定输入顺序? 条目在给定数组中的位置
什么情况下两个值算重复? 形状和尺寸都相同
保留哪个条目? 首次出现的条目
后续重复项会改变保留值的位置吗? 不会
输入为空时会怎样? 返回空数组,总数为零
哪些值会计入总数? 仅保留的值

这套规则可以避免一个常见错误:当计算需要区分形状和尺寸的组合时,却仅按形状名称移除条目。示例还会计算仅按形状区分时的结果,让你看到更改规则的后果。

在 Mac 上构建完整的本地 Swift 示例

你需要安装 Swift 工具链。在 Terminal 中运行 swift --version,确认命令可用。Swift 官方的构建命令行工具教程介绍了这一检查、可执行程序包的创建以及如何运行程序包。

为示例创建一个新目录,并初始化一个可执行程序包:

mkdir OrderDemo
cd OrderDemo
swift package init --name OrderDemo --type executable

将 Sources 下生成的可执行源文件替换为以下完整程序。生成的文件名和目录结构可能因工具链而异;请替换现有入口点,不要再添加第二个入口点。此程序使用 @main 入口点,因此请将它放在名为 OrderDemo.swift 的文件中;如有必要,可将生成的 main.swift 文件重命名。命令行教程说明了使用 main.swift 文件和使用 @main 入口点之间的区别。

无需导入任何模块,也不需要包依赖。程序使用标准库类型和函数。

struct Tile: Hashable {
    let shape: String
    let size: Int
}

func uniqueFirst<Value, Key: Hashable>(
    _ values: [Value],
    by key: (Value) -> Key
) -> [Value] {
    var seen: Set<Key> = []
    var result: [Value] = []

    for value in values {
        if seen.insert(key(value)).inserted {
            result.append(value)
        }
    }

    return result
}

@main
struct OrderDemo {
    static func main() {
        let input = [
            Tile(shape: "circle", size: 12),
            Tile(shape: "square", size: 8),
            Tile(shape: "circle", size: 12),
            Tile(shape: "circle", size: 20),
            Tile(shape: "square", size: 8)
        ]

        let retained = uniqueFirst(input, by: { $0 })
        let total = retained.reduce(0) { $0 + $1.size }
        let skipped = input.count - retained.count

        let expected = [
            Tile(shape: "circle", size: 12),
            Tile(shape: "square", size: 8),
            Tile(shape: "circle", size: 20)
        ]
        precondition(retained == expected)
        precondition(total == 40)
        precondition(skipped == 2)

        let empty: [Tile] = []
        precondition(uniqueFirst(empty, by: { $0 }).isEmpty)
        precondition(
            uniqueFirst([input[0], input[0]], by: { $0 })
                == [input[0]]
        )

        let byShape = uniqueFirst(input, by: { $0.shape })
        precondition(byShape == [input[0], input[1]])
        let shapeTotal = byShape.reduce(0) { $0 + $1.size }
        precondition(shapeTotal == 20)

        for tile in retained {
            print("\(tile.shape): \(tile.size)")
        }
        print("Total: \(total)")
        print("Skipped duplicates: \(skipped)")
        print("Shape-only total: \(shapeTotal)")
    }
}

在程序包目录中运行:

swift run OrderDemo

下面是预期的程序输出,构建消息之后会显示这些内容。我们根据检索到的 API 文档检查了程序,但没有在此处执行,因为本研究环境没有 Swift 可执行程序。

circle: 12
square: 8
circle: 20
Total: 40
Skipped duplicates: 2
Shape-only total: 20

数组与集合的组合为何能保留顺序?

数组是输出顺序的唯一来源。循环按顺序访问每个输入位置,append(_:) 会将已接受的值追加到结果末尾。集合只用于判断所选键是否已出现;程序从不遍历集合。

关键表达式是 seen.insert(key(value)).inserted。Swift 文档说明,对于新成员,insert(_:) 会返回 inserted 作为 true;如果已有相等成员,则返回 false。已有成员保持不变。借助这个返回值,只需一次操作,就能决定是否追加原始值。参见 Apple 的集合插入文档。

对于给定输入,第一个圆形和正方形会被接受。第二个相同的圆形会被跳过。较大的圆形因完整值不同而被接受,重复的正方形则会被跳过。因此,保留下来的尺寸为 12 + 8 + 20 = 40。

此实现始终满足一个有用的不变条件:每次迭代后,结果都按遇到的顺序,恰好包含 seen 中每个键首次遇到的值。新键会扩展两个集合;重复键则不会改变任何一个集合。无论集合如何存储其成员,这一推理都能说明顺序为何有保证。

自定义 Swift 值应如何表达相等性?

声明 struct Tile: Hashable 表示请求合成遵循性。两个存储属性 String 和 Int 都可哈希,因此 Swift 可以合成所需行为。Apple 在 Hashable 文档中说明了合成遵循性的条件。

将 { $0 } 用作键,会让整个 Tile 值决定唯一性。使用 { $0.shape } 则会将规则改为每种形状只保留一个条目。在示例中,这会移除后续那个尺寸为 20 的圆形,将总数降至 20。这是对两种规则的有意比较,并非同一规则的第二种实现。

在整个计算过程中,所选键应保持确定性。应根据正在处理的值生成键,而不是使用计数器或会变化的外部变量。否则,相同输入可能得到不同的键,从而以不同条目的形式保留下来。

如果手动实现相等性和哈希,请在两者中使用相同的关键属性。Apple 要求相等的实例以相同顺序将相同值传入哈希器。避免直接使用 hashValue 作为唯一性键:哈希值不能替代值的相等性规则。保留实际的可哈希键,集合才能应用这套规则。参见 Hashable 的遵循要求。

什么时候应改用有序集合?

对于这次单独的计算,数组加本地集合能清楚地表达行为。如果应用需要反复追加、删除、重排和查询唯一成员,专用的有序集合可以让这些操作更易维护。

选项 适用场景 相关限制
数组加集合 对合成 Swift 值进行一次遍历,也可以按选定的键判断唯一性 函数负责定义首次出现的行为
Swift Collections 的 OrderedSet 持续使用由可哈希唯一值组成的有序集合 需要单独添加 Swift Collections 程序包
Foundation 的 NSOrderedSet 某个 API 已使用 Foundation 有序对象集合 用于表示静态有序集合的类;可变操作使用 NSMutableOrderedSet

Swift Collections 官方文档将 OrderedSet 描述为由唯一元素组成的有序集合,通过 OrderedCollections 导入。它提供了 append(_:) 和 insert(_:at:) 等与位置相关的操作。这些属于程序包 API,因此只添加 import OrderedCollections 并不能让原本无依赖的项目完成配置。参见 OrderedSet。

Apple 的 Foundation 文档将 NSOrderedSet 描述为由不同对象组成的静态有序集合,并指出其可变版本是 NSMutableOrderedSet。对于上面的原生 Swift 值计算,泛型函数可以直接表达所选的相等性键,无需引入这种对象集合。参见 NSOrderedSet。

当计算结果用于桌面界面时,哪些地方会改变?

完整示例是同步的,临时集合由函数在内部管理。对于一份较小的合成列表,这样能保持清晰的处理顺序,无需引入任务。

在 macOS 界面中,应适当地隔离面向 UI 的状态和更新。Apple 将 MainActor 记录为一个全局 actor,其执行器等同于主调度队列;Swift 并发指南则说明了如何将 @MainActor 用于访问界面状态的函数和类型。参见 MainActor 和并发。

并发也要求明确定义顺序。Swift 不保证任务组中子任务的完成顺序。因此,在任务完成时追加结果,会保留完成顺序,而这个顺序可能与原始输入顺序不同。官方并发指南说明了这一限制。

如果将来的版本需要并发执行彼此独立的合成计算,请在结果旁保留每项输入的位置;收集结果后,按输入位置恢复顺序,再执行唯一性筛选。这一设计是基于文档说明的完成顺序限制推导出来的。除非额外工作确有必要,否则请让当前的小型示例保持同步。

重用函数之前,先检查规则

程序包含对准确保留顺序、空输入、完全相同的重复值以及仅按形状区分的另一种规则的检查。这些检查针对最可能改变计算含义的决策。

改用此函数时,请检查三个条件:输入本身必须有明确顺序,键必须表达你想要的相等性规则,并且保留首次出现的条目必须符合需求。此函数会有意跳过后续匹配条目;它不会合并这些条目的尺寸,也不会替换已保留的值。报告中会显示跳过的数量,让这一效果一目了然。

对于给定的合成形状列表,最终结果按首次出现顺序保留三个值,总数为 40。让数组负责顺序,让集合负责成员判定,让所选键决定“唯一”的含义。

来源