JavaRush /Курсы /Swift SELF /Hashable в Swift: hash(into:)

Hashable в Swift: hash(into:)

Swift SELF
44 уровень , 4 лекция
Открыта

1. Зачем нужен Hashable

Если смотреть на Set и Dictionary, кажется, что это просто «массивы, но поумнее». На деле у них другой принцип работы: вместо перебора всех элементов они пытаются быстро найти нужный ключ или проверить наличие элемента. Для этого они используют хэш — число, которое вычисляется из значения и помогает «примерно понять», куда его класть и где искать.

С практической точки зрения Hashable нужен для двух вещей: чтобы ваш тип мог быть элементом Set, и чтобы он мог быть ключом Dictionary. То есть Set<BookID> и [BookID: Book] — это не прихоть компилятора, а гарантия, что операции вроде contains, insert, dict[key] будут работать быстро и предсказуемо.

В нашем CLI‑приложении LibraryCLI это особенно важно: как только у нас появляется идентификатор книги (BookID), мы естественным образом хотим строить быстрые индексы. Например, хранить книги по ID, хранить «слово → набор ID книг», хранить уникальные авторы, уникальные теги. И всё это упирается в Hashable.

2. Как работают Set и Dictionary: хэш + ==

Представьте библиотеку с огромным складом, где каждая книга должна лежать в «примерно правильном» секторе. Чтобы не ходить по всем стеллажам, библиотекарь сначала смотрит на номер сектора, а уже потом, внутри сектора, сверяет точное совпадение. Это и есть модель «хэш → финальная проверка».

В Swift это выражается простой идеей: Set и Dictionary используют хэш, чтобы быстро сузить поиск, но окончательное решение «это тот же элемент или нет» принимается через ==. Именно поэтому Hashable наследуется от Equatable и почему нельзя «хешировать одно, а сравнивать другое»: эти две части должны быть согласованы.

Небольшая схема (упрощённо) того, что происходит при contains:

flowchart TD
    A[Ищем элемент/ключ] --> B[Вычисляем хэш]
    B --> C[Находим подходящую группу/ячейку]
    C --> D[Сравниваем через == с кандидатами]
    D --> E{Нашли равный?}
    E -->|Да| F[Успех: элемент есть]
    E -->|Нет| G[Элемента нет]

Важный вывод: хэш — это не «уникальный номер», это скорее «быстрый указатель, где примерно искать». А равенство (==) — это уже «точная идентификация».

3. hash(into:) и Hasher

Раньше (исторически) в Swift существовало требование реализовывать hashValue вручную, и люди начинали изобретать свои «магические формулы» с XOR и умножением. Это выглядело умно ровно до тех пор, пока не начинало тормозить или пока кто-то не приносил данные, от которых формула давала лавину коллизий. Поэтому в Swift появился нормальный, современный путь: hash(into:) и Hasher.

Hasher — это специальный тип из стандартной библиотеки, который умеет «смешивать» компоненты значения в хэш, а вы просто говорите: «вот существенные части моего типа». Вы не обязаны быть экспертом по хэш‑функциям. Более того, вам не нужно и не стоит быть таким экспертом в рамках прикладного кода.

Ещё один критичный момент: Hasher обычно использует случайное зерно (seed) на запуск программы, поэтому хэш‑значения не обязаны совпадать между запусками приложения. Следовательно, hashValue нельзя сохранять в файл как «ID» и нельзя отправлять наружу как «стабильный ключ».

Мини‑демо того, как работает Hasher в принципе (вам редко нужно делать это руками, но полезно для понимания):

import Foundation

var hasher = Hasher()
hasher.combine(23)
hasher.combine("Hello")
let value = hasher.finalize()

print(value) // Например: -407812345678 (значение не обязано быть стабильным)

И да, если у вас совпало число у соседа по парте — не делайте выводов. Возможно, это просто судьба. Или коллизия. Или сосед — @unchecked Sendable (шутка, пока рано).

Автосинтез Hashable

Когда вы объявляете struct Something: Hashable, Swift часто способен сам сгенерировать корректное хеширование — при условии, что все stored properties тоже Hashable. Это было добавлено как осознанная «опция по умолчанию», чтобы программисты не писали вручную хрупкий код.

Вот пример «идеального кандидата» на автосинтез — наш BookID, который просто оборачивает UUID:

import Foundation

struct BookID: Hashable {
    let rawValue: UUID
}

let a = BookID(rawValue: UUID())
let b = a
print(a == b) // true

Здесь компилятор автоматически делает и ==, и hash(into:), потому что UUID уже умеет и то, и другое.

Когда автосинтез хорош? Когда вы реально согласны с тем, что «равенство по всем полям» — это ваш контракт. А если равенство у вас «по подмножеству полей» (например, только по id), тогда и хэширование тоже должно быть «по этому подмножеству», и тут мы переходим к ручной реализации.

Почему нельзя использовать hashValue как ID

Очень частая попытка «схитрить»: «А давайте hashValue сделаем идентификатором! Он же Int, удобно!»

К сожалению (и к счастью), hashValue — не ID. Более того, хэширование в Swift специально устроено так, чтобы хэш мог отличаться между запусками программы, потому что Hasher обычно инициализируется случайным seed и алгоритм может меняться между версиями стандартной библиотеки.

Поэтому:

  • Если вам нужен ID — используйте UUID (или ваш собственный стабильный формат), а не hashValue.
  • Если вам нужен быстрый ключ в памяти — используйте Hashable и hash(into:), но не пытайтесь «сохранить хэш на диск».

В LibraryCLI это идеально укладывается в модель: BookID содержит UUID, а уже BookID становится ключом словаря или элементом множества.

4. Ручной Hashable: правило согласованности с ==

Самое важное правило сегодняшней лекции звучит так:

Если a == b, то a и b обязаны давать одинаковый хэш при hash(into:).

Это не «рекомендация для красоты». Это фундаментальная предпосылка корректности Set и Dictionary. Нарушите — и получите коллекции, которые ведут себя так, будто они вас не уважают (а компилятор при этом будет молчать, потому что формально вы реализовали протокол).

Рассмотрим типичную ситуацию из нашего LibraryCLI. Книга — сущность: название может поменяться (опечатку исправили), автор может быть отредактирован (инициалы), год уточнили. Но книга всё равно «та же» — по ID.

Тогда логично сделать Equatable по ID, и Hashable тоже по ID.

import Foundation

struct BookID: Hashable {
    let rawValue: UUID
}

struct Book: Hashable {
    let id: BookID
    var title: String

    static func == (lhs: Book, rhs: Book) -> Bool {
        lhs.id == rhs.id
    }

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

Обратите внимание, как читается hash(into:): мы не строим «число руками», мы просто говорим: «смешай в хэш мой id». Это как сказать бармену «пожалуйста, сделайте кофе» вместо того, чтобы приносить воду, зёрна и инструкцию по помолу.

5. Коллизии и почему это нормально

Слово «коллизия» звучит как «всё сломалось», но в мире хэшей это нормальная и ожидаемая вещь: два разных значения могут иметь одинаковый хэш. Хэш — это Int, а значений в мире потенциально бесконечно много (особенно если внутри строка). Поэтому коллизии возможны математически.

Swift это учитывает: хэш используется как ускоритель, а корректность всегда держится на сравнении через ==. Именно поэтому даже если два разных объекта попали «в одну ячейку», коллекция обязана перепроверить равенство.

Давайте намеренно создадим «плохой» тип, который провоцирует коллизии. Это учебный анти‑пример: так делать нельзя, но полезно увидеть, что корректность не ломается мгновенно — просто может стать медленно.

import Foundation

struct BadKey: Hashable {
    let value: Int

    func hash(into hasher: inout Hasher) {
        hasher.combine(0) // намеренно одинаковый хэш для всех
    }
}

let a = BadKey(value: 1)
let b = BadKey(value: 2)
print(a == b) // false

Если вы поместите много таких ключей в Set, Set всё ещё будет отличать элементы по ==, но производительность будет хуже, потому что «группа кандидатов» будет огромной. Именно поэтому хорошее хеширование важно не для «правильности», а для «скорости и устойчивости».

6. Стабильность ключа: что нельзя менять после вставки

Есть тонкая ловушка, из-за которой новички начинают сомневаться в реальности: «Я вставил ключ в словарь, а потом он перестал находиться. Swift, ты чего?»

Проблема почти всегда одна: вы сделали ключ изменяемым (var) и меняете поле, которое участвует в равенстве/хэше. В результате ключ «логически переехал в другой сектор», но словарь об этом не знает, потому что словарь не пересортировывает себя при каждой вашей мутации.

Поэтому правило простое и почти всегда правильное в доменной модели: всё, что участвует в == и hash(into:), должно быть стабильным. Чаще всего это означает let, а не var. Особенно это касается id.

Для LibraryCLI это прям спасение: BookID лучше сделать неизменяемым, и в Book сделать id неизменяемым, а меняющимися оставить title, author, year.

import Foundation

struct BookID: Hashable {
    let rawValue: UUID
}

struct Book {
    let id: BookID        // стабильно
    var title: String     // можно редактировать
}

Такой дизайн уменьшает число «мистических» багов примерно на 80%, а остальные 20% уйдут на печеньки и отладку.

7. Hashable в LibraryCLI: быстрые структуры данных

Теперь давайте приземлим всё на наш учебный проект. Мы хотим как минимум два быстрых механизма:

  • Первый — хранить книги по ID, чтобы команда вроде show <id> работала без перебора массива.
  • Второй — хранить простой индекс для поиска: например, «нормализованное слово из названия → множество BookID», чтобы команда поиска могла быстро сузить кандидатов.

И то, и другое требует, чтобы BookID был Hashable. Поскольку он оборачивает UUID, это получается очень естественно.

Храним книги по ID: [BookID: Book]

import Foundation

struct BookID: Hashable { let rawValue: UUID }

struct Book: Hashable {
    let id: BookID
    var title: String
    func hash(into hasher: inout Hasher) { hasher.combine(id) }
    static func == (l: Book, r: Book) -> Bool { l.id == r.id }
}

var booksByID: [BookID: Book] = [:]
let id = BookID(rawValue: UUID())
booksByID[id] = Book(id: id, title: "Swift для людей")
print(booksByID[id]?.title ?? "NOT FOUND") // Swift для людей

Здесь важно, что ключом выступает BookID, а не вся книга. Это хороший стиль: ключ — маленький и стабильный, значение — полное состояние.

Индекс «слово → набор книг»: [String: Set<BookID>]

В ранних лекциях мы уже применяли dict[key, default: ...] для счётчиков и накопления. Этот приём отлично работает и здесь: мы хотим накапливать в словаре множество ID, а не число.

import Foundation

struct BookID: Hashable { let rawValue: UUID }

var index: [String: Set<BookID>] = [:]
let token = "swift"
let bookID = BookID(rawValue: UUID())

index[token, default: []].insert(bookID)
print(index[token]?.count ?? 0) // 1

Это выглядит почти как магия, но на самом деле это просто «если ключа нет — подставь пустой Set, потом вставь в него bookID».

И вот здесь мы получаем момент, за который Hashable стоит любить: Set<BookID> гарантирует уникальность. Если вы дважды добавите один и тот же bookID в тот же token, дубликата не будет — и это поведение полностью основано на корректном == и корректном hash(into:).

8. Типичные ошибки

Ошибка №1: несогласованность == и hash(into:).
Самая разрушительная (и при этом тихая) ошибка — сравнивать одно, а хэшировать другое. Например, == сравнивает только id, а hash(into:) смешивает id и title. Тогда две книги с одинаковым id, но разным title будут считаться равными, но иметь разные хэши — и Set/Dictionary могут начать вести себя непредсказуемо. Надёжный стиль: сначала выписать «существенные поля» равенства, затем использовать точно тот же набор в hash(into:).

Ошибка №2: включать в хэш «редактируемые» поля, если равенство по ID.
Иногда хочется «усилить уникальность» и добавить в хэш title или author. Но если по вашему контракту книга «та же» при изменении названия, то эти поля не должны участвовать в hash(into:). Иначе вы получите ситуацию, когда редактирование названия меняет хэш, а значит меняет поведение в хэш‑коллекциях. Это особенно неприятно, если книга лежит в Set<Book>.

Ошибка №3: делать ключ изменяемым и менять его после вставки в Set/Dictionary.
Если вы используете тип как ключ или как элемент Set, и потом меняете внутри него поля, влияющие на хэш/равенство, вы нарушаете базовую предпосылку коллекции: элемент должен оставаться «в своём секторе». В доменной модели почти всегда лечится так: всё существенное делаем let, а изменяемыми оставляем только поля состояния, которые не участвуют в идентичности.

Ошибка №4: воспринимать коллизии как признак «неправильной работы Set».
Коллизии возможны всегда, потому что хэш — конечного размера, а значений потенциально бесконечно. Корректность держится на том, что после совпадения хэша Swift всё равно делает проверку через ==. Если вы видите коллизии в теории — это нормально. Ненормально, когда вы сами делаете хэш «плохим» (например, всегда 0) и тем самым превращаете быстрые операции в медленные.

Ошибка №5: использовать hashValue как внешний идентификатор или сохранять его.
hashValue может меняться между запусками и даже между версиями стандартной библиотеки, потому что Hasher обычно использует случайный seed и является деталью реализации. Это означает, что «вчерашний hashValue» не обязан совпасть с «сегодняшним». Для хранения и обмена используйте UUID/строки/ваш формат, а Hashable оставляйте для быстрых структур данных в оперативной памяти.

1
Задача
Swift SELF, 44 уровень, 4 лекция
Недоступна
Метки на карте
Метки на карте
1
Задача
Swift SELF, 44 уровень, 4 лекция
Недоступна
Очки игрока
Очки игрока
1
Задача
Swift SELF, 44 уровень, 4 лекция
Недоступна
Контакт по паре
Контакт по паре
1
Задача
Swift SELF, 44 уровень, 4 лекция
Недоступна
Поисковый индекс
Поисковый индекс
1
Опрос
Контракты стека, 44 уровень, 4 лекция
Недоступен
Контракты стека
Контракты, инварианты, Equatable, Hashable
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ