iOS コレクションの同一性を検証:Hashable とフィールド更新

iOS コレクションの同一性を検証:Hashable とフィールド更新

iOS コレクションの同一性を検証:Hashable とフィールド更新

リモートから同じレコードを再取得すると、名前は更新されているのに一覧に行が増えてしまう。別の箇所では、そのレコードを Dictionary のキーに使った途端、以前の状態を取得できなくなる。こうした問題に遭遇しても、まず更新処理を書き直す必要はありません。モデルをクラウド Mac に持ち込み、等価性とコレクション内での振る舞いを単独で検証すると、同一性の定義のずれを直接突き止められることがよくあります。ここでは値型のモデルだけをテストします。シミュレータも、実際の API への接続も不要です。

何を同じレコードとみなすかを先に定義する

複数のテナントに属するメンバーを管理するアプリを考えます。メンバー ID はテナント内でのみ一意で、名前は変更可能です。この場合、業務上の同一性は「テナント ID + メンバー ID」で決まり、名前はその一部ではありません。まずこのルールをテストの説明に明記し、そのうえでプロトコルを実装します。

ここには、区別すべき二つの問いがあります。二つのデータが同じメンバーを表しているかという問いと、二つのデータの全フィールドが一致しているかという問いです。構造体の Hashable 準拠を自動合成に任せると、通常はすべての格納プロパティが比較対象になります。そのため、名前が変わると二つのスナップショットは等しくなくなります。

重複を排除するためだけに、あらゆるモデルの等価性の意味を安易に変えないでください。完全なスナップショット同士を比較する必要があるなら、不変の識別キーを別に定義し、そのキーから完全なレコードを引ける辞書を使うほうが堅実です。この記事では契約を直接示すため、同一性に基づいて等価性を判定するモデルを使います。

等しい値は、一貫したルールでハッシュ化されなければなりません。ただし、ハッシュ値が同じでも、値が等しいとは限りません。ハッシュは検索範囲を絞るためのものであり、レコードの永続的な識別子ではありません。

最小のスクリプトで等価性とハッシュのルールを固定する

クラウド Mac で Xcode のコマンドラインツールが利用できることを確認し、xcrun swift --version を実行します。出力はコードのコミット識別子と一緒に記録してください。identity-contract.swift を作成し、次の内容を保存します。この例には外部依存関係がなく、認証情報も読み取りません。

struct Member: Hashable {
    let tenantID: String
    let memberID: String
    var displayName: String

    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.tenantID == rhs.tenantID &&
        lhs.memberID == rhs.memberID
    }

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

let original = Member(
    tenantID: "team-a",
    memberID: "member-7",
    displayName: "Old"
)
var renamed = original
renamed.displayName = "New"

let otherTenant = Member(
    tenantID: "team-b",
    memberID: "member-7",
    displayName: "Old"
)

precondition(original == renamed, "Rename changed identity")
precondition(original.hashValue == renamed.hashValue,
             "Equal values hash differently")
precondition(original != otherTenant, "Tenant boundary lost")

var members: Set<Member> = [original]
precondition(!members.insert(renamed).inserted,
             "Duplicate identity inserted")
precondition(members.count == 1, "Unexpected member count")
precondition(members.first?.displayName == "Old",
             "Insert unexpectedly replaced the member")

let previous = members.update(with: renamed)
precondition(previous?.displayName == "Old",
             "Previous member missing")
precondition(members.count == 1, "Update changed member count")
precondition(members.first?.displayName == "New",
             "Updated fields missing")

members.insert(otherTenant)
precondition(members.count == 2, "Distinct tenants merged")

let states: [Member: String] = [original: "selected"]
precondition(states[renamed] == "selected",
             "Lookup failed after rename")
precondition(states[otherTenant] == nil,
             "Lookup crossed tenant boundary")

等価比較と hash(into:) では、同じ識別フィールドを使います。識別フィールドは let で定義し、表示用のフィールドは更新できるようにしています。特に注意したいのは、比較では名前を無視しているのに、ハッシュには名前を含めてしまう見落としやすい誤りです。これはプロトコルの要件に違反しており、毎回の実行ですぐに問題が表面化するとは限りません。

重複排除だけでなく、更新後の内容も検証する

スクリプトを保存したディレクトリで、次を実行します。

set -eu
xcrun swiftc -Onone identity-contract.swift -o identity-contract
./identity-contract

成功するとスクリプトは何も出力せずに終了し、事前条件に違反するとゼロ以外の終了ステータスを返します。-Onone はそのまま使い、安全性チェックを無効にする -Ounchecked は使わないでください。既存のパイプラインに組み込む場合は、このコマンドの終了ステータスでテストの成否を判定します。

Set の要素数が一つであることを確認するだけでは不十分です。insert は等しいメンバーがすでに存在する場合、その内容を置き換えません。つまり、重複排除に成功しても、名前の更新に成功したとは限りません。上の例では、まず古い名前が残っていることを確認し、その後 update(with:) を使って新しい名前が反映されたことを検証しています。

Dictionary のテストで確認するのは別の問題です。名前を変更したキーで、元の状態を引き続き取得できるかを確かめます。最新の完全なレコードを保存する必要がある場合は、辞書の値にレコードを持たせ、キーには同一性だけを持たせる設計を推奨します。表示用のデータをキーに含めないようにします。

境界ケースを補い、具体的なハッシュ値はテストしない

同一性のルールと入力のルールを分ける

文字列の識別子で大文字と小文字を区別するか、空の値を許可するかは、API の契約で決めるべきことです。ハッシュ関数の中で暗黙に変換してはいけません。正規化が必要ならモデルの作成前に行い、等価比較とハッシュの両方で同じ正規化結果を使います。

プロジェクトのテストに、次の検証を追加してください。

hashValue が特定の整数と一致することをアサートしたり、異なる同一性のハッシュが絶対に衝突しないことを求めたりしてはいけません。プロセスごとのランダム化と、仕様上許容される衝突があるため、そのようなテストには意味がありません。スクリプトを再実行すると不安定な振る舞いの発見に役立つことはありますが、フィールドに関するルールのレビューに代わるものではありません。

契約を本番モデルとマージ前のチェックに適用する

独立したスクリプトが証明するのは、あくまでサンプルのロジックです。実際のプロジェクトに組み込む際は、アサーションをテストターゲットに移し、本番モデルを直接インポートしてください。テスト専用のコピーを長期にわたって維持すると、業務コードに誤りが入ってもスクリプトは成功し続ける可能性があります。

現在のモデルが参照型なら、コレクションの要素として格納されている間に、ハッシュに使うフィールドを変更しないことが特に重要です。オブジェクト内部の状態が変わっても、コレクションのインデックスは自動では再構築されません。不変の識別キーへの変更を優先してください。同一性そのものを変える必要がある場合は、まず古いメンバーを削除し、それから新しいメンバーを作成して挿入します。

最後に、永続化と画面上の選択状態を見直します。保存すべきなのは明確な業務上の識別子であり、ハッシュ値やコレクションの走査順序上の位置ではありません。検証完了の基準は具体的です。名前を変更してもレコードが重複せず、更新後に新しい内容を取得でき、異なるテナントのメンバーが統合されず、検索と削除も正しく動作することです。

よくある質問

異なるレコードの hashValue が同じなら実装ミスですか?

いいえ。ハッシュ衝突は許容され、集合は等価性の比較で値を区別します。等しい値は整合的にハッシュされる必要がありますが、異なる識別子のハッシュが必ず異なる必要はありません。

名前を変更すると Set の既存レコードも自動更新されますか?

されません。Set の外にあるコピーを変更しても格納済みの要素は変わりません。update(with:) で等しい要素を置換し、要素数と新しいフィールドを確認してください。

hashValue をデータベースの永続キーに使えますか?

使えません。Swift のハッシュはランダム化され、プロセスやバージョンをまたぐ安定性は保証されません。サーバーの識別子や UUID を使い、テナント内でのみ一意ならテナント識別子も含めてください。

開発用に、専用のMacを1台確保

Mac miniの物理ノードを、仮想マシンではなく専有で利用できます。日単位・週単位・月単位・四半期単位から選び、用途と構成を決めたら、コントロールパネルに表示される最新の空き状況をご確認ください。

機種と利用期間を選ぶ 料金一覧を見る