要按多个字段对本地 Swift 数组排序,请按优先级顺序比较每个键,仅在键值相同时继续比较。分别解析可选备用值,然后确定缺失值应位于何处。本示例为 Mac 桌面列表中的合成形状标签排序。
编写闭包之前先定义排序约定
假设桌面列表包含形状标签,每个标签都有组编号、可选的首选位置、可选的备用位置和标题。任务是根据这些内存中的值生成一个可预测的显示顺序。
有两项决策需要分别制定规则。回退规则决定哪个值代表某个条目:如果存在首选位置,就使用它;否则使用备用位置。平局处理规则则规定,只有两个条目的有效位置相同时,才比较另一个字段。Swift 的空合并运算符 ?? 可通过选择封装值或备用值来实现第一项决策。基本运算符 — 空合并运算符
本示例采用以下明确约定:
| 优先级 | 键 | 规则 |
|---|---|---|
| 1 | 组 | 数字较小者在前 |
| 2 | 有效位置 | preferredPosition ?? backupPosition |
| 3 | 缺失位置的排列 | 在每个组内,缺失位置排在已有位置之后 |
| 4 | 标题 | 位置相同时按 Swift 字符串的升序比较 |
| 5 | 标识符 | 前面的所有键都相同时,标识符较小者在前 |
缺失位置的排列规则是位置比较的一部分,而不是一个会覆盖组顺序的独立字段。即使组零中的条目没有位置,它仍排在组一中的条目之前。
标题比较特意采用 Swift 的常规字符串排序,用于此合成计算。如果之后改用 Foundation 比较器,请保持这一选择一致。
显式处理 nil,编写完整的多字段排序
下面的完整程序使用 Swift 标准库,无需导入任何模块。请在 Mac 上支持 Swift 的环境中运行。所有输入都定义在程序中;计算不使用外部输入或持久化存储。排序操作通过 sorted(by:) 返回一个新数组。Apple 的 sorted(by:) 文档
struct ShapeItem {
let id: Int
let group: Int
let preferredPosition: Int?
let backupPosition: Int?
let title: String
var effectivePosition: Int? {
preferredPosition ?? backupPosition
}
}
func comesBefore(_ lhs: ShapeItem, _ rhs: ShapeItem) -> Bool {
if lhs.group != rhs.group {
return lhs.group < rhs.group
}
switch (lhs.effectivePosition, rhs.effectivePosition) {
case let (.some(left), .some(right)):
if left != right {
return left < right
}
case (.some(_), .none):
return true
case (.none, .some(_)):
return false
case (.none, .none):
break
}
if lhs.title != rhs.title {
return lhs.title < rhs.title
}
return lhs.id < rhs.id
}
let items = [
ShapeItem(id: 10, group: 1, preferredPosition: nil,
backupPosition: 2, title: "Circle"),
ShapeItem(id: 20, group: 1, preferredPosition: 1,
backupPosition: 9, title: "Square"),
ShapeItem(id: 30, group: 1, preferredPosition: nil,
backupPosition: nil, title: "Arc"),
ShapeItem(id: 40, group: 1, preferredPosition: 2,
backupPosition: 0, title: "Arc"),
ShapeItem(id: 50, group: 0, preferredPosition: nil,
backupPosition: nil, title: "Dot"),
ShapeItem(id: 60, group: 1, preferredPosition: 2,
backupPosition: nil, title: "Arc"),
ShapeItem(id: 70, group: 1, preferredPosition: 0,
backupPosition: 8, title: "Zero")
]
let ordered = items.sorted(by: comesBefore)
let expectedIDs = [50, 70, 20, 40, 60, 10, 30]
precondition(ordered.map(\.id) == expectedIDs)
precondition(ordered.count == items.count)
precondition(items.reversed().sorted(by: comesBefore).map(\.id)
== expectedIDs)
print(ordered.map(\.id))
预期输出:
[50, 70, 20, 40, 60, 10, 30]
程序和预期顺序已经检查过,但由于研究环境没有 Swift 编译器,这里没有实际运行。程序中的前置条件可让你在本地运行时检查结果。
各示例条目为何位于该位置
条目 50 排在最前,因为组零的优先级高于位置。接着,条目 70 展示了零是有效首选位置:它的备用值八不会被使用。条目 20 使用其首选位置一。
条目 40、60 和 10 的有效位置都是二。两个标题为 Arc 的条目排在 Circle 之前,而标识符则用于解决 40 和 60 之间剩下的平局。条目 30 排在最后,因为它在组一中两个位置字段都缺失。
这也回答了一个常见的回退问题:当条目 40 的首选位置与另一个条目相同时,它不会使用值为零的备用位置。按照本约定,备用值仅在首选值缺失时替代它。如果备用值也应该用于打破平局,就需要增加一条比较规则。
值相同时确保比较器仍然有效
Swift 要求排序谓词构成严格弱序。实际而言,条目绝不能排在自身之前;排序决策在不同比较中必须保持一致;等价条目必须形成一致的分组。Apple 在 sorted(by:) 的文档中说明了这些要求。谓词要求
本示例会先检查更靠前的键是否不同,再返回相应的排序结果。只有组和有效位置相同时才会比较标题,只有标题也相同时才会比较标识符。当标识符及所有更靠前的键都相同时,最终的严格比较会返回 false。
避免用 || 连接彼此独立的字段比较。一个条目可能组别较靠前,但位置较靠后;另一个条目可能正好相反。基于 OR 的谓词可能因此判定两个条目都排在对方之前。按顺序处理平局可以避免这种矛盾。
还要避免把 <= 用作最终比较:值相等时它会返回 true,违反条目不能排在自身之前的规则。确保每次比较只依赖给定值和固定的排序规则。
选择保留平局顺序,还是使用最终标识符
Swift 文档说明其排序是稳定的:谓词判定为等价的元素会保留原有的相对输入顺序。如果这是预期行为,就省略最终的标识符比较,并在标题相同时返回 false。稳定排序保证
本示例改用唯一的合成标识符来解决剩余平局。因此,即使反转示例输入,得到的标识符序列也相同。这个性质来自本约定和示例值。如果标识符以及其他所有键都重复,这些条目仍然等价,并会保留输入顺序。
使用 Foundation 键路径比较器表达相同规则
Foundation 提供 KeyPathComparator 用于比较属性,并提供 sorted(using:) 用于应用一系列比较器。第一个比较器指定主要顺序;后续比较器则进一步处理平局。这些 API 从 macOS 12 起可用。KeyPathComparator、sorted(using:)
要用 Foundation 实现等价版本,请公开两个位置键:缺失值等级和数值位置。等级会把缺失位置排在最后。等级相同时,缺失位置的数值替代值不会与已有的零冲突,因为这些条目的等级已经不同。
在上面的完整程序后添加以下扩展和计算。这是一个依赖前文的示例,会使用已有的 ShapeItem、items 和 expectedIDs 定义。它需要 macOS 14 或更高版本,因为它显式地使用 init(order:) 构造 ComparableComparator。初始化器可用性
import Foundation
extension ShapeItem {
var missingPositionRank: Int {
effectivePosition == nil ? 1 : 0
}
var numericPositionKey: Int {
effectivePosition ?? 0
}
}
if #available(macOS 14.0, *) {
let comparators: [KeyPathComparator<ShapeItem>] = [
KeyPathComparator(\ShapeItem.group, order: .forward),
KeyPathComparator(\ShapeItem.missingPositionRank,
order: .forward),
KeyPathComparator(\ShapeItem.numericPositionKey,
order: .forward),
KeyPathComparator(
\ShapeItem.title,
comparator: ComparableComparator<String>(order: .forward)
),
KeyPathComparator(\ShapeItem.id, order: .forward)
]
let foundationOrdered = items.sorted(using: comparators)
precondition(foundationOrdered.map(\.id) == expectedIDs)
print(foundationOrdered.map(\.id))
} else {
print(items.sorted(by: comesBefore).map(\.id))
}
显式的标题比较器保留了先前选择的 Swift 字符串排序方式。Foundation 文档说明 ComparableComparator 使用被比较类型的 Comparable 实现,而 KeyPathComparator 接受提供的属性比较器。ComparableComparator、键路径比较器初始化器
如果排列规则固定,闭包会让每个分支一目了然。如果希望将支持的键顺序表示为值,比较器数组会很有用。无论采用哪种方式,都应将回退值选择放在模型的计算属性中,以确保两种实现使用相同的定义。
调整规则时保持其含义不变
如果要让位置按降序排列,同时让缺失位置仍排在最后,只需将两个已有位置之间的比较改为 left > right。在 Foundation 版本中,只反转 numericPositionKey;让 missingPositionRank 保持正向。反转整个结果也会颠倒组、标题、标识符以及缺失值的排列顺序。
如果可选字符串要用作备用键,请单独决定空字符串是否算作缺失。空合并运算符会响应 nil;它不会替换已有的空字符串。排序前先确定任何额外的规范化规则,并在整个过程中使用相同的有效键。空合并行为
让这项小计算保持同步。Swift 并发指南区分普通的同步排序和挂起点,而文档中的 sorted(by:) 谓词是同步的。如果应用需要异步任务提供的值,请在调用排序器之前先获取这些值。比较闭包内添加 await 并不是对其签名的受支持扩展。并发、sorted(by:) 签名
将本示例改用于另一个桌面列表前,请保留对以下情况的检查:首选键缺失、两个位置键都缺失、有效零值、有效位置相同,以及显示字段完全相同。决定最后的平局应保留输入顺序,还是使用标识符。这些选择能为列表定义明确的排序约定,并让两种实现都易于理解。
来源
- sorted(by:) — Apple
- 基本运算符 — Swift Project
- KeyPathComparator — Apple
- sorted(using:) — Apple
- ComparableComparator — Apple
- init(order:) — Apple
- init(_:comparator:) — Apple
- 并发 — Swift Project