Сумна історія про Swift Equatable

Це історія про підступний Equatable в Swift.

Почнемо як в рекламі. Все круто, все працює:

class Person: Equatable {
    let name: String

    init(name: String) {
        self.name = name
    }

    static func == (lhs: Person, rhs: Person) -> Bool {
        Swift.print(">> Person operator == called")
        return lhs.name == rhs.name
    }

    static func != (lhs: Person, rhs: Person) -> Bool {
        // Зазвичай я не реалізую цей оператор – компілятор згенерує його сам.
        // Я просто хочу додати трохи логів
        // Навіть якби мені довелось реалізувати !=, я б реалізував його через ==.
        // Заради логування нехай буде дійсно тупий
        //
        Swift.print(">> Person operator != called")
        return lhs.name != rhs.name
    }
}

let john = Person(name: "John")
let jack = Person(name: "Jack")

Перевіряємо. Все правильно. Перейдемо до реального життя.

john == jack // false
john != jack // true

Додамо трошки ООП

class Worker: Person {
    let job: String

    init(name: String, job: String) {
        self.job = job
        super.init(name: name)
    }

    static func == (lhs: Worker, rhs: Worker) -> Bool {
        Swift.print(">> Worker operator == called")

        return lhs.name == rhs.name &&
               lhs.job == rhs.job
    }

    // не буду реалізовувати !=. Нехай компілятор зробить свою роботу.
}

let johnTheProgramer = Worker(name: "John", job: "iOS Developer")
let johnTheGardener = Worker(name: "John", job: "Gardener")

johnTheProgramer == johnTheGardener // звісно false
johnTheProgramer != johnTheGardener // барабанна дріб – false теж!

Невеликі проблеми. Викликався != базового класу.

З одного боку, все правильно і логічно – є протокол, і треба реалізувати обидва методи, щоб воно працювало.

З іншого боку, стандартна бібліотека містить дефолтну реалізацію !=, і це присипляє пильність розробника. Навіть Xcode додає заглушку тільки для ==. Тобто все спонукає до факапу.

Це легко виправити, додавши != до Worker. Але це не кінець історії.

NSObject вибігає на сцену.

// Equatable успадкований від NSObject тут
class Car: NSObject {
    let brand: String

    init(brand: String) {
        self.brand = brand
        super.init()
    }

    static func ==(lhs: Car, rhs: Car) -> Bool {
        return lhs.brand == rhs.brand
    }
}

let toyota = Car(brand: "Toyota")
let toyota2 = Car(brand: "Toyota")

toyota == toyota2 // true
toyota != toyota2 // 💩 true теж

Насправді, нічого нового, все та ж сама проблема що і раніше. Просто тепер наш базовий клас це NSObject, і викликається його реалізація !=, а вона просто порівнює адреси вказівників:

toyota != toyota  // false – той самий вказівник

А тепер найцікавіше.

Одна з фіч == про яку нам розповідають це пошук у структурах даних, наприклад, в масивах.

Спробуємо:

class Phone: Equatable {
    let brand: String

    init(brand: String) {
        self.brand = brand
    }

    static func == (lhs: Phone, rhs: Phone) -> Bool {
        return lhs.brand == rhs.brand
    }
}

// для iPhone я навіть почну назву класу з маленької літери
//
class iPhone: Phone {
    let model: String

    init(model: String) {
        self.model = model
        super.init(brand: "iPhone")
    }

    static func == (lhs: iPhone, rhs: iPhone) -> Bool {
        return  lhs.brand == rhs.brand &&
                lhs.model == rhs.model
    }
}

let iPhoneX = iPhone(model: "X")

let phones = [ iPhone(model: "3G"), iPhone(model: "4"), iPhoneX, iPhone(model: "6") ]

// пошук ніби працює...
phones.contains(iPhoneX) // true

// або ні?...
phones.firstIndex(of: iPhoneX) // 💩 – 0

// можемо також подивитись як filter "працює":

let oldModels = phones.filter { $0 != iPhoneX }
oldModels.count // 0 (нуль)

// реалізація == базового класу викликається у всіх цих тестах :)

Як це виправити?

Я пропоную додати метод для порівняння об’єктів у базовому класі. Потім реалізувати == в базовому класі через виклик цього методу.

У випадку успадкування від NSObject це буде isEqual.

Загалом, можна назвати як завгодно. isEqual підійде.

Це дозволить уникнути наступних проблем:

  • не треба ламати голову як викликати super
  • не треба морочитись з реалізацією обох == ТА != у вашому класі
  • пошук у колекціях працюватиме як очікується

Наприклад:

class Animal: NSObject {
    let type: String

    init(type: String) {
        self.type = type
        super.init()
    }

    override func isEqual(_ object: Any?) -> Bool {
        guard let other = object as? Animal else {
            return false
        }

        return self.type == other.type
    }
}

let lizard = Animal(type: "lizard")
let snail = Animal(type: "snail")

assert(lizard == lizard)
assert(false == (lizard != lizard))
assert(false == (lizard == snail))
assert(lizard != snail)

class Cat: Animal {
    let name: String

    init(name: String) {
        self.name = name
        super.init(type: "cat")
    }

    override func isEqual(_ object: Any?) -> Bool {
        if false == super.isEqual(object) {
            return false
        }

        guard let other = object as? Cat else {
            return false
        }

        return self.name == other.name
    }
}

let catTom = Cat(name: "Tom")
assert(catTom == catTom)
assert(false == (catTom != catTom))

let animals = [ Animal(type: "dog"), catTom, Animal(type: "bird") ]

assert(animals.contains(catTom))
assert(1 == animals.firstIndex(of: catTom))

Ми не перші на Землі хто стикається з цією проблемою:
https://lists.swift.org/pipermail/swift-evolution/Week-of-Mon-20170116/030562.html
https://bugs.swift.org/browse/SR-1729
https://bugs.swift.org/browse/SR-1218
https://stackoverflow.com/questions/28793218/swift-overriding-in-subclass-results-invocation-of-in-superclass-only
Останнє містить наукові підхід до вирішення цієї проблеми.

Загалом, у більшості реальних ситуацій підхід з isEqual працюватиме добре.

Замість висновку. Та сама проблема існує в C++. Кожен, хто коли-небудь намагався зробити operator == віртуальним, страждав жахливо. Минуло 30 років, у нас новенька блискуча мова програмування… і ті самі старі проблеми. isEqual все ще рулить.