若要依多個欄位排序本機 Swift 陣列,請依優先順序逐一比較各鍵,只有在相同時才繼續比較。分別處理可選的備援值,再定義缺少值應放置的位置。此範例會為 Mac 桌面列表排序合成的形狀標籤。
撰寫排序閉包前先定義排序規則
假設桌面列表包含形狀標籤,每個標籤都有群組編號、可選的首選位置、可選的備用位置,以及標題。任務是依據這些記憶體中的值,產生一個可預測的顯示順序。
兩項決策需要分別訂定規則。備援會選出代表單一項目的值:若有首選位置就使用,否則使用備用位置。平手時的次要排序鍵,則是在兩個項目的有效位置相同後才比較另一個欄位。Swift 的 nil 合併運算子 ?? 可透過選取包裝值或備援值,處理第一項決策。基本運算子 — Nil 合併運算子
此範例採用以下明確規則:
| 優先順序 | 鍵 | 規則 |
|---|---|---|
| 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 說明零是有效的首選位置:它的備用值 8 不會用到。項目 20 使用其首選位置 1。
項目 40、60 和 10 的有效位置都是 2。兩個 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 定義。由於明確使用 init(order:) 建構 ComparableComparator,因此需要 macOS 14 或以上版本。初始化項目適用版本
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 合併只會在 nil 時生效;若字串有值但內容為空,它不會取代該字串。排序前應先處理任何額外的正規化規則,並在整個排序過程中使用相同的有效鍵。Nil 合併行為
請讓這項小型計算維持同步。Swift 的並行程式設計指南區分一般同步排序與暫停點,而文件所述的 sorted(by:) 述詞也是同步的。如果應用程式需要非同步工作取得的值,請先完成取得,再呼叫此排序器。在比較閉包內加入 await,並不是其簽章所支援的延伸方式。並行程式設計、sorted(by:) 簽章
將此範例套用到另一個桌面列表前,請保留以下檢查情況:缺少首選鍵、兩個位置鍵都缺少、有效的零、有效位置相同,以及顯示欄位完全相同。並決定最後平手時要保留輸入順序,還是使用識別碼。這些選擇能讓列表的排序規則明確,且在兩種實作中都易於理解。
來源
- sorted(by:) — Apple
- 基本運算子 — Swift 專案
- KeyPathComparator — Apple
- sorted(using:) — Apple
- ComparableComparator — Apple
- init(order:) — Apple
- init(_:comparator:) — Apple
- 並行程式設計 — Swift 專案