Сохраняйте неизменными свойства, используемые для сравнения и хеширования, пока объект служит ключом словаря. Для локальных вычислений на Swift на Mac выбирайте идентичность объекта, неизменяемый идентификатор или снимок значения — в зависимости от смысла поиска.

В этом руководстве используются искусственные прямоугольники и простые вычисления площади, выполняемые целиком в памяти. Практическая задача — позволить изменить метку или размеры прямоугольника, не потеряв связанную с ним запись словаря и не перепутав успешный поиск с актуальностью вычисления.

Почему изменение объекта может нарушить поиск в словаре?

Dictionary в Swift — это хеш-таблица, ключи которой соответствуют протоколу Hashable. При обращении по индексу возвращается опциональное значение, поскольку запрошенный ключ может отсутствовать. Хеширование помогает найти запись, а равенство определяет, какие ключи считаются эквивалентными. См. документацию Apple по Dictionary.

Контракт Hashable связывает эти операции: экземпляры, равные друг другу, должны передавать в Hasher одинаковые существенные компоненты в одном и том же порядке. При реализации собственного ключа используйте свойства, определяющие равенство в hash(into:). Apple описывает это требование в Hashable.

У ключа-ссылки есть ещё одна особенность. Присваивание экземпляра класса передаёт ссылку на существующий объект, а не создаёт независимый объект. Объявление ссылки с помощью let не делает изменяемые свойства неизменными. Эти правила языка описаны в разделе Structures and Classes.

Предположим, класс прямоугольника сравнивает и хеширует редактируемую метку. Вы добавляете объект, когда его метка равна "Tile A", а затем меняете её на "Tile B". Теперь хранящаяся ссылка указывает на состояние ключа с другими определяющими свойствами. Исходное размещение в словаре и последующий поиск больше не имеют стабильного соответствия.

Из контрактов хеш-таблицы и равенства следует правило проектирования: состояние ключа, определяющее равенство, должно оставаться неизменным, пока ключ находится в словаре. Apple прямо формулирует соответствующее требование для объектов Foundation: хеш объекта не должен меняться, пока объект входит в коллекцию, использующую этот хеш для размещения. См. NSObjectProtocol.hash.

Не основывайте тест на предположении, что каждое недопустимое изменение немедленно возвращает nil. После нарушения контракта ключа кажущийся успешным поиск не доказывает, что решение работает. Проверяйте предполагаемые правила равенства и стабильность поведения.

Какой ключ соответствует задаче?

Прежде чем объявлять соответствие протоколу, определите смысл записи словаря. Для инструмента вычисления площади прямоугольника эти три вопроса ведут к разным решениям:

Что должна представлять запись? Подходящий ключ Что происходит при изменении прямоугольника?
Именно этот объект-прямоугольник Ключ класса на основе идентичности Запись остаётся связанной с этим экземпляром
Логический прямоугольник, определяемый независимо от текущего объекта Значение неизменяемого идентификатора Запись остаётся связанной с идентификатором
Вычисление для определённых размеров Снимок неизменяемых размеров Новые размеры дают другой ключ поиска

Эта таблица помогает выбрать решение на основе правил идентичности, значений и хеширования в Swift. Она также разделяет две обязанности: сохранять доступность связи и поддерживать актуальность вычисленного результата. Стабильный ключ идентичности решает первую задачу; повторное вычисление или полный снимок входных данных — вторую.

Используйте идентичность объекта, если запись относится к одному экземпляру

Для связи в памяти, которая должна следовать за одним объектом при его редактировании, определите равенство с помощью ===, а хешируйте его ObjectIdentifier. Оператор идентичности в Swift проверяет, указывают ли ссылки на один и тот же экземпляр. Apple описывает ObjectIdentifier как хешируемый идентификатор, сравнивать который можно в течение времени жизни экземпляра. См. Structures and Classes и ObjectIdentifier.

Ниже приведена полная синхронная программа. Для её запуска нужен Mac с установленными инструментами командной строки Swift; используется только автоматически импортируемая стандартная библиотека Swift. Сохраните её как IdentityKeys.swift и выполните в Терминале команду swift IdentityKeys.swift. Примеры были проверены, но не выполнены в исследовательской среде, поскольку там не было Swift.

final class Rectangle: Hashable {
    var label: String
    var width: Int
    var height: Int

    init(label: String, width: Int, height: Int) {
        self.label = label
        self.width = width
        self.height = height
    }

    static func == (lhs: Rectangle, rhs: Rectangle) -> Bool {
        lhs === rhs
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(ObjectIdentifier(self))
    }
}

func demonstrateIdentityKeys() {
    let rectangle = Rectangle(label: "Tile A", width: 4, height: 3)
    let alias = rectangle
    let separate = Rectangle(label: "Tile A", width: 4, height: 3)

    var areas: [Rectangle: Int] = [
        rectangle: rectangle.width * rectangle.height
    ]

    rectangle.label = "Tile B"
    assert(areas[alias] == 12)
    assert(areas[separate] == nil)

    rectangle.width = 5
    assert(areas[rectangle] == 12) // Still the previous calculation.

    areas[rectangle] = rectangle.width * rectangle.height
    assert(areas[rectangle] == 15)
    assert(areas.count == 1)

    print("Renamed lookup: 12")
    print("Recalculated area: \(areas[rectangle]!)")
}

demonstrateIdentityKeys()

Ожидаемый вывод: Renamed lookup: 12, затем Recalculated area: 15. Утверждения выражают полезный контракт: псевдоним находит существующую запись, отдельно созданный прямоугольник — нет, а редактирование исходного объекта не создаёт новый ключ.

Изменение ширины намеренно оставляет сохранённую площадь равной 12, пока программа не присвоит ей 15. Идентичность остаётся стабильной, но словарь не отслеживает зависимости вычислений и не пересчитывает значения. Решите, должно ли приложение обновлять значение сразу после редактирования или удалять его до следующего вычисления.

Равенство по идентичности — это решение для всего типа. Теперь каждое сравнение == для этого класса означает «тот же экземпляр». Если модель уже определяет равенство по размерам или другому значению, используйте отдельную схему ключей, а не меняйте смысл равенства только ради одного словаря.

В качестве ключа словаря можно также использовать непосредственно ObjectIdentifier, но тогда по одному идентификатору не получить прямоугольник для последующих операций. Пока идентификатор используется для сравнения, сохраняйте соответствующий объект, удаляйте устаревшие записи. Ограничение по времени жизни, указанное в документации, делает этот механизм идентичности пригодным для использования в памяти в рамках определённого срока. См. ObjectIdentifier.

Используйте снимок значения, если запись представляет вычисление

Для повторного использования результатов вычисления площади обычно подходят сами размеры. У двух прямоугольников размером 4 на 3 может быть общий результат, даже если это разные объекты с разными метками. Изменение размеров должно выбирать другую запись.

Структуры Swift обладают семантикой значений, и компилятор может автоматически синтезировать Hashable, если их сохранённые свойства соответствуют этому протоколу. Поэтому снимок с неизменяемыми целыми числами — простой ключ для этого вычисления. См. Structures and Classes и Hashable.

Запустите этот полный альтернативный вариант отдельно командой swift SnapshotKeys.swift. В нём используются небольшие положительные целые числа, поэтому примеру не нужен универсальный интерфейс для ввода чисел.

final class EditableRectangle {
    var label: String
    var width: Int
    var height: Int

    init(label: String, width: Int, height: Int) {
        self.label = label
        self.width = width
        self.height = height
    }
}

struct AreaInputs: Hashable, Sendable {
    let width: Int
    let height: Int
}

func demonstrateSnapshotKeys() {
    let rectangle = EditableRectangle(
        label: "Tile A", width: 4, height: 3
    )
    var areas: [AreaInputs: Int] = [:]

    let original = AreaInputs(
        width: rectangle.width, height: rectangle.height
    )
    areas[original] = original.width * original.height

    rectangle.label = "Tile B"
    rectangle.width = 5

    let current = AreaInputs(
        width: rectangle.width, height: rectangle.height
    )
    assert(areas[original] == 12)
    assert(areas[current] == nil)

    areas[current] = current.width * current.height
    assert(areas[current] == 15)
    assert(areas.count == 2)

    print("Original area: \(areas[original]!)")
    print("Current area: \(areas[current]!)")
}

demonstrateSnapshotKeys()

Ожидаемый вывод: Original area: 12, затем Current area: 15. Оба результата остаются доступными, поскольку программа добавила два разных снимка. Старый результат по-прежнему правильно описывает прежние размеры.

Включайте в снимок все входные данные, влияющие на вычисление. Метка не важна для площади прямоугольника, поэтому здесь её нет. Если последующее вычисление зависит от другой настройки, добавьте её в ключ. Иначе разные вычисления могут ошибочно использовать одну запись. Это требование к проектированию вычислений, независимое от соответствия ключа протоколу Hashable.

Структура, которая лишь оборачивает ссылку на изменяемый класс, сама по себе не создаёт зафиксированный снимок этого объекта. Скопируйте нужные значения в снимок, как это сделано в примере. Также решите, когда удалять старые снимки, если повторные изменения накапливают результаты, которые больше не нужны.

Когда вместо этого использовать неизменяемый идентификатор?

Используйте идентификатор, если несколько экземпляров объектов должны обращаться к одной логической записи. Для этого условного инструмента может быть достаточно неизменяемого целочисленного идентификатора: храните результаты в [Int: Int], используйте let id: Int прямоугольника для доступа, а редактируемые метки и размеры оставьте вне ключа.

При таком подходе два объекта с идентификатором 7 намеренно обращаются к одной записи словаря. Правила создания объектов должны гарантировать, что разным логическим прямоугольникам случайно не назначат одинаковый идентификатор. Обновление существующего ключа заменяет связанное с ним значение, как описано в Dictionary.

Таблице площадей с идентификаторами всё равно нужна политика обновления после изменения размеров. Если нужны результаты для каждого набора входных данных, используйте снимки. Если нужен только последний результат для каждого логического прямоугольника, лучше подойдут идентификатор и явный пересчёт.

Решают ли проблему изменяемых ключей словари Foundation или акторы?

У Foundation другие правила добавления. NSMutableDictionary.setObject(_:forKey:) требует, чтобы ключ соответствовал NSCopying, и копирует его при добавлении. Копия ключа может зафиксировать снимок, если реализация копирования сохраняет стабильное состояние ключа. При последующих поисках ключ должен соответствовать сохранённой копии. См. описание Apple метода setObject(_:forKey:).

Не следует заключать из этого поведения, что нативный словарь Swift создаёт независимую копию экземпляра класса, используемого как ключ. Присваивание классов в Swift имеет семантику ссылок. Для собственных ключей Foundation проверяйте согласованность копирования, равенства и хеширования; требование Foundation к хешу по-прежнему действует.

Параллелизм — отдельный вопрос. Акторы координируют доступ к своему изолированному изменяемому состоянию, а Sendable описывает значения, которые могут пересекать домены конкурентного выполнения с соблюдением требований протокола. Ни то ни другое не меняет смысл равенства ключа и не исправляет ключ, определяющее состояние которого изменилось после добавления. См. главу Swift о Concurrency и документацию Apple по Sendable.

В примере со снимком явно указан Sendable, поскольку сохранённые целые числа удовлетворяют требованиям протокола к типам-значениям. Если позднее вы перенесёте вычисление площадей в актор, передавайте полный снимок и храните словарь результатов внутри актора. Изменяемый прямоугольник может остаться в текущей области изоляции. Для этих синхронных примеров актор не нужен.

Проверьте контракт, прежде чем полагаться на поиск

Для проверки выбранного решения используйте небольшую последовательность детерминированных действий:

  1. Добавьте запись и убедитесь, что первоначальный поиск её находит.
  2. Измените свойство, не участвующее в равенстве и хешировании; убедитесь, что запись по-прежнему доступна.
  3. Сравните псевдоним с отдельно созданным объектом или снимком; убедитесь, что результаты соответствуют предполагаемому смыслу равенства.
  4. Измените входные данные вычисления; убедитесь, что результат обновляется, удаляется или ищется по новому снимку.
  5. Проверьте количество элементов словаря, чтобы замена и добавление записей работали ожидаемым образом.

Если существующая реализация изменяет состояние, определяющее ключ, сначала исправьте модель ключа, а затем полагайтесь на поиск. Для этого небольшого локального вычисления понятнее заново построить результаты по отдельному списку искусственных входных данных, чем считать последующие операции со словарём способом исправления. Сохраните достаточно исходных данных для повторного вычисления, чтобы случайно не потерять запись, которую больше не удаётся найти.

Выбирайте идентичность для связи с одним существующим объектом, неизменяемый идентификатор для одного логического элемента и снимок значения для одного вычисления. Затем явно определите политику обновления результата. Вместе эти решения сохранят осмысленность поиска при изменении редактируемого прямоугольника.

Источники