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, він усе ще розрізнятиме елементи за ==, але продуктивність стане гіршою, тому що «група кандидатів» буде дуже великою. Саме тому хороше хешування важливе не для правильності, а для швидкості та стійкості.
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 ?? "Не знайдено") // 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 залишайте для швидких структур даних в оперативній пам’яті.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ