iOS-Sammlungsidentität testen: Hashable und Feldänderungen

iOS-Sammlungsidentität testen: Hashable und Feldänderungen

Beim erneuten Abruf vom Server kommt derselbe Datensatz mit einem aktualisierten Namen zurück, doch in der Liste erscheint eine zusätzliche Zeile. An anderer Stelle dient der Datensatz als Schlüssel in einem Dictionary, und plötzlich lässt sich der bisherige Zustand nicht mehr abrufen. Bei solchen Problemen sollte man nicht gleich die Aktualisierungslogik neu schreiben. Oft lässt sich die abweichende Identitätsdefinition direkt aufspüren, indem man das Modell auf einem Cloud-Mac isoliert auf Gleichheit und sein Verhalten in Sammlungen prüft. Im Folgenden testen wir ausschließlich Werttypen – ohne Simulator und ohne Anbindung an eine echte API.

Zuerst festlegen, was als derselbe Datensatz gilt

Angenommen, eine Anwendung verwaltet Mitglieder mehrerer Mandanten: Die Mitglieds-ID ist nur innerhalb eines Mandanten eindeutig, der Name lässt sich ändern. Die fachliche Identität besteht daher aus „Mandanten-ID + Mitglieds-ID“; der Name gehört nicht dazu. Diese Regel sollte zunächst in der Testbeschreibung stehen, bevor das Protokoll implementiert wird.

Dabei sind zwei Fragen zu unterscheiden: Repräsentieren zwei Datensätze dasselbe Mitglied? Und stimmen sämtliche Felder der beiden Datensätze überein? Lässt man für eine Struktur die Hashable-Konformität automatisch synthetisieren, fließen in der Regel alle gespeicherten Eigenschaften in den Vergleich ein. Nach einer Namensänderung gelten die beiden Momentaufnahmen dann nicht mehr als gleich.

Die Gleichheitssemantik sämtlicher Modelle sollte nicht beliebig geändert werden, nur um Duplikate zu vermeiden. Wenn vollständige Momentaufnahmen verglichen werden müssen, ist es meist robuster, einen separaten, unveränderlichen Identitätsschlüssel zu definieren und vollständige Datensätze in einem Dictionary unter diesem Schlüssel abzulegen. In diesem Artikel verwenden wir bewusst ein Modell, dessen Gleichheit sich nach der Identität richtet, um den Vertrag unmittelbar sichtbar zu machen.

Gleiche Werte müssen nach denselben Regeln gehasht werden. Werte mit demselben Hash müssen dagegen nicht gleich sein. Ein Hash grenzt den Suchbereich ein; er ist keine dauerhafte Datensatz-ID.

Gleichheit und Hashing mit einem minimalen Skript festschreiben

Prüfen Sie, ob die Xcode-Kommandozeilentools auf dem Cloud-Mac verfügbar sind, indem Sie xcrun swift --version ausführen. Halten Sie die Ausgabe zusammen mit der Kennung des Code-Commits fest. Erstellen Sie die Datei identity-contract.swift mit folgendem Inhalt. Das Beispiel hat keine externen Abhängigkeiten und liest keine Zugangsdaten ein.

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")

Der Gleichheitsvergleich und hash(into:) verwenden dieselben Identitätsfelder. Diese sind mit let als unveränderlich deklariert, während sich Anzeigefelder aktualisieren lassen. Achten Sie insbesondere auf einen leicht zu übersehenden Fehler: Der Vergleich ignoriert den Namen, das Hashing bezieht ihn jedoch ein. Das verletzt die Anforderungen des Protokolls und muss nicht bei jedem Durchlauf sofort auffallen.

Nicht nur die Deduplizierung, sondern auch den aktualisierten Inhalt prüfen

Führen Sie im Verzeichnis des Skripts Folgendes aus:

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

Bei Erfolg beendet sich das Skript ohne Ausgabe. Schlägt eine Vorbedingung fehl, liefert es einen von null verschiedenen Exit-Status. Behalten Sie -Onone bei und verwenden Sie nicht -Ounchecked, da diese Option Sicherheitsprüfungen deaktiviert. In einer bestehenden Pipeline kann der Exit-Status dieses Aufrufs direkt darüber entscheiden, ob der Test bestanden ist.

Es genügt nicht, nur zu prüfen, dass das Set ein Element enthält. Trifft insert auf ein gleiches Mitglied, ersetzt es den vorhandenen Inhalt nicht. Erfolgreiche Deduplizierung bedeutet also noch nicht, dass auch der Name aktualisiert wurde. Das Beispiel prüft zunächst, dass der alte Name erhalten bleibt, und anschließend mit update(with:), dass der neue Name tatsächlich übernommen wurde.

Der Dictionary-Test beantwortet eine andere Frage: Lässt sich über den Schlüssel nach einer Namensänderung weiterhin der bisherige Zustand finden? Soll der jeweils neueste vollständige Datensatz gespeichert werden, empfiehlt es sich, ihn als Wert im Dictionary abzulegen und den Schlüssel auf die Identität zu beschränken. Anzeigedaten gehören dann nicht in den Schlüssel.

Grenzfälle abdecken, ohne konkrete Hashwerte zu testen

Identitätsregeln von Eingaberegeln trennen

Ob bei einer Identität aus Zeichenketten Groß- und Kleinschreibung unterschieden wird und ob leere Werte zulässig sind, muss der API-Vertrag festlegen. Solche Umwandlungen dürfen nicht versteckt in der Hashfunktion stattfinden. Ist eine Normalisierung erforderlich, sollte sie vor der Erstellung des Modells erfolgen. Gleichheitsvergleich und Hashing müssen anschließend dasselbe normalisierte Ergebnis verwenden.

Ergänzen Sie die Projekttests um folgende Prüfungen:

Prüfen Sie weder, dass ein bestimmter hashValue einer festen Ganzzahl entspricht, noch, dass unterschiedliche Identitäten niemals denselben Hash haben. Prozessübergreifende Randomisierung und zulässige Hashkollisionen machen solche Tests bedeutungslos. Wiederholte Skriptdurchläufe können helfen, instabiles Verhalten zu entdecken, ersetzen aber keine Prüfung der Regeln für die beteiligten Felder.

Den Vertrag im Produktionsmodell und in den Merge-Prüfungen verankern

Das eigenständige Skript bestätigt lediglich die Logik des Beispiels. Bei der Integration sollten Sie die Assertions in das Test-Target übernehmen und dort direkt das Produktionsmodell importieren. Pflegen Sie nicht dauerhaft eine separate Kopie nur für Tests: Sonst kann das Skript weiterhin erfolgreich durchlaufen, obwohl im Anwendungscode ein Fehler eingeführt wurde.

Ist das aktuelle Modell ein Referenztyp, dürfen insbesondere die am Hashing beteiligten Felder nicht verändert werden, solange das Objekt Bestandteil einer Sammlung ist. Eine Sammlung baut ihren Index bei internen Änderungen eines Objekts nicht automatisch neu auf. Bevorzugen Sie einen unveränderlichen Identitätsschlüssel. Muss sich die Identität tatsächlich ändern, entfernen Sie zuerst das alte Element und erstellen und ergänzen Sie danach ein neues.

Prüfen Sie abschließend auch die Persistenz und den Auswahlzustand der Benutzeroberfläche: Beide sollten explizite fachliche Kennungen speichern, keine Hashwerte oder Positionen in der Iterationsreihenfolge einer Sammlung. Die Abnahmekriterien sind konkret: Eine Namensänderung erzeugt keine doppelten Datensätze, nach einer Aktualisierung ist der neue Inhalt abrufbar, Mitglieder verschiedener Mandanten werden nicht zusammengeführt, und Suchen sowie Löschen funktionieren weiterhin korrekt.

Häufig gestellte Fragen

Ist derselbe hashValue für verschiedene Datensätze ein Fehler?

Nein. Hash-Kollisionen sind zulässig; Sammlungen unterscheiden Werte zusätzlich über Gleichheit. Gleiche Werte müssen konsistent gehasht werden, verschiedene Identitäten dürfen jedoch denselben Hash haben.

Ersetzt ein Set einen Datensatz automatisch nach einer Namensänderung?

Nein. Eine Änderung an einer Kopie außerhalb des Sets verändert das gespeicherte Element nicht. Verwenden Sie update(with:) und prüfen Sie anschließend die unveränderte Anzahl sowie die neuen Feldwerte.

Eignet sich hashValue als dauerhafter Datenbankschlüssel?

Nein. Swift-Hashing ist randomisiert und bleibt nicht garantiert über Prozesse oder Versionen hinweg stabil. Nutzen Sie eine Server-ID oder UUID und ergänzen Sie bei mandantenlokalen IDs die Mandantenkennung.

Ein eigener Mac für Ihre Projekte

Mac-mini-Hardware, keine virtuelle Maschine. Mieten Sie tage-, wochen-, monats- oder quartalsweise. Wählen Sie zuerst Einsatzzweck und Ausstattung und prüfen Sie anschließend die aktuelle Verfügbarkeit direkt in der Konsole.

Modell und Mietdauer wählen Alle Preise ansehen