JavaRush /Курсы /Swift SELF /Обновление индекса при изменениях

Обновление индекса при изменениях

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

1. Зачем индекс должен жить вместе с данными

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

Проблема простая: у нас есть две структуры в памяти.

  • booksByID — «настоящие данные».
  • index — «ускоритель», который должен соответствовать данным.

Если мы меняем booksByID и не меняем index, поиск начинает вести себя так, будто в библиотеке завёлся призрак: книга уже удалена, а индекс её всё ещё «видит». Или наоборот: книга добавлена, а поиск её «не знает». Поэтому сегодня мы фиксируем правило: все мутации данных обязаны обновлять индекс, а после загрузки данных индекс обязан быть пересобран (rebuild), если нет гарантированного актуального состояния.

2. Исходные типы: источник правды и индекс

Прежде чем говорить про обновления и пересборку, полезно договориться о минимальном каркасе. Мы не будем сейчас углубляться в файловый ввод/вывод и JSON — это уже слой вокруг. Нам важнее увидеть «сердце» репозитория: что именно хранится в памяти и кто за что отвечает. Как только каркас становится ясным, остальной код внезапно перестаёт быть магией.

Ниже — небольшой набор типов, с которым будем работать в примерах. Он нарочно простой: ID, книга и токен.

import Foundation

typealias Token = String

struct BookID: Hashable, Codable {
    let rawValue: UUID
}

struct Book: Codable {
    let id: BookID
    var title: String
    var author: String
}

И каркас репозитория (пока без load/save, потому что сегодня фокус на индексе):

import Foundation

enum RepositoryError: Error {
    case duplicateID(BookID)
    case notFound(BookID)
}

final class JSONFileRepository {
    private var booksByID: [BookID: Book] = [:]     // source of truth
    private var index: [Token: Set<BookID>] = [:]   // производная структура
}

3. Детерминированная токенизация: одно правило для данных и запросов

Когда говорят «поиск по словам», хочется сразу придумать умный разбор с морфологией, стоп-словами и прочими взрослыми вещами. Но в рамках нашего проекта мы не играем в «мини-Яндекс», мы играем в «предсказуемость». Поэтому важнее всего не идеальная лингвистика, а железобетонное правило: одинаковая токенизация для индексируемых данных и для запроса пользователя.

Сделаем функцию tokenize, которая приводит к нижнему регистру и разбивает строку по символам, которые не являются буквами и цифрами. Это даёт нам устойчивость к пробелам и пунктуации: Swift, Basics и Swift Basics станут одинаковыми токенами.

import Foundation

func tokenize(_ text: String) -> [Token] {
    text.lowercased()
        .split(whereSeparator: { !$0.isLetter && !$0.isNumber })
        .map { String($0) }
}

print(tokenize(" Swift,  Basics! ")) // ["swift", "basics"]

Обратите внимание: это правило не «самое умное», зато оно детерминированное. А детерминизм — это то, что спасает нас от «почему вчера искалось, а сегодня нет».

4. Пересборка индекса после загрузки

После load() мы получаем новые данные в booksByID. И вот здесь очень легко допустить ошибку новичка: «я же только что загрузил данные, значит индекс где-то уже был». Нет. Индекс — это производная структура, и она в момент загрузки либо пуста, либо устарела. Поэтому безопасное правило такое: после загрузки данных индекс всегда приводим в согласованное состояние через rebuild.

Пересборка индекса — это «снести и построить заново». Это звучит как варварство, но на практике это часто самый стабильный путь: вы не пытаетесь угадать, что менялось, вы просто берёте источник правды и создаёте индекс заново. Это особенно важно после load, потому что после загрузки мы не знаем «историю изменений», у нас есть только итоговое состояние.

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

flowchart TD
    A["load(): прочитали файл"] --> B["decode: получили книги"]
    B --> C["booksByID = ... (source of truth)"]
    C --> D["rebuildIndex()"]
    D --> E["репозиторий готов к search/add/update/remove"]

Реализация rebuildIndex() компактная: очищаем индекс и заново индексируем все книги из booksByID.values. Обход values — обычная и очень частая практика для словаря.

import Foundation

extension JSONFileRepository {
    private func rebuildIndex() {
        index = [:]
        for book in booksByID.values {
            indexBook(book)
        }
    }
}

Заметьте: rebuildIndex() вызывает indexBook, которого у нас ещё нет. Сейчас сделаем его — и rebuild станет рабочим.

5. Индексация одной книги: indexBook(_:)

Когда мы делаем add, update, remove и rebuildIndex, нам очень хочется не размазывать одну и ту же логику по всему классу. Во-первых, это неприятно читать. Во-вторых, это гарантированная ловушка: вы исправите токенизацию в одном месте и забудете в другом. Поэтому мы делаем маленькие методы-кирпичики: «как индексировать книгу» и «как разиндексировать книгу».

В простейшей версии индексируем и title, и author. Для удобства склеим их в одну строку и токенизируем. Да, склейка строк — это не верх оптимальности, но сейчас мы учимся проектировать корректность и контракт; микрооптимизации оставим будущему.

import Foundation

extension JSONFileRepository {
    private func indexBook(_ book: Book) {
        let text = book.title + " " + book.author
        for token in tokenize(text) {
            index[token, default: []].insert(book.id)
        }
    }
}

Здесь важная деталь: index[token, default: []] — это тот самый «удобный паттерн», который делает код аккуратным и предсказуемым: если ключа нет, берём пустое множество и вставляем. Это один из приятных моментов работы со словарями и множествами в Swift.

6. Удаление из индекса: unindexBook(_:)

Добавлять в индекс приятно: просто вставляй ID в Set. Удалять сложнее психологически, потому что приходится думать о «хвостах»: а что если после удаления множества станет пустым? Оставлять пустые множества в словаре можно, программа не взорвётся. Но индекс постепенно начнёт раздуваться «мёртвыми токенами», и вы будете хранить мусор.

Поэтому нормальная гигиена индекса такая: после remove(id) мы удаляем ID из каждого токена, а если Set стал пустым — удаляем и сам ключ из словаря.

import Foundation

extension JSONFileRepository {
    private func unindexBook(_ book: Book) {
        let text = book.title + " " + book.author
        for token in tokenize(text) {
            index[token]?.remove(book.id)
            if index[token]?.isEmpty == true {
                index[token] = nil
            }
        }
    }
}

Обратите внимание на стиль: мы не используем !, потому что отсутствие ключа в словаре — это нормальная ситуация. Индекс мог быть пересобран, книга могла быть не проиндексирована из-за ошибки в другом месте, и нам не нужно превращать такой случай в крэш процесса.

7. Обновление индекса при add/remove/update

Теперь у нас есть кирпичики indexBook и unindexBook. Это значит, что мы можем реализовать «правильную дисциплину»: любые мутации source of truth обязаны обновлять индекс. Очень удобно держать это в голове в виде таблицы: вы открыли код, посмотрели — и сразу ясно, что должно происходить.

Операция Что меняется в booksByID Что обязано произойти с индексом
add(book)
появляется новая запись
indexBook(book)
remove(id)
запись исчезает
unindexBook(removedBook)
update(book)
запись заменяется
unindexBook(old) + indexBook(new)

add: сначала инварианты, потом данные, потом индекс

add — это «появился новый объект». Здесь важно, чтобы add не превращался в «тихое обновление»: если ID уже существует, это контрактная ошибка. После успешного добавления обновляем индекс.

import Foundation

extension JSONFileRepository {
    func add(_ book: Book) throws {
        guard booksByID[book.id] == nil else {
            throw RepositoryError.duplicateID(book.id)
        }
        booksByID[book.id] = book
        indexBook(book)
    }
}

remove: получить удалённую книгу и разиндексировать

Трюк здесь в том, что unindexBook требует саму книгу (чтобы повторить токенизацию). Поэтому мы делаем removeValue(forKey:) и используем возвращаемое значение: если оно nil, книги не было — это notFound.

import Foundation

extension JSONFileRepository {
    func remove(id: BookID) throws {
        guard let removed = booksByID.removeValue(forKey: id) else {
            throw RepositoryError.notFound(id)
        }
        unindexBook(removed)
    }
}

update: «двухфазное» обновление индекса

Обновление — самое интересное, потому что книга могла поменять заголовок и автора, а значит набор токенов тоже меняется. Правильный (и надёжный) алгоритм: берём старую версию, разиндексируем её, кладём новую версию в booksByID, индексируем новую.

import Foundation

extension JSONFileRepository {
    func update(_ book: Book) throws {
        guard let old = booksByID[book.id] else {
            throw RepositoryError.notFound(book.id)
        }
        unindexBook(old)
        booksByID[book.id] = book
        indexBook(book)
    }
}

Этот вариант прост, легко читается и обычно достаточно хорош. Но у него есть цена: если книга меняется «чуть-чуть», мы всё равно удаляем и добавляем все токены.

8. update без лишней работы: обновляем индекс по разнице токенов

Иногда хочется сделать update чуть более «бережным»: не удалять и добавлять всё, а обновить индекс только там, где действительно поменялся набор токенов. Это полезно, если вы часто делаете мелкие правки (например, исправляете опечатку в одном слове), а данных много.

Для этого нам нужно уметь получить токены книги как Set<Token>, чтобы можно было сравнивать множества и брать разницу. Set как раз хорош тем, что умеет subtracting.

import Foundation

extension JSONFileRepository {
    private func tokenSet(for book: Book) -> Set<Token> {
        let text = book.title + " " + book.author
        return Set(tokenize(text))
    }
}

Теперь можно обновлять индекс точечно: убрать ID из токенов, которые были в старой версии, но исчезли в новой; и добавить ID в токены, которые появились в новой версии.

import Foundation

extension JSONFileRepository {
    private func updateIndex(old: Book, new: Book) {
        let oldTokens = tokenSet(for: old)
        let newTokens = tokenSet(for: new)

        for token in oldTokens.subtracting(newTokens) {
            index[token]?.remove(old.id)
        }
        for token in newTokens.subtracting(oldTokens) {
            index[token, default: []].insert(old.id)
        }
    }
}

Если делать так, то update становится чуть аккуратнее: вы всё равно обновляете booksByID, но индекс обновляете через updateIndex(old:new:). Важно: после удаления ID из токена всё ещё может понадобиться чистка пустых множеств. Мы не добавили её в пример, чтобы он не разросся, но по смыслу это тот же приём, что в unindexBook.

9. После load: применили данные → rebuild

Теперь соберём мысль в один практический шаблон. Представим, что load() уже прочитал JSON, декодировал DTO, преобразовал DTO в доменные Book, и мы получили массив books: [Book]. Нам нужно сделать две вещи: построить booksByID и затем пересобрать индекс.

Ключевой момент: booksByID удобно строить через Dictionary(uniqueKeysWithValues:).

import Foundation

extension JSONFileRepository {
    func applyLoadedBooks(_ books: [Book]) {
        booksByID = Dictionary(uniqueKeysWithValues: books.map { book in
            (book.id, book)
        })
        rebuildIndex()
    }
}

Здесь мы намеренно пишем closure явно { book in (book.id, book) }. Это выглядит чуть длиннее, зато новички не теряются, а код читабельнее.

Почему не используем \.rawValue

В требованиях к лекции есть конкретная оговорка: не использовать key-path литералы вида \.rawValue. Почему это может быть важно в учебном проекте? Потому что key-path синтаксис легко превращается в «магическое заклинание»: человек копирует, оно работает, но почему — непонятно. А нам сейчас важнее, чтобы было понятно, что делает каждая строка.

Например, если когда-нибудь вам понадобится получить из множества ID список UUID (для сериализации или отладки), мы делаем это обычным closure:

import Foundation

func idsAsUUIDs(_ ids: Set<BookID>) -> [UUID] {
    ids.map { id in
        id.rawValue
    }
}

А если вы захотите преобразовать весь индекс в «словарь токен → массив UUID», mapValues тоже используем с явным closure. В Swift словарь умеет такие преобразования очень удобно.

import Foundation

func debugIndex(_ index: [Token: Set<BookID>]) -> [String: [UUID]] {
    index.mapValues { ids in
        ids.map { id in id.rawValue }
    }
}

Да, это на пару символов длиннее, чем ids.map(\.rawValue). Зато в голове не остаётся ощущения, что «это какая-то магия компилятора». Хотя… магия там всё равно есть, просто теперь она хотя бы не выглядит как руны.

Самопроверка консистентности индекса

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

Самая честная проверка — пересобрать индекс «эталонно» и сравнить с текущим. В примере ниже мы строим новый индекс через rebuild-логику и сравниваем.

import Foundation

extension JSONFileRepository {
    func isIndexConsistent() -> Bool {
        let oldIndex = index
        rebuildIndex()
        let rebuilt = index
        index = oldIndex
        return oldIndex == rebuilt
    }
}

Эта функция не обязана быть суперэффективной; она про спокойствие разработчика. И да, если вы увидели здесь «мы пересобрали, потом вернули обратно» — вы всё правильно поняли. Это такой мини-детектор «я что-то забыл обновить».

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

Ошибка №1: обновляют booksByID, но забывают обновить индекс.
Это самая частая и самая неприятная проблема, потому что проявляется не сразу. add или update вроде бы отработали, а поиск стал «странным»: что-то находит, что-то нет. Лечится дисциплиной: любой метод, который меняет source of truth, обязан вызывать indexBook/unindexBook (или diff-обновление) в том же месте, не «где-нибудь потом».

Ошибка №2: в update добавляют новые токены, но не удаляют старые.
Это классика «полу-обновления»: вы добавили ID в новые токены, но забыли убрать из старых. В результате книга ищется по словам, которых в ней уже нет. Самый надёжный способ — делать unindexBook(old) indexBook(new). Более оптимизированный — diff токенов, но с тем же смыслом: старое должно быть вычищено.

Ошибка №3: не удаляют пустые множества из словаря индекса.
Технически это не ломает поиск, но ломает память и делает индекс «грязным»: токены копятся, хотя соответствующих книг уже нет. Если в unindexBook или при diff-удалении Set стал пустым, лучше удалить ключ полностью (index[token] = nil). Так индекс остаётся компактным и честным.

Ошибка №4: разная токенизация для индекса и для запроса.
Если данные индексируются через split по пробелам, а запрос — через разбиение по пунктуации, вы получите «необъяснимые» промахи поиска. Сегодняшняя лекция строится на идее, что tokenize — это один-единственный источник правил. Если правило меняется, оно меняется в одном месте и для данных, и для запросов.

Ошибка №5: пересборку индекса после load() пропускают «потому что и так работает».
Она будет «и так работать» ровно до первого запуска, когда индекс окажется пустым, или до первого сценария, где индекс был не пустым (например, репозиторий переиспользовался в процессе тестов/команд). После load() индекс должен быть гарантированно консистентным. Самый надёжный вариант — всегда rebuildIndex() после применения загруженных данных.

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