JavaRush /Курси /Swift SELF /Користувацькі типи як ключі Dictionary/Set

Користувацькі типи як ключі Dictionary/Set

Swift SELF
Рівень 45 , Лекція 2
Відкрита

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-ключ зазвичай виграє одразу в трьох місцях: читабельність, безпека, розширюваність.

Порівняймо в таблиці:

Підхід Як виглядає Головна проблема Коли допустимо
Склеєний рядок
"ann#2026"
крихкий підхід: роздільники, екранування, колізії майже ніколи, хіба що в чернетці
Кортеж
(author, year)
у Swift кортежі не дають повноцінного протокольного конформансу для ролі «типу ключа», тому ви часто впираєтеся в обмеження мови краще не будувати на цьому архітектуру
Окремий struct Key: Hashable
AuthorYearKey(author: "Ann", year: 2026)
потрібно написати тип, але це буквально кілька рядків майже завжди найкращий варіант

Про кортежі: у 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). Якщо не вирішити, ви отримаєте три різні ключі й три «різні» записи, хоча користувач упевнений, що ввів одне й те саме.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ