Зачем типу уметь сравнивать себя и рассказывать о себе
Возьмите два билета в кино. Чтобы понять, одинаковые они или нет, вы сравниваете места и сеанс, а не смотрите, на одном ли они куске бумаги напечатаны. С кодом та же история: когда вы заводите свой struct или class, компилятор по умолчанию не знает, что для вас значит «одинаковые значения» — это нужно объяснить явно.
Для самых частых объяснений — «равны», «можно положить в набор», «можно отсортировать», «можно красиво напечатать» — в стандартной библиотеке Swift уже есть готовые протоколы. Присоединяетесь к ним — и получаете это поведение для своего типа, причём часть работы компилятор делает за вас сам.
Equatable: оператор ==
Протокол Equatable требует ровно одного: оператора == для двух значений своего типа. Реализовав его, вы разрешаете сравнивать значения через == и != (второй оператор Swift выводит из первого сам).
Хорошая новость: если ваш тип — struct или enum, и все его хранимые свойства сами соответствуют Equatable, компилятор способен написать == за вас — достаточно просто объявить соответствие протоколу, без единой строчки реализации.
struct Point: Equatable {
let x: Int
let y: Int
}
let a = Point(x: 1, y: 2)
let b = Point(x: 1, y: 2)
print(a == b)
Здесь не написано ни одного метода, но a == b компилируется и возвращает true: компилятор сам сравнил x с x и y с y, потому что оба поля — Int, а Int уже Equatable.
Если «одинаковые» для вас значит что-то своё — например, счита́ть письма одинаковыми независимо от регистра букв, — автогенерация не подойдёт, и == нужно написать руками как статический метод:
struct Email: Equatable {
let address: String
static func == (lhs: Email, rhs: Email) -> Bool {
return lhs.address.lowercased() == rhs.address.lowercased()
}
}
let e1 = Email(address: "Ann@mail.com")
let e2 = Email(address: "ann@mail.com")
print(e1 == e2)
Здесь напечатается true, хотя строки отличаются регистром: сравнение приводит обе стороны к нижнему регистру перед проверкой равенства.
Hashable: чтобы жить в Set и быть ключом словаря
Когда разбирали множества и словари, вы уже видели требование: элемент Set и ключ Dictionary обязаны быть Hashable. Этот протокол наследует Equatable и добавляет ровно одно новое требование — метод hash(into:), по которому значение можно быстро уложить в нужную «корзину» внутри набора.
Автогенерация здесь работает точно так же, как у Equatable: если все хранимые свойства сами Hashable, достаточно написать : Hashable в объявлении, и компилятор сгенерирует и hash(into:), и == сам.
struct Tag: Hashable {
let name: String
}
let tags: Set = [Tag(name: "swift"), Tag(name: "swift")]
print(tags.count)
Несмотря на то что Tag создан дважды с одинаковым именем, в множестве оказывается один элемент: Set использует Hashable, чтобы понять, что второе значение — дубликат первого, и не хранить его повторно.
Comparable: чтобы типы можно было сортировать
Протокол Comparable наследует Equatable и добавляет требование реализовать оператор <. В отличие от Equatable и Hashable, компилятор не умеет сам придумывать порядок для вашего типа — «что меньше, а что больше» решаете только вы, поэтому < для собственных структур и классов всегда пишется руками.
struct Version: Comparable {
let major: Int
let minor: Int
static func < (lhs: Version, rhs: Version) -> Bool {
if lhs.major != rhs.major {
return lhs.major < rhs.major
}
return lhs.minor < rhs.minor
}
}
let versions = [Version(major: 2, minor: 1), Version(major: 1, minor: 9)]
let oldest = versions.sorted().first!
print(oldest.major, oldest.minor)
Реализован только оператор <: сначала сравниваются старшие номера версий, а при равенстве — младшие. Но код при этом спокойно вызывает sorted() без всяких аргументов и мог бы использовать <=, > и >= — все они достаются бесплатно: стандартная библиотека сама выводит их из вашего < и унаследованного ==.
CustomStringConvertible: как значение выглядит в print
Без единой настройки print у структуры и так что-то покажет — Swift умеет заглянуть внутрь значения и вывести имена и содержимое полей. Но для типа со сложной внутренней структурой такой авто-вывод редко читается как что-то осмысленное для пользователя программы.
Протокол CustomStringConvertible требует вычисляемое свойство description типа String. Как только тип ему соответствует, именно это свойство использует и print, и обычная интерполяция строки \(значение).
struct Money: CustomStringConvertible {
let amountCents: Int
var description: String {
let cents = amountCents % 100
let centsText = cents < 10 ? "0\(cents)" : "\(cents)"
return "\(amountCents / 100).\(centsText) руб."
}
}
let price = Money(amountCents: 1250)
print(price)
Результат — 12.50 руб., а не служебный список полей: print автоматически обратился к description, потому что Money объявила соответствие CustomStringConvertible.
Частые ошибки
- Ждать, что автогенерация сработает, даже если среди хранимых свойств есть что-то не
Equatableили неHashable(например, замыкание) — в этом случае компилятор откажется выводить==илиhash(into:)сам, и их придётся написать руками. - Считать, что
Comparableработает так же, какEquatableиHashable, и рассчитывать на автогенерацию<— компилятор его никогда не выводит сам, порядок значений всегда объясняете вы. - Реализовать
<непоследовательно — например, забыть учесть второе поле при равенстве первого. Тогдаsorted()и сравнения дадут неожиданный порядок, а найти причину будет непросто. - Написать метод
==илиdescription, но забыть указать сам протокол в объявлении типа (: Equatable,: CustomStringConvertible). Без явного соответствия тип нельзя положить вSetили передать туда, где протокол требуется по имени. - Путать
descriptionс методом наподобиеdescription()— это вычисляемое свойство без круглых скобок, обращаться к нему нужно как к обычному свойству.
Резюме
Equatableдаёт оператор==; для структур и перечислений с полями-Equatableкомпилятор способен сгенерировать его сам.HashableнаследуетEquatableи добавляетhash(into:)— без него тип нельзя положить вSetили использовать как ключ словаря; тоже умеет автогенерироваться.ComparableнаследуетEquatableи требует реализовать<вручную — автогенерации здесь нет, зато<=,>,>=иsorted()достаются бесплатно.CustomStringConvertibleдаёт свойствоdescription, которое используетprintи интерполяция строк — так можно управлять тем, что видит пользователь программы.- Все четыре протокола просто описывают контракт: что должен уметь тип. Реализацию для нестандартных случаев вы всегда пишете сами.
Проверьте себя
4 вопроса
Деньги с нечувствительной к регистру валютой
Дан тип Money с полями amountCents: Int (сумма в центах) и currency: String (код валюты, например "usd" или "USD").
Реализуйте для Money протокол Equatable так, чтобы два значения считались равными, если у них совпадает amountCents и совпадает currency без учёта регистра букв.
Реализуйте для Money протокол CustomStringConvertible: свойство description должно возвращать строку вида "12.50 USD" — сумма в виде рублей и копеек через точку (копейки всегда двумя цифрами) и код валюты в верхнем регистре.
Затем реализуйте функцию cheaper(_ a: Money, _ b: Money) -> Money, которая возвращает то из двух значений, у которого amountCents меньше (если суммы равны — верните первый аргумент a).
Рейтинг игроков
Дан тип Player с полями name: String и score: Int.
Сделайте Player соответствующим Hashable (у обоих полей есть автоматический вывод, дополнительный код не нужен — просто укажите протокол) и Comparable, реализовав оператор < так, чтобы игрок с меньшим score считался «меньше».
Реализуйте функцию rankings(_ players: [Player]) -> [String], которая возвращает имена игроков, отсортированные от наибольшего счёта к наименьшему (используйте то, что даёт соответствие Comparable).
Реализуйте функцию uniquePlayers(_ players: [Player]) -> Int, которая возвращает количество различных игроков во входном массиве, считая двух игроков одинаковыми, если у них совпадают и name, и score (используйте Set).