Caveats while using Equatable with NSObject

Today I am going to talk about how to use Equatable with Swift and NSObject inherited objects. This is interesting because last week I came to know about the fact that Swift's inbuilt Equatable protocol does not play nice with objects inherited from NSObject

Equality with Swift objects

Let's look at the simple case first. Suppose you have a struct (Or class) that you want to make automatically comparable. It is possible for that specific construct to conform to Equatable protocol and implement the required method.

Let's begin by creating a struct we want to test equality for

struct Address {
    let streetName: String
    let zipCode: Int
}

And say, we have two objects which we want to compare like following,

let address1 = Address(streetName: "asd", zipCode: 1293)
let address2 = Address(streetName: "asd1", zipCode: 12931)

Now, if we try to check equality using address1 == address2, this being a construct not conforming to Equatable will throw a compiler error. The solution is to conform to Equatable protocol and implement a required method as follows,

extension Address: Equatable {
    static func ==(lhs: Address, rhs: Address) -> Bool {
        return lhs.streetName == rhs.streetName && lhs.zipCode == lhs.zipCode
    }
}

Now we successfully made the two address objects equatable so that we can compare them directly using comparison operator and finally we're done with equality part dealing with direct Swift objects.

let address1 = Address(streetName: "asd", zipCode: 1293)
let address2 = Address(streetName: "asd1", zipCode: 12931)
print(address1 == address2) // Prints false
print(address1 != address2) // Prints true

let address3 = Address(streetName: "something", zipCode: 4455)
let address4 = Address(streetName: "something", zipCode: 4455)
print(address3 == address4) // Prints true
print(address3 != address4) // Prints false

Equality with objects inherited from NSObject

This is fine, but how do we compare objects inherited from NSObject for equality? This case is a little bit different from the one that we just saw. If you try the above code with objects inheriting from NSObject, you will not get the correct result,

Let's modify the above code a little bit since swift struct cannot inherit directly from NSObject

@objc class Address: NSObject {
    let streetName: String
    let zipCode: Int
    init(streetName: String, zipCode: Int) {
        self.streetName = streetName
        self.zipCode = zipCode
    }
}

extension Address {
    static func ==(lhs: Address, rhs: Address) -> Bool {
        return lhs.streetName == rhs.streetName && lhs.zipCode == lhs.zipCode
    }
}

And now if you try to do following comparison, they will either won't work, will be buggy or fail to compile

let address1 = Address(streetName: "asd", zipCode: 1293)
let address2 = Address(streetName: "asd1", zipCode: 12931)
print(address1 == address2) // Prints false
print(address1 != address2) // Prints true

Additionally, if you put a breakpoint inside operator overloaded method, you will see it never gets called for the second comparison (Nor will it be called for contains check to verify existance of the element inside an array). This is because in this case our Address object inherits from NSObject and we will need a different strategy.

To solve this bug, first, get rid of code for operator overloading in the extension and add another NSObject method called isEqual to perform the actual comparison.

NSObject has a method named isEqual(object:) that is used for comparing different objects. The default implementation just compares instance pointers, but subclasses can override this method to do custom comparisons

extension Address {
    override func isEqual(_ object: Any?) -> Bool {
        if let object = object as? Address {
            return self.streetName == object.streetName && self.zipCode == object.zipCode
        }
        return false
    }
}

Now this modified code will allow you to do following comparson and if you note, the breakpoint inside the method isEqual will also be eventually called,

let address1 = Address(streetName: "asd", zipCode: 1293)
let address2 = Address(streetName: "asd", zipCode: 1293)

let array1 = [Address(streetName: "asd", zipCode: 1293)]
let array2 = [Address(streetName: "asd", zipCode: 129332)]

let result = address1 != address2          // false
let result1 = address1 == address2         // true

let result2 = array1.contains(address1)    // true    
let result3 = array2.contains(address1)    // false

As we have seen this system now works as expected when we want to make a comparison with the construct that was directly inherited from NSObject.

To conclude, I would like to express thought about how confusing it is to work with equality operator sometimes. I wouldn't even notice the special case with objects inheriting from NSObject if I wouldn't be using != operator which would've made it even more difficult to catch this bug. Hopefully, this post will help someone going through the same situation. Remember, any time in doubt put a breakpoint in the protocol method (isEqual/operator overloaded) to make sure that equality operator is using the desired logic to check for equality comparison.

References:
Use your loaf - Swift Equatable and Comparable