Какие проблемы могут возникнуть при реализации протокола Hashable в Swift?

«Какие проблемы могут возникнуть при реализации протокола Hashable в Swift?» — вопрос из категории Swift Core, который задают на 10% собеседований IOS Разработчик. Ниже — развёрнутый ответ с разбором ключевых моментов.

Ответ

Некорректная реализация Hashable может привести к тонким и трудноотлавливаемым ошибкам. Основные проблемы:

  1. Нарушение контракта между Hashable и Equatable Контракт требует: если a == b, то a.hashValue == b.hashValue. Обратное не обязательно (из-за коллизий), но нарушение прямого условия приведет к непредсказуемому поведению Set и Dictionary.

    struct Broken: Hashable {
        let id: Int
        let name: String
    
        static func == (lhs: Self, rhs: Self) -> Bool {
            return lhs.id == rhs.id // Сравниваем только id
        }
    
        func hash(into hasher: inout Hasher) {
            hasher.combine(id)
            hasher.combine(name) // В хэш добавляем name! НАРУШЕНИЕ!
            // Два объекта с одинаковым id, но разным name будут равны (==),
            // но иметь разный хэш, что сломает коллекцию.
        }
    }
  2. Использование изменяемых свойств в хэшировании Если свойство, участвующее в hash(into:) и ==, изменяется после помещения объекта в Set или как ключ в Dictionary, коллекция ломается. Объект может стать "невидимым" или привести к утечке памяти.

    struct User: Hashable {
        var id: UUID // Изменяемое? Опасно!
    }
    var user = User(id: UUID())
    var set = Set([user])
    user.id = UUID() // КАТАСТРОФА: хэш объекта в коллекции больше не соответствует его новому состоянию.
  3. Низкокачественная хэш-функция, ведущая к частым коллизиям Хэш-функция, которая дает много одинаковых значений для разных входных данных, сводит производительность Dictionary к O(n), как у списка.

    • Плохо: hasher.combine(1) для всех объектов.
    • Хорошо: Комбинировать все значимые для равенства свойства.
  4. Проблемы с наследованием в классах При реализации hash(into:) в классе-наследнике необходимо комбинировать хэш суперкласса.

    class Vehicle: Hashable { ... }
    class Car: Vehicle {
        let licensePlate: String
        override func hash(into hasher: inout Hasher) {
            super.hash(into: &hasher) // Не забыть!
            hasher.combine(licensePlate)
        }
    }
  5. Игнорирование семантического равенства Выбор свойств для == и hash(into:) должен отражать логическое равенство объектов, а не просто сравнение всех полей.

    struct Person: Hashable {
        let id: Int // Уникальный идентификатор
        var lastUpdated: Date // Мета-информация
        // Равенство и хэш должны основываться только на `id`,
        // так как обновление времени не создает нового человека.
    }

Рекомендация: Для структур в большинстве случаев достаточно объявить соответствие протоколу Hashable — компилятор сгенерирует корректную реализацию, используя все хранимые свойства.