Проверка идентичности коллекций iOS: Hashable и обновления

Проверка идентичности коллекций iOS: Hashable и обновления

Проверка идентичности коллекций iOS: Hashable и обновления

С сервера приходит та же запись с новым именем, но в списке появляется лишняя строка. В другом месте запись используется как ключ Dictionary, и прежнее состояние уже не находится. При таких симптомах не спешите переписывать логику обновления. Перенесите модель на облачный Mac и отдельно проверьте равенство и поведение коллекций: часто это позволяет сразу обнаружить ошибку в определении идентичности. Ниже мы тестируем только модели-значения — без симулятора и обращений к реальному API.

Сначала определите, что считается одной и той же записью

Допустим, приложение управляет участниками нескольких арендаторов: идентификатор участника уникален только в пределах арендатора, а имя можно менять. Значит, бизнес-идентичность определяется парой «идентификатор арендатора + идентификатор участника», и имя в неё не входит. Сначала зафиксируйте это правило в описании тестов, а затем реализуйте протокол.

Здесь важно различать два вопроса: представляют ли два значения одного участника и совпадают ли у них все поля. Если позволить компилятору автоматически синтезировать реализацию 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 для ваших проектов

Физические узлы Mac mini, не виртуальные машины. Арендуйте на день, неделю, месяц или квартал: выберите задачу и конфигурацию, затем проверьте актуальную доступность в консоли.

Выбрать модель и срок аренды Посмотреть все цены