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 оставляйте для быстрых структур данных в оперативной памяти.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ