カスタムSwiftの等価性とハッシュ化で、単一の不変な値定義を使います。この手順では、名前の大文字と小文字を区別せず、寸法は厳密に比較するローカルタイルキーを作成し、等価性、セットへの所属、辞書検索を確認します。

2つのタイルが同じと見なされる条件を厳密に定義する

小規模なMacデスクトップ向けグラフィックス演習を作っているとします。名前、幅、高さで合成タイルの説明をグループ化する必要があります。読み手が実装するのは、一貫したグループ化です。同じ寸法の Orbit と ORBIT は等しいキーになり、どちらかの寸法を変えると異なるキーになります。

どちらかのプロトコルを実装する前に、契約を書き出しましょう。この例では、大文字と小文字を折りたたんだ名前、幅、高さが等しい場合、2つのキーは等しいものとします。寸法は正の整数で表す描画単位です。先頭と末尾の空白は区別されます。これは例としてのアプリケーションルールであり、Swiftが定める要件ではありません。

合成入力 期待される関係 理由
Orbit、40 × 20 と ORBIT、40 × 20 等しい 大文字と小文字を区別しない
Orbit、40 × 20 と orbit、41 × 20 等しくない 幅も等価性の判定対象
Orbit、40 × 20 と orbit、40 × 21 等しくない 高さも等価性の判定対象
Orbit、40 × 20 と orbit 、40 × 20 等しくない 末尾の空白を保持する

表示されるタイルの値全体とは異なるグループ化ルールが必要な場合は、専用のキーを使います。元の表示名は、周囲の表示用モデルに保持します。そうすれば、見た目の名前が異なることを値の一部として示しながら、タイルの説明全体を相互に置き換え可能だと宣言せずに済みます。SwiftのEquatableドキュメントは、置き換え可能性の観点から等価性を説明し、反射律、対称律、推移律を要件としています。Equatable

等価性とハッシュ化で同じ構成要素を使う

SwiftのHashableプロトコルはEquatableを継承します。基本要件は、等しいインスタンスが同じ値を同じ順序でハッシャーに渡すことです。Hashableな値はセットの要素や辞書のキーとして使えます。Hashable

よくある間違いは、==で名前を正規化して比較する一方、元の綴りをハッシュ化することです。すると、2つのメソッドで名前の扱いが異なります。もう1つの間違いは、等価性では幅を省きながら、ハッシュ化には幅を含めることです。どちらの設計でも、等しいと宣言された値が異なるハッシュ入力を渡す可能性があります。Appleは、カスタム実装では両方のメソッドで同じ重要なプロパティを使うよう明示しています。Adopting Common Protocols

この例では、等価性を決める構成要素を非公開のネスト型にまとめます。その型の等価性とハッシュ化はSwiftに合成させ、外側の両方の処理から委譲します。これで、構成要素の一覧を一か所で確認できます。Swiftは、保存プロパティが必要なプロトコルに準拠している構造体について、準拠の実装を合成できます。Protocols — Adopting a Protocol Using a Synthesized Implementation

型がすでに等価性を決める構成要素だけを保存しているなら、通常はHashable準拠を合成するだけで十分です。以下の明示的な外側メソッドは、既存のカスタム実装を、独立したフィールド一覧を2つ維持せずに整合させる方法を示しています。

Macで完全なローカル例を実行する

Foundationを含むSwiftツールチェーンがインストールされている必要があります。Swiftのスタートガイドの推奨に従い、Terminalでswift --versionを実行して確認してください。Swiftを利用できない場合は、続行する前に公式のmacOSインストールガイドに従ってください。Getting Started、Install Swift — macOS

以下は、Swiftプログラムを含む完全なシェルコマンドです。ブロック全体をTerminalに貼り付けてください。標準入力からプログラムを渡します。この例はメモリ内で計算し、結果を出力するだけです。調査環境ではSwiftを利用できなかったため、Swiftコードの確認は行いましたが、実行はできませんでした。

swift - <<'SWIFT'
import Foundation

struct TileKey: Hashable, Sendable {
    private struct Components: Hashable, Sendable {
        let foldedName: String
        let width: Int
        let height: Int
    }

    private let components: Components

    init(name: String, width: Int, height: Int) {
        precondition(width > 0 && height > 0,
                     "Tile dimensions must be positive")

        let foldedName = name.folding(
            options: [.caseInsensitive],
            locale: Locale(identifier: "en_US_POSIX")
        )
        components = Components(
            foldedName: foldedName,
            width: width,
            height: height
        )
    }

    static func == (lhs: TileKey, rhs: TileKey) -> Bool {
        lhs.components == rhs.components
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(components)
    }
}

let a = TileKey(name: "Orbit", width: 40, height: 20)
let b = TileKey(name: "ORBIT", width: 40, height: 20)
let c = TileKey(name: "orbit", width: 40, height: 20)
let wider = TileKey(name: "orbit", width: 41, height: 20)
let taller = TileKey(name: "orbit", width: 40, height: 21)
let spaced = TileKey(name: "orbit ", width: 40, height: 20)
let samples = [a, b, c, wider, taller, spaced]

precondition(a == b && b == c && a == c)
precondition(a != wider && a != taller && a != spaced)

// Check the equality laws over this finite sample.
for first in samples {
    precondition(first == first, "Reflexivity failed")
    for second in samples {
        precondition((first == second) == (second == first),
                     "Symmetry failed")
        if first == second {
            precondition(first.hashValue == second.hashValue,
                         "Equal values have different hashes")
        }
        for third in samples where first == second && second == third {
            precondition(first == third, "Transitivity failed")
        }
    }
}

let uniqueKeys = Set(samples)
precondition(uniqueKeys.count == 4)
precondition(uniqueKeys.contains(b))

let patterns = [a: "striped"]
precondition(patterns[b] == "striped")

print("Unique keys:", uniqueKeys.count)
print("Equivalent key found:", uniqueKeys.contains(b))
print("Pattern:", patterns[b] ?? "missing")
print("Sample contract checks passed")
SWIFT

期待される出力:

Unique keys: 4
Equivalent key found: true
Pattern: striped
Sample contract checks passed

6つの入力は4つのグループになります。大文字と小文字だけが異なる3つは同じキーを共有し、幅を変えたもの、高さを変えたもの、末尾に空白を付けたものはそれぞれ別のグループになります。辞書の確認では、aに対応付けた値を、等しいキーであるbを使って取得できるかを調べます。これらの確認では、特定の数値ハッシュに依存せず、意図したコレクション動作を検証できます。

イニシャライザーは、寸法が正の値でない場合に意図的に処理を停止します。無効な寸法はプログラミング上の誤りを示す、この固定された合成演習では適切です。デスクトップのインターフェースで未入力の寸法を許可する場合は、キーを作成する前に入力を検証するか、イニシャライザーがオプショナルを返すか、エラーをスローするよう変更してください。寸法を暗黙に置き換えてはいけません。等価性の契約が変わってしまうためです。

テキスト正規化を明示的なルールにする

正規化の処理では、Swiftの文字列APIを通じてFoundationのfolding(options:locale:)を呼び出します。このAPIは、指定されたフォールディングオプションを適用した文字列を返します。ここでは.caseInsensitiveだけを指定します。StringProtocol.folding(options:locale:)

Foundationでは、大文字と小文字、文字幅、ダイアクリティカルマークのフォールディングを別々に選択できます。また、ロケールによってフォールディング結果が変わることや、フォールディングしたテキストが表示に適さない場合があることも説明されています。そのため、この例では固定ロケールを指定し、フォールディングした結果はキーの内部だけに保存します。例に使う名前は意図的に単純なものにしています。このロケールの選択は、この演習の動作を定めるものです。あらゆる名前入力インターフェースに共通する推奨事項ではありません。NSString.folding(options:locale:)

有用な設計判断は、作成時に一度だけ正規化することです。その後の等価性とハッシュ化は保存済みの結果を使うため、どちらの処理もロケールや変換を個別に選ぶことはありません。アプリケーションで後から別の比較ポリシーが必要になった場合は、新しいポリシーでキーを作成し、影響を受けるメモリ内コレクションを再構築します。

正規化の範囲を広げる前に、意図するグループを区別する例を追加してください。空白は区別するべきでしょうか。アクセント付き文字は別のものとして扱うべきでしょうか。見た目の文字幅が異なる場合、同じ名前として扱うべきでしょうか。これらを製品ルールとして決めたうえで、両方の処理で使う変換を1つ実装します。

ハッシュ値を当てにせず、不一致を診断する

既存の型にこの設計を適用するときは、次の手順を使ってください。

  1. 等しいと判定すべきペアを1組と、値を区別する各フィールドについて1組ずつ例を作る。
  2. ==が実際に比較する値を、正規化や派生プロパティも含めて追跡する。
  3. hasher.combineに渡す値を、その順序も含めて追跡する。
  4. 共通の定義を単一の不変な構成要素の値にまとめるか、両方の実装が明示的に同じ構成要素を使うようにする。
  5. 同じテストデータで、等価性の法則、等しい値のハッシュ、セットへの所属、辞書検索を確認する。

hashValueを比較して等価性を実装してはいけません。等しい値のハッシュは等しくなければなりませんが、ハッシュが一致しても等価であるとは限りません。==の基準は、あくまで等価性を決めるすべての構成要素です。同様に、等しくないすべてのペアで異なるハッシュを要求するテストは、ハッシュ化の契約で保証される範囲を超えています。Hashable

この契約を確認するときは、現在の実行内でのみハッシュを比較してください。SwiftのHasherはランダムなシードを使い、そのアルゴリズムは標準ライブラリのバージョン間で変わることがあります。そのため、昨日の実行で出力された整数は、今日の実行で期待値として使うには適しません。Hasher

有限個のサンプル確認は有用な回帰チェックですが、あらゆる文字列や寸法に対する証明ではありません。構造に基づく説明も重要です。つまり、外側の両方のメソッドは同じ不変なComponents値に委譲し、その保存フィールドが選択した契約を完全に表しています。

並行処理の要件は等価性と分けて考える

この例では、保存される値が文字列、整数、そして別のSendableな構造体なので、両方の構造体をSendableとして宣言しています。Swiftでは、Sendableな構造体にはSendableな保存プロパティが含まれていなければならないと説明されています。Sendabilityは並行実行ドメイン間で値を渡すことに関するものであり、どの値が等しいかを定義するものではありません。Sendable、The Swift Programming Language — Concurrency

このローカル計算ではタスクやアクターは必要ありません。後に並行処理を使うデスクトップコードの一部になる場合も、キーの保存済み構成要素に対する等価性とハッシュ化は同期処理のままにしてください。共有された可変コレクションは、別途並行処理の設計課題として扱います。言語ガイドでは、可変状態へのアクセスを調整する仕組みとして、アクターと隔離について説明しています。The Swift Programming Language — Concurrency

このパターンを適用する際には、Foundationとの境界にも注意が必要です。NSObjectのサブクラスでは、継承した等価性とハッシュ化の動作が使われます。Appleがサポートするカスタマイズ方法は、isEqual(_:)とhashを両方オーバーライドすることです。この例のプレーンなSwift構造体では、==とhash(into:)を使います。Adopting Common Protocols

ローカルタイルキーで保守しやすい実装にするには、比較ルールを書き出し、そのルールを表す不変な表現を1つ持ち、実際のコレクション検索を確認します。定義を変更する場合は、構成要素の表現と例を一緒に更新し、等価性とハッシュ化が同じ値を表し続けるようにします。

出典