ローカルのSwift配列を複数のフィールドで並べ替えるには、優先順位に従って各キーを比較し、同値の場合にだけ次へ進みます。オプションの代替値は個別に解決し、値が欠けている場合の位置を定義します。この例では、Macのデスクトップリスト用の架空の図形ラベルを並べ替えます。

クロージャを書く前に並べ替えの規則を定義する

デスクトップリストに、グループ番号、オプションの優先位置、オプションの代替位置、タイトルを持つ図形ラベルが含まれているとします。目的は、これらのメモリ上の値から、予測可能な表示順を1つ作ることです。

2つの判断には、それぞれ別の規則が必要です。代替値は、1つの項目を表す値を選びます。優先位置があればそれを使い、なければ代替位置を使います。同順位の比較では、2つの項目の実効位置が等しい場合に限り、別のフィールドを比較します。Swiftのnil合体演算子??は、ラップされた値か代替値を選択して、最初の判断を行います。基本演算子 — Nil合体演算子

この例では、次の明示的な規則を使います。

優先順位 キー 規則
1 グループ 数字の小さい順
2 実効位置 preferredPosition ?? backupPosition
3 位置が欠けている場合 各グループ内で、位置がある項目の後に配置
4 タイトル 位置が同じ場合、Swiftの文字列比較で昇順
5 識別子 それ以前のキーがすべて同じ場合、識別子の小さい順

位置が欠けている場合の配置は、グループを上書きする独立したフィールドではなく、位置の比較規則の一部です。グループ0の項目は、位置がなくてもグループ1の項目より先になります。

この計算例では、意図的にSwift標準の文字列順序を使っています。後でクロージャをFoundationの比較子に置き換える場合も、この選択を一貫させてください。

nilを明示的に処理して複数フィールドの並べ替えを完成させる

次の完全なプログラムはSwift標準ライブラリを使い、importは不要です。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は、位置を考慮する前にグループ0が優先されるため、最初になります。次の項目70は、0が有効な優先位置であることを示しています。代替値の8は使われません。項目20は優先位置の1を使います。

項目40、60、10は、いずれも実効位置が2です。タイトルがArcの2項目がCircleより前になり、識別子によって40と60の残る同順位が解決されます。項目30は、グループ1で位置フィールドが両方とも欠けているため、最後になります。

これで、よくある代替値についての疑問にも答えられます。項目40は、優先位置が別の項目と同じ場合、代替位置の0を使いません。この規則では、代替値は優先値がないときにだけ使われます。代替値でも同順位を解決するなら、比較規則を追加する必要があります。

値が同じ場合も比較関数の条件を守る

Swiftでは、並べ替え述語が狭義弱順序を形成する必要があります。実際には、項目が自分自身より前になることがなく、比較をまたいでも順序の判断に一貫性があり、同値の項目が一貫したグループを形成しなければなりません。Appleはsorted(by:)について、これらの要件を説明しています。述語の要件

この例では、先行するキーが異なるかどうかを確認してから、その順序の結果を返します。グループと実効位置が同じ場合にだけタイトルを比較し、タイトルも同じ場合にだけ識別子を比較します。識別子もそれ以前のキーも同じなら、最後の厳密比較はfalseを返します。

独立したフィールドの比較を||でつなぐのは避けてください。一方の項目はグループが先でも位置が後になり、もう一方はその逆になることがあります。ORを使った述語では、その場合に両方の項目が互いに先だと判定される可能性があります。同順位を順に処理すれば、この矛盾を防げます。

また、最後の比較に<=を使うのも避けてください。値が同じだとtrueを返し、項目が自分自身より前になってはならないという規則に反します。各比較は、渡された値と固定された並べ替え規則だけに依存するようにしてください。

同順位を安定させるか、最後に識別子で決めるか

Swiftで文書化されている並べ替えは安定しています。述語が同値として扱う要素は、入力時の相対順序を保ちます。その動作が意図したものなら、最後の識別子による比較を省き、タイトルも同じ場合はfalseを返してください。安定ソートの保証

この例では、残る同順位を解決するために、一意な架空の識別子を使います。そのため、サンプル入力の順序を逆にしても、識別子の順序は同じになります。この性質は、この規則とサンプル値によるものです。識別子を含めすべてのキーが同じ項目は同値のままなので、入力時の順序を保ちます。

Foundationのキーパス比較子で同じ規則を表現する

Foundationには、プロパティを比較するためのKeyPathComparatorと、比較子の列を適用するsorted(using:)があります。最初の比較子が主となる順序を決め、後続の比較子が同順位を細分化します。これらのAPIはmacOS 12以降で使用できます。KeyPathComparator、sorted(using:)

同等のFoundation版では、位置を表すキーを2つ用意します。1つは値が欠けているかどうかの順位、もう1つは数値の位置です。この順位によって、位置が欠けている項目は最後になります。順位が同じ場合、欠損値の代わりに使う数値が、値として存在する0と衝突することはありません。両者の順位がすでに異なるためです。

上記の完全なプログラムの後に、次の拡張と計算を追加してください。既存の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、キーパス比較子の初期化子

1つの固定された並び方なら、クロージャを使うとすべての分岐を確認できます。サポートするキーの順序を値として表したい場合は、比較子の配列が便利です。どちらの方法でも、両方の実装が同じ定義を使うよう、代替値の選択はモデルの計算プロパティに置いてください。

意味を変えずに規則を応用する

位置を降順にしつつ、位置が欠けている項目を最後にするには、位置が存在する2項目の比較だけをleft > rightに変更します。Foundation版では、numericPositionKeyだけを逆順にし、missingPositionRankは順方向のままにします。結果全体を逆順にすると、グループ、タイトル、識別子、値の欠落時の配置も逆になります。

オプションの文字列が代替キーになる場合は、空文字列を欠損値として扱うかどうかを別途決めてください。nil合体はnilに反応します。値が存在する空文字列は置き換えません。追加の正規化規則があれば、並べ替えの前に適用し、実効キーを一貫して使ってください。Nil合体の動作

この小さな計算は同期処理のままにしてください。Swiftの並行処理ガイドでは、通常の同期並べ替えと中断点を区別しており、文書化されたsorted(by:)述語は同期処理です。アプリケーションで非同期処理の結果が必要なら、このソーターを呼び出す前にその値の取得を終えてください。比較クロージャ内にawaitを追加しても、シグネチャでサポートされる拡張にはなりません。並行処理、sorted(by:)のシグネチャ

この例を別のデスクトップリストに応用する前に、優先キーがない場合、位置キーが両方ともない場合、有効な0、同じ実効位置、表示フィールドがすべて同じ場合を確認できるようにしてください。最後の同順位では入力順を保つのか、識別子で決めるのかを選びます。こうした選択によって、どちらの実装でも理解しやすい、明確な並べ替え規則になります。

出典