$ sudo teach IT

Зачем типу уметь сравнивать себя и рассказывать о себе

Возьмите два билета в кино. Чтобы понять, одинаковые они или нет, вы сравниваете места и сеанс, а не смотрите, на одном ли они куске бумаги напечатаны. С кодом та же история: когда вы заводите свой 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).