若要依多個欄位排序本機 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:) 簽章

將此範例套用到另一個桌面列表前,請保留以下檢查情況:缺少首選鍵、兩個位置鍵都缺少、有效的零、有效位置相同,以及顯示欄位完全相同。並決定最後平手時要保留輸入順序,還是使用識別碼。這些選擇能讓列表的排序規則明確,且在兩種實作中都易於理解。

來源