1. Ключ словника — частина дизайну
Коли ми вперше бачимо Dictionary, здається, що це просто «ключ → значення». І на простих прикладах це справді так: [String: Int], [Int: String] — усе просто. Але щойно ви починаєте писати застосунок, виявляється, що ключ — це не технічна деталь, а вибір моделі даних.
Якщо ви зберігаєте книги в «міні-бібліотеці», то можна зробити так:
- ключем буде title (рядок),
- або ключем буде ISBN,
- або ключем буде UUID,
- або ключем буде композитний ключ (автор + рік + нормалізована назва).
І кожен варіант змінює поведінку програми: що вважається «тією самою книгою», як відбувається пошук, що станеться під час оновлення полів і як легко зламати індекси.
Далі — практичний набір правил: які ключі обирати, коли варто робити окремий тип ключа і як не отримати «словник, який інколи не знаходить те, що сам же поклав».
Міні-чекліст вибору ключа
Спершу запитайте себе: ключ відповідає за ідентичність чи за групування?
Якщо за ідентичність, то найчастіше ключ — це ID. Якщо за групування, то ключ — це нормалізована ознака (наприклад, TitleKey) або композитний ключ (наприклад, AuthorYearKey).
Потім перевірте стабільність: ключ не має змінюватися сам по собі. Якщо ключ будується з рядка, краще сховати нормалізацію всередину типу ключа. Якщо ключ складається з кількох частин, краще зберігати їх як поля let в окремому struct, ніж збирати їх у рядок.
І нарешті, згадайте про контракт Hashable: реалізуючи hash(into:), ви маєте комбінувати суттєві частини ключа через hasher.combine(...). Саме для цього й існує Hasher: щоб розробник не займався «магією бітових операцій», а явно описував, які частини вважаються значущими.
2. Як Dictionary шукає ключ: хеш + ==
Зовні dict[key] виглядає як магія. Усередині це доволі прагматична двоступенева схема: спершу обчислюється хеш ключа, щоб швидко потрапити «в потрібний район», а потім виконується перевірка ==, щоб переконатися, що ключ справді той самий.
Тому колізії (коли різні ключі отримують однаковий хеш) допустимі, а коректність тримається на тому, що == фінально підтверджує збіг. Цю ідею безпосередньо закладено в дизайн стандартної бібліотеки та в мотивацію Hashable/Hasher.
Можна уявити це так:
flowchart TD
A["словник[key]"] --> B["обчислити hash(into:)"]
B --> C["знайти кошик/слот за хешем"]
C --> D["порівняти кандидатів через =="]
D --> E["знайдено значення або nil"]
Чому нам важливо це розуміти як розробникам?
Тому що звідси випливають два жорсткі практичні правила.
Перше: якщо ви вирішили, що два значення рівні (a == b), то вони зобовʼязані хешуватися однаково, інакше словник почне поводитися «дивно»: не знаходити ключі, дублювати те, що має бути унікальним, і так далі.
Друге: ключ повинен бути логічно стабільним. Якщо ви змінили суттєві частини ключа після вставлення, словник може перестати знаходити цей елемент, бо пошук піде за новим хешем, а значення лежить у старому «кошику».
Чому не можна використовувати hashValue як ідентифікатор
У якийсь момент хтось обов’язково скаже: «О, у ключа є hashValue, давайте його і зберігати як ідентифікатор! Він же Int, зручно!»
І тут Swift каже: «Я, звісно, можу, але потім не дивуйтеся».
Причина практична: хешер у стандартній бібліотеці має випадкове початкове зерно на кожен запуск процесу, тобто один і той самий ключ може мати різні хеші в різних запусках програми. Ба більше, сам алгоритм хешування може змінюватися між версіями стандартної бібліотеки. Це прямо зазначено в описі Hasher: не зберігайте й не перевикористовуйте hash values між запусками.
Тобто hashValue — це не «постійний номер об’єкта», а технічна деталь для прискорення колекцій.
3. ID-ключі: ключ адресує, значення зберігає дані
Найчастіша помилка новачка — намагатися зробити ключем усю сутність цілком. Наприклад, покласти Book як ключ і прив’язати до нього якісь дані.
Інтуїтивно хочеться сказати: «книга — це й є ключ». Але в більшості застосунків ключ має відповідати на запитання: як я адресую об’єкт? А сама сутність має лежати у значенні: що я зберігаю?
Міні-модель книги
Давайте продовжимо цю ідею: у книги є стабільний id, а решта може змінюватися.
import Foundation
struct BookID: Hashable {
let rawValue: UUID
init() {
self.rawValue = UUID()
}
}
struct Book {
let id: BookID
var title: String
var author: String
}
Зверніть увагу: BookID маленький, простий, з полями let, тож він добре підходить на роль ключа.
Тепер базове сховище:
import Foundation
var booksByID: [BookID: Book] = [:]
let book = Book(id: BookID(), title: "Swift Basics", author: "A. Developer")
booksByID[book.id] = book
print(booksByID[book.id]?.title ?? "НЕ ЗНАЙДЕНО") // Swift Basics
Ось це — здорова архітектура словника: ключ відповідає за адресацію, значення — за дані.
Коли окремий BookID кращий, ніж просто UUID
На цьому місці часто кажуть: «Ну то ключ — UUID, навіщо обгортка?» А потім у коді з’являються userID: UUID, bookID: UUID, orderID: UUID, і одного дня ви передаєте не той UUID не в те місце. Компілятор нічого не підказує, бо тип той самий.
BookID робить код типобезпечнішим: функція remove(bookID:) уже не прийме UserID випадково. І цей підхід особливо добре поєднується з тим, що Hashable у Swift легко синтезується, якщо всі поля теж Hashable і ви явно оголосили конформанс.
4. Композитні та рядкові ключі: робимо struct Key
Іноді ID немає (або він вам не потрібен), і ви хочете адресувати дані за кількома полями одразу: наприклад, «автор + рік» або «полиця + ряд + місце». На цьому етапі в новачків часто з’являється бажання склеїти рядок: "\(author)#\(year)".
Так, це можливо. Але майже завжди це погана ідея: легко помилитися з роздільником, легко отримати конфлікти («Ann#20» проти «An#n20»), важко підтримувати, незручно розширювати.
Правильніше зробити окремий тип ключа.
Композитний ключ: «Автор + Рік»
import Foundation
struct AuthorYearKey: Hashable {
let author: String
let year: Int
}
var booksByAuthorYear: [AuthorYearKey: [Book]] = [:]
Тепер заповнюємо:
import Foundation
let a = Book(id: BookID(), title: "Swift 101", author: "Ann")
let b = Book(id: BookID(), title: "Swift 102", author: "Ann")
let key = AuthorYearKey(author: a.author, year: 2026)
booksByAuthorYear[key, default: []].append(a)
booksByAuthorYear[key, default: []].append(b)
print(booksByAuthorYear[key]?.count ?? 0) // 2
Зверніть увагу на патерн dict[key, default: ] — він працює точно так само з вашими ключами, як і зі стандартними.
Нормалізація: рядкові ключі треба приводити до канону
Якщо ключ — рядок, то користувач (або дані із зовнішніх джерел) швидко влаштує вам фестиваль пробілів, регістрів і дивних варіацій:
- "Swift" і "swift" — це одне й те саме чи різне?
- " Swift " — це те саме, що "Swift"?
- "Swift Basics" і "Swift Basics"?
Якщо ви хочете, щоб це вважалося однаковим, потрібно зафіксувати правила й порівнювати канонічну форму.
Приклад: TitleKey як нормалізований ключ.
import Foundation
struct TitleKey: Hashable {
let canonical: String
init(_ raw: String) {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
self.canonical = trimmed.lowercased()
}
}
Використання:
import Foundation
var idsByTitle: [TitleKey: Set<BookID>] = [:]
let id = BookID()
idsByTitle[TitleKey(" Swift Basics "), default: []].insert(id)
print(idsByTitle[TitleKey("swift basics")]?.contains(id) ?? false) // true
Тут важлива ідея: ми нормалізуємо один раз у ключі, а не розмазуємо .trimmingCharacters і .lowercased() по всьому коду. Це економить мозок і зменшує шанс «в одному місці нормалізували, в іншому забули».
Чому struct Key зазвичай кращий, ніж «склейка рядка»
Склейка рядка здається дешевою і швидкою. Але окремий struct-ключ зазвичай виграє одразу в трьох місцях: читабельність, безпека, розширюваність.
Порівняймо в таблиці:
| Підхід | Як виглядає | Головна проблема | Коли допустимо |
|---|---|---|---|
| Склеєний рядок | |
крихкий підхід: роздільники, екранування, колізії | майже ніколи, хіба що в чернетці |
| Кортеж | |
у Swift кортежі не дають повноцінного протокольного конформансу для ролі «типу ключа», тому ви часто впираєтеся в обмеження мови | краще не будувати на цьому архітектуру |
| Окремий struct Key: Hashable | |
потрібно написати тип, але це буквально кілька рядків | майже завжди найкращий варіант |
Про кортежі: у Swift уже були обговорення та пропозиції щодо повноцінного Equatable/Hashable/Comparable для tuples, але це нетривіальна мовна тема. Зокрема, там є тонкощі на кшталт того, що мітки елементів можуть не враховуватися під час хешування. Тому в прикладному коді для ключів частіше обирають маленький struct.
5. Індекси та оновлення: ключ → набір BookID
Коли застосунок починає шукати дані не лише за ID, з’являється природний патерн: ми зберігаємо сутності за ID, а паралельно тримаємо словники-індекси, які швидко дають нам набір відповідних ID.
Тобто:
- booksByID: [BookID: Book] — основне сховище,
- idsByTitle: [TitleKey: Set<BookID>] — індекс за назвою,
- далі можуть з’являтися індекси за токенами, автором тощо, але зараз нам достатньо однієї ідеї.
Зберемо це в маленький тип Library.
import Foundation
struct Library {
private(set) var booksByID: [BookID: Book] = [:]
private(set) var idsByTitle: [TitleKey: Set<BookID>] = [:]
mutating func add(_ book: Book) {
booksByID[book.id] = book
idsByTitle[TitleKey(book.title), default: []].insert(book.id)
}
}
Перевіримо:
import Foundation
var lib = Library()
let b = Book(id: BookID(), title: " Swift Basics ", author: "Ann")
lib.add(b)
let foundIDs = lib.idsByTitle[TitleKey("swift basics")] ?? []
print(foundIDs.contains(b.id)) // true
Це вже дуже схоже на зрілу логіку: ключі в словниках — користувацькі типи (BookID, TitleKey), але код залишається коротким і зрозумілим.
Оновлення: індекс потрібно підтримувати вручну
Поки ми лише додавали книги. Але в реальному світі книга може змінити назву (виправили помилку) або автора (привели до нормалізованого написання).
І тут постає неприємна правда: якщо ви підтримуєте індекс, ви зобов’язані оновлювати його під час змін. І саме тому ключі для індексів мають бути максимально передбачуваними, а самі індекси — оновлюватися в одному місці.
Додамо метод updateTitle.
import Foundation
extension Library {
mutating func updateTitle(id: BookID, newTitle: String) {
guard var book = booksByID[id] else { return }
// 1) прибрати id зі старого ключа
idsByTitle[TitleKey(book.title)]?.remove(id)
// 2) оновити книгу
book.title = newTitle
booksByID[id] = book
// 3) додати id до нового ключа
idsByTitle[TitleKey(book.title), default: []].insert(id)
}
}
І перевіримо:
import Foundation
var lib = Library()
let id = BookID()
lib.add(Book(id: id, title: "Swift Baiscs", author: "Ann"))
lib.updateTitle(id: id, newTitle: "Swift Basics")
print(lib.idsByTitle[TitleKey("swift baiscs")]?.contains(id) ?? false) // false
print(lib.idsByTitle[TitleKey("swift basics")]?.contains(id) ?? false) // true
Зверніть увагу: тут ключовий принцип — індекс не буває «магічно правильним», він правильний рівно настільки, наскільки дисципліновано ви його оновлюєте.
Небезпечний кейс: змінюваний ключ
До цього ми працювали з struct-ключами (BookID, TitleKey) і робили поля let. Це ідеальний сценарій: ключ незмінний, отже, не може «переїхати» в інший кошик словника.
Але після теми про class люди іноді перетворюють ключ на посилальний об’єкт і дають йому змінювані поля. І якщо ці поля беруть участь у ==/hash(into:), ви отримаєте словник-привид: «я щойно поклав — і вже не можу знайти».
Демонстрація (код компілюється, але приклад поганий з погляду дизайну):
import Foundation
final class MutableKey: Hashable {
var value: Int
init(value: Int) { self.value = value }
static func == (lhs: MutableKey, rhs: MutableKey) -> Bool {
lhs.value == rhs.value
}
func hash(into hasher: inout Hasher) {
hasher.combine(value)
}
}
var dict: [MutableKey: String] = [:]
let k = MutableKey(value: 10)
dict[k] = "Ten"
k.value = 11
print(dict[k] ?? "НЕ ЗНАЙДЕНО") // НЕ ЗНАЙДЕНО
Що сталося? Ви змінили те, що брало участь у хешуванні. Для словника це так, ніби ви переклеїли адресу на будинку, але забули повідомити пошту й навігатор.
Практичний висновок: ключі мають бути логічно стабільними. Якщо це struct, робіть поля let. Якщо це class, майже завжди краще взагалі не використовувати її як ключ, а застосувати стабільний ID.
6. Типові помилки
Помилка № 1: ключем роблять «велику сутність» зі змінними полями.
Коли ключ — це весь Book, а всередині Book змінюється title або author, ви фактично робите так, що адреса об’єкта залежить від його поточного стану. Це майже завжди суперечить очікуванням: ви хотіли «знайти ту саму книгу», а отримали «нову книгу, бо назва змінилася».
Помилка № 2: неузгодженість == і hash(into:).
Іноді рівність роблять «за id», а хешування — «за id + title» (або навпаки). Тоді дві рівні сутності можуть мати різні хеші, а словник і множина починають поводитися непередбачувано. Ваше завдання як автора типу — щоб «суттєві частини» збігалися і для ==, і для hash(into:).
Помилка № 3: використовують hashValue як ідентифікатор або зберігають його «на потім».
Це дуже спокусливо, бо Int короткий, гарний і проситься в лог. Але Hasher спеціально влаштований так, що хеші не зобов’язані бути стабільними між запусками та версіями стандартної бібліотеки. Тому hashValue — не ID, а внутрішній інструмент колекцій.
Помилка № 4: роблять ключ змінюваним і змінюють його після вставлення.
Особливо часто це трапляється з class, де ви вставили об’єкт-ключ у словник, а потім змінили поле, що бере участь у hash(into:). Після цього ви отримуєте ефект «словник забув, де лежить значення». Правило просте: ключ має бути логічно незмінюваним; на практиці це означає let-поля в struct і відмову від самої ідеї змінюваних ключів.
Помилка № 5: рядки використовують як ключ без нормалізації, а потім дивуються дублям.
Якщо ви хочете, щоб "Swift", " swift " і "SWIFT" вважалися одним і тим самим, це потрібно вирішити на рівні ключа (наприклад, TitleKey). Якщо не вирішити, ви отримаєте три різні ключі й три «різні» записи, хоча користувач упевнений, що ввів одне й те саме.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ