オブジェクトが辞書のキーである間は、等価性とハッシュに使われるプロパティを安定させておきましょう。Mac 上でローカルに Swift の計算を行う場合は、検索が何を意味するべきかに応じて、オブジェクトの同一性、不変の ID、または値のスナップショットを選びます。

この手順では、メモリ内だけで扱う合成矩形と小規模な面積計算を使います。実際の課題は、矩形のラベルや寸法が変わっても、対応する辞書エントリを失わず、検索に成功したことと計算結果が最新であることを混同しないようにすることです。

オブジェクトを変更すると辞書検索が失敗するのはなぜですか?

Swift の Dictionary は、キーが Hashable に準拠するハッシュテーブルです。キーによる添字アクセスは、要求されたキーが存在しない可能性があるため、オプショナルを返します。ハッシュはエントリの場所を見つけるのに役立ち、等価性はどのキーを同等とみなすかを決めます。Apple のDictionary のドキュメントを参照してください。

Hashable の契約は、これらの操作を結び付けています。同等なインスタンスは、同じ順序で同じ基本コンポーネントを Hasher に渡さなければなりません。カスタムキーを実装する場合は、hash(into:) で等価性を定義するプロパティを使います。Apple はこの要件を Hashable で説明しています。

参照型のキーには、もう一つ考慮すべき点があります。クラスのインスタンスを代入すると、独立したオブジェクトが作られるのではなく、既存のオブジェクトへの参照が渡されます。参照を let として宣言しても、その可変プロパティが固定されるわけではありません。これらは構造体とクラスで説明されている言語仕様です。

編集可能なラベルを比較・ハッシュ化する矩形クラスを考えてみましょう。ラベルが \"Tile A\" のときにオブジェクトを挿入し、その後ラベルを \"Tile B\" に変更します。保存された参照から見える、キーを定義する状態が変わります。すると、辞書への元の配置と後続の検索との間で、安定した対応が保てなくなります。

設計上のルールは、ハッシュテーブルと等価性の契約から導かれます。キーが辞書に含まれている間は、その等価性を定義する状態を安定させておきましょう。Apple は Foundation オブジェクトについて、対応する要件を明示しています。オブジェクトのハッシュを配置に使うコレクションに含まれている間は、そのハッシュを変更してはなりません。NSObjectProtocol.hashを参照してください。

不正な変更があれば、必ず即座に nil が返されるという前提でテストを組まないでください。キーの契約が破られた後では、検索が一見成功しても、設計が正しいことの証拠にはなりません。意図した等価性のルールと安定した動作をテストしてください。

タスクに合うキーはどれですか?

準拠を実装する前に、辞書エントリが何を意味するかを決めましょう。矩形の面積ツールでは、次の3つの問いから異なる設計が導かれます。

エントリが表すもの 適したキー 矩形を変更したときの動作
この特定の矩形オブジェクト 同一性ベースのクラスキー エントリはそのインスタンスに結び付いたままになる
現在のオブジェクトとは独立して識別される論理上の矩形 不変の ID 値 エントリはその ID に結び付いたままになる
特定の寸法に対する計算結果 不変の寸法スナップショット 寸法が変わると別の検索キーになる

この表は、Swift の同一性、値、ハッシュの規則に基づく設計の手引きです。また、関連付けを検索可能な状態に保つことと、計算結果を最新の状態に保つことという2つの責務を分けています。安定した同一性キーは前者を解決し、再計算または完全な入力スナップショットは後者に対処します。

エントリを特定のインスタンスに結び付ける場合はオブジェクトの同一性を使う

メモリ内の関連付けを編集後も1つのオブジェクトに追従させたい場合は、=== で等価性を定義し、ObjectIdentifier をハッシュ化します。Swift の同一性演算子は、参照が同じインスタンスを指しているかを調べます。Apple は ObjectIdentifier を、インスタンスの存続期間中に比較が有効なハッシュ可能な識別子として説明しています。構造体とクラスおよび ObjectIdentifierを参照してください。

次は、完全な同期プログラムです。Swift のコマンドラインツールが使える Mac が必要で、自動的にインポートされる Swift 標準ライブラリだけを使用します。IdentityKeys.swift として保存し、ターミナルで swift IdentityKeys.swift を実行してください。調査環境では Swift が利用できなかったため、例を確認しましたが実行はできませんでした。

final class Rectangle: Hashable {
    var label: String
    var width: Int
    var height: Int

    init(label: String, width: Int, height: Int) {
        self.label = label
        self.width = width
        self.height = height
    }

    static func == (lhs: Rectangle, rhs: Rectangle) -> Bool {
        lhs === rhs
    }

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

func demonstrateIdentityKeys() {
    let rectangle = Rectangle(label: "Tile A", width: 4, height: 3)
    let alias = rectangle
    let separate = Rectangle(label: "Tile A", width: 4, height: 3)

    var areas: [Rectangle: Int] = [
        rectangle: rectangle.width * rectangle.height
    ]

    rectangle.label = "Tile B"
    assert(areas[alias] == 12)
    assert(areas[separate] == nil)

    rectangle.width = 5
    assert(areas[rectangle] == 12) // Still the previous calculation.

    areas[rectangle] = rectangle.width * rectangle.height
    assert(areas[rectangle] == 15)
    assert(areas.count == 1)

    print("Renamed lookup: 12")
    print("Recalculated area: \(areas[rectangle]!)")
}

demonstrateIdentityKeys()

期待される出力は Renamed lookup: 12、続いて Recalculated area: 15 です。アサーションは有用な契約を表しています。別名から既存のエントリを見つけられること、別に作成した矩形からは見つからないこと、元のオブジェクトを編集しても別のキーが作られないことです。

幅を変更しても、プログラムが 15 を代入するまでは、保存された面積が意図的に 12 のままになります。同一性は安定していますが、辞書は計算上の依存関係を監視したり値を再計算したりしません。編集直後に値を更新するのか、次の計算まで削除しておくのかを決めてください。

同一性による等価性は、型全体に関わる決定です。このクラスでのすべての == による比較は、以後「同じインスタンス」を意味します。モデルですでに寸法や別の値による等価性が定義されている場合は、1つの辞書のためだけにその意味を変えるのではなく、別のキー設計を使ってください。

辞書のキーとして ObjectIdentifier を直接使うこともできますが、識別子だけでは後続の処理に必要な矩形は得られません。識別子を比較に使っている間は関連するオブジェクトを存続させ、不要になったエントリを削除してください。ドキュメントに記載された存続期間の制限から、これはスコープ内のメモリ上で使う同一性の仕組みです。ObjectIdentifierを参照してください。

エントリが計算結果を表す場合は値のスナップショットを使う

再利用可能な面積計算結果には、寸法そのものが適切なキーになることがよくあります。4 × 3 の矩形は、別々のオブジェクトでラベルが異なっていても、同じ結果を共有できます。寸法が変われば別のエントリが選ばれるべきです。

Swift の構造体には値セマンティクスがあり、保存プロパティが Hashable に準拠している場合は、コンパイラが準拠を合成できます。不変の整数を含むスナップショットは、この計算のキーとして簡潔に使えます。構造体とクラスおよび Hashableを参照してください。

この代替例を単独で SnapshotKeys.swift として保存し、swift SnapshotKeys.swift を実行してください。入力は合成した小さな正の整数なので、汎用の数値入力インターフェースは必要ありません。

final class EditableRectangle {
    var label: String
    var width: Int
    var height: Int

    init(label: String, width: Int, height: Int) {
        self.label = label
        self.width = width
        self.height = height
    }
}

struct AreaInputs: Hashable, Sendable {
    let width: Int
    let height: Int
}

func demonstrateSnapshotKeys() {
    let rectangle = EditableRectangle(
        label: "Tile A", width: 4, height: 3
    )
    var areas: [AreaInputs: Int] = [:]

    let original = AreaInputs(
        width: rectangle.width, height: rectangle.height
    )
    areas[original] = original.width * original.height

    rectangle.label = "Tile B"
    rectangle.width = 5

    let current = AreaInputs(
        width: rectangle.width, height: rectangle.height
    )
    assert(areas[original] == 12)
    assert(areas[current] == nil)

    areas[current] = current.width * current.height
    assert(areas[current] == 15)
    assert(areas.count == 2)

    print("Original area: \(areas[original]!)")
    print("Current area: \(areas[current]!)")
}

demonstrateSnapshotKeys()

期待される出力は Original area: 12、続いて Current area: 15 です。プログラムが異なる2つのスナップショットを挿入したため、どちらの結果も検索できます。古い結果は、古い寸法に対する値として引き続き正しいものです。

計算に影響する入力はすべてスナップショットに含めてください。矩形の面積にはラベルは関係ないため、ここでは除外しています。後の計算で別の設定値に依存するなら、その設定値をキーに加えてください。そうしないと、異なる計算が誤って同じエントリを共有する可能性があります。これは Hashable の要件を満たすかどうかとは別の、計算設計上の要件です。

可変クラス参照を包むだけの構造体では、そのオブジェクトの凍結されたスナップショットが自動的に作られるわけではありません。この例のように、関連する値をスナップショットへコピーしてください。また、編集のたびに不要になった結果が蓄積するなら、古いスナップショットをいつ破棄するかも決めてください。

不変の ID を使うべきなのはどんなときですか?

複数のオブジェクトインスタンスが同じ論理エントリを参照すべき場合は、ID を使います。この合成ツールでは、不変の整数 ID で十分です。[Int: Int] に結果を保存し、矩形の let id: Int でアクセスし、編集可能なラベルと寸法はキーの外に置きます。

この設計では、ID が 7 の2つのオブジェクトが、意図的に同じ辞書エントリを参照します。異なる論理上の矩形に誤って同じ ID が割り当てられないよう、生成ルールを定める必要があります。Dictionaryのドキュメントにあるとおり、既存のキーを更新すると、対応する値が置き換わります。

ID ベースの面積テーブルでも、寸法が変わった後の更新方針は必要です。異なる入力の組み合わせごとに結果を残したいなら、スナップショットを使ってください。論理上の矩形ごとに最新の結果だけを保持したいなら、ID と明示的な再計算の組み合わせが適しています。

Foundation の辞書やアクターを使えば可変キーの問題は解決しますか?

Foundation には異なる挿入契約があります。NSMutableDictionary.setObject(_:forKey:) は NSCopying に準拠するキーを要求し、挿入時にそれをコピーします。コピーの実装によってキーの状態が安定していれば、キーのコピーでスナップショットを作れます。以後の検索では、保存されたコピーに一致させる必要があります。Apple の setObject(_:forKey:)を参照してください。

この動作から、Swift のネイティブ辞書がキーとして使われるクラスインスタンスを独立したコピーにする、と推測しないでください。Swift のクラス代入には参照セマンティクスがあります。カスタムの Foundation キーでは、コピー、等価性、ハッシュの動作を併せて確認してください。Foundation のハッシュ要件も引き続き適用されます。

並行処理は別の問題です。アクターは隔離された可変状態へのアクセスを調整し、Sendable はその要件のもとで並行実行のドメイン間を渡せる値を表します。どちらもキーの等価性の意味を変えたり、挿入後に定義状態が変わるキーを修復したりはしません。Swift の並行処理の章および Apple の Sendable のドキュメントを参照してください。

スナップショットの例では、保存された整数がプロトコルの値型要件を満たすため、Sendable を明示的に宣言しています。後で面積計算をアクター内に移す場合は、完全なスナップショットを渡し、結果の辞書はアクターに管理させてください。可変の矩形は、既存の隔離ドメイン内にとどめておけます。これらの同期例にアクターは必要ありません。

検索に頼る前に契約を確認する

選んだ設計を検証するには、小さくて決定的な手順を使います。

  1. エントリを挿入し、最初の検索が成功することを確認します。
  2. 等価性とハッシュから除外したプロパティを変更し、同じエントリを引き続き検索できることを確認します。
  3. 別名と、別に作成したオブジェクトまたはスナップショットを比較し、結果が意図した等価性の意味に合うことを確認します。
  4. 計算入力を変更し、結果が更新、削除、または新しいスナップショット経由でアクセスされることを確認します。
  5. 辞書の件数を確認し、置換と挿入が意図どおりに行われることを確かめます。

既存の実装でキーを定義する状態が変更されているなら、それ以降の検索に頼る前にキーのモデルを修正してください。この小さなローカル計算では、後続の辞書操作を修復手段として扱うより、別の合成入力リストから結果を作り直すほうが明快です。見つけられないエントリをひそかに捨ててしまわないよう、再計算に必要な元の入力を保持してください。

生きている1つのオブジェクトとの関連付けには同一性を、1つの論理項目には不変の ID を、1つの計算には値のスナップショットを選びます。そして、結果の更新方針を明確にします。この2つの判断を組み合わせれば、編集可能な矩形が変わっても検索の意味を保てます。

出典