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

Определите, когда две плитки считаются одинаковыми

Предположим, вы создаёте небольшое графическое приложение для Mac. Вам нужно группировать синтетические описания плиток по названию, ширине и высоте. Задача читателя — реализовать это группирование согласованно: Orbit и ORBIT с одинаковыми размерами должны давать равные ключи; изменение любого размера должно давать другой ключ.

Перед реализацией обоих протоколов запишите условие. В этом примере два ключа равны, если совпадают названия без учёта регистра, ширина и высота. Размеры задаются положительными целыми числами в единицах рисования. Пробелы в начале и конце учитываются. Это правило для конкретного приложения, а не требование Swift.

Синтетические данные Ожидаемое отношение Причина
Orbit, 40 × 20 и ORBIT, 40 × 20 Равны Регистр букв не учитывается
Orbit, 40 × 20 и orbit, 41 × 20 Не равны Ширина участвует в сравнении на равенство
Orbit, 40 × 20 и orbit, 40 × 21 Не равны Высота участвует в сравнении на равенство
Orbit, 40 × 20 и orbit , 40 × 20 Не равны Конечный пробел сохраняется

Используйте отдельный ключ, когда правило группировки отличается от полного значения отображаемой плитки. Исходное написание названия храните в модели представления. Так вы не объявите взаимозаменяемыми два полных описания плиток, если их видимые названия различаются и входят в состав значения. В документации Swift для Equatable равенство описано через взаимозаменяемость; от него требуются рефлексивность, симметричность и транзитивность. Equatable

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

Протокол Swift Hashable наследуется от Equatable. Его основное требование: равные экземпляры должны передавать хешеру одинаковые значения в одинаковом порядке. Значения Hashable можно использовать в качестве элементов множеств и ключей словарей. Hashable

Распространённая ошибка — сравнивать нормализованные названия в ==, а хешировать исходное написание. Тогда в этих двух методах используются разные представления названия. Другая ошибка — исключить ширину из сравнения на равенство, но включить её в хеширование. В обоих случаях значения, объявленные равными, могут передавать хешеру разные входные данные. Apple прямо рекомендует использовать в обеих пользовательских реализациях одни и те же значимые свойства. Принятие общих протоколов

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

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

Запустите полный локальный пример на Mac

Вам понадобится установленный инструментарий Swift с Foundation. В Terminal проверьте его командой swift --version, как рекомендует документация Swift для начала работы. Если Swift недоступен, прежде чем продолжить, следуйте официальному руководству по установке для macOS. Начало работы, Установка Swift — macOS

Ниже приведена одна полная команда оболочки, содержащая программу на Swift. Вставьте весь блок в Terminal. Команда передаёт программу через стандартный ввод; пример выполняет только вычисления в памяти и выводит результаты. Код Swift был проверен, но запустить его в исследовательской среде не удалось, поскольку Swift там отсутствовал.

swift - <<'SWIFT'
import Foundation

struct TileKey: Hashable, Sendable {
    private struct Components: Hashable, Sendable {
        let foldedName: String
        let width: Int
        let height: Int
    }

    private let components: Components

    init(name: String, width: Int, height: Int) {
        precondition(width > 0 && height > 0,
                     "Tile dimensions must be positive")

        let foldedName = name.folding(
            options: [.caseInsensitive],
            locale: Locale(identifier: "en_US_POSIX")
        )
        components = Components(
            foldedName: foldedName,
            width: width,
            height: height
        )
    }

    static func == (lhs: TileKey, rhs: TileKey) -> Bool {
        lhs.components == rhs.components
    }

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

let a = TileKey(name: "Orbit", width: 40, height: 20)
let b = TileKey(name: "ORBIT", width: 40, height: 20)
let c = TileKey(name: "orbit", width: 40, height: 20)
let wider = TileKey(name: "orbit", width: 41, height: 20)
let taller = TileKey(name: "orbit", width: 40, height: 21)
let spaced = TileKey(name: "orbit ", width: 40, height: 20)
let samples = [a, b, c, wider, taller, spaced]

precondition(a == b && b == c && a == c)
precondition(a != wider && a != taller && a != spaced)

// Check the equality laws over this finite sample.
for first in samples {
    precondition(first == first, "Reflexivity failed")
    for second in samples {
        precondition((first == second) == (second == first),
                     "Symmetry failed")
        if first == second {
            precondition(first.hashValue == second.hashValue,
                         "Equal values have different hashes")
        }
        for third in samples where first == second && second == third {
            precondition(first == third, "Transitivity failed")
        }
    }
}

let uniqueKeys = Set(samples)
precondition(uniqueKeys.count == 4)
precondition(uniqueKeys.contains(b))

let patterns = [a: "striped"]
precondition(patterns[b] == "striped")

print("Unique keys:", uniqueKeys.count)
print("Equivalent key found:", uniqueKeys.contains(b))
print("Pattern:", patterns[b] ?? "missing")
print("Sample contract checks passed")
SWIFT

Ожидаемый вывод:

Уникальных ключей: 4
Найден равный ключ: true
Узор: striped
Проверки условий для примера пройдены

Шесть входных данных образуют четыре группы: три варианта написания с разным регистром имеют один ключ, а вариант с изменённой шириной, вариант с изменённой высотой и вариант с конечным пробелом образуют по отдельному ключу. Проверка словаря выясняет, можно ли получить значение, связанное с a, используя равный ключ b. Эти проверки подтверждают ожидаемое поведение коллекций и не зависят от конкретного числового значения хеша.

Инициализатор намеренно останавливает выполнение при неположительных размерах. Это уместно для данного фиксированного синтетического примера, где недопустимые размеры указывают на ошибку программирования. Если интерфейс рабочего стола допускает ввод неполных данных о размерах, проверьте их до создания ключа или измените инициализатор так, чтобы он возвращал optional или выбрасывал ошибку. Не подставляйте размер молча: это изменит условие равенства.

Явно задайте правило нормализации текста

Этап нормализации вызывает folding(options:locale:) из Foundation через API строк Swift. API возвращает строку с применёнными указанными параметрами свёртки. Здесь запрашивается только .caseInsensitive. StringProtocol.folding(options:locale:)

В документации Foundation свёртка регистра, ширины символов и диакритических знаков описана как набор отдельных параметров. Там также объясняется, что результат свёртки может зависеть от локали и что свёрнутый текст может не подходить для отображения. Поэтому в примере задаётся фиксированная локаль, а результат свёртки хранится только внутри ключа. Названия в примере намеренно простые. Выбор локали определяет поведение этого упражнения; это не универсальная рекомендация для любого интерфейса ввода названий. NSString.folding(options:locale:)

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

Прежде чем расширять нормализацию, добавьте примеры, которые позволяют различить нужные группы. Должны ли учитываться пробелы? Должны ли буквы с диакритическими знаками оставаться отдельными? Следует ли считать одинаковыми названия с визуально разной шириной символов? Определите это как правила продукта, а затем реализуйте одно преобразование, используемое обоими методами.

Выявите несоответствие, не полагаясь на числа хешей

При адаптации этого подхода к существующему типу действуйте по следующему плану:

  1. Запишите одну пару, которая должна сравниваться как равная, и по одной паре для каждого поля, которое должно различать значения.
  2. Проследите, какие именно значения сравнивает ==, включая нормализацию и производные свойства.
  3. Проследите значения, передаваемые в hasher.combine, включая их порядок.
  4. Перенесите общее определение в одно неизменяемое составное значение или явно используйте в обеих реализациях одни и те же компоненты.
  5. Проверьте законы равенства, хеши равных значений, принадлежность множеству и поиск в словаре на одних и тех же тестовых данных.

Не реализуйте равенство сравнением hashValue. Равные значения должны иметь равные хеши, но совпадение хешей не доказывает равенство. Основой для == остаются все компоненты равенства. Аналогично, тест, требующий, чтобы все неравные пары имели разные хеши, предъявляет требование, не гарантируемое контрактом хеширования. Hashable

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

Проверки на конечном наборе данных полезны для выявления регрессий, но не доказывают корректность для всех возможных строк и размеров. Важен и структурный аргумент: оба внешних метода делегируют работу одному неизменяемому значению Components, сохранённые поля которого полностью выражают выбранное условие.

Рассматривайте требования конкурентности отдельно от равенства

В примере обе структуры объявлены как Sendable, поскольку их сохранённые значения — строки, целые числа и другая передаваемая между доменами конкурентности структура. В документации Swift сказано, что передаваемые структуры должны содержать передаваемые сохранённые свойства. Sendable касается передачи значений между доменами конкурентности; он не определяет, какие значения сравниваются как равные. Sendable, Язык программирования Swift — конкурентность

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

При адаптации подхода есть и нюанс взаимодействия с Foundation: подкласс NSObject использует унаследованное поведение равенства и хеширования. Поддерживаемый Apple способ настройки — совместно переопределить isEqual(_:) и hash. В обычной структуре Swift из этого примера используются == и hash(into:). Принятие общих протоколов

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

Источники