Equatable Pitfalls in iOS

Mark Newton
4 min readMar 9, 2017

Conformance to the Equatable protocol seems pretty straightforward. You simply override the == function. No need to define what happens when using the != operator, we get this for free with the below protocol conformance.

extension Person: Equatable {
static func ==(lhs: Person, rhs: Person) -> Bool {
return lhs.id == rhs.id
}
}

This works great for objects like structs, or classes with no superclass. However, we can run into problems with the == function if we’re dealing with NSObject subclasses.

In a nutshell, you’ll find that its wise to leave == alone and deal only with isEqual when working with NSObject subclasses. Let’s discuss why. Suppose we have an NSObject subclass like this:

class Person: NSObject {
let id: Int
var name: String?
init(id: Int, name: String?) {
self.id = id
self.name = name
}
}

First of all, NSObject already conforms to Equatable. So we can go ahead and compare two Person objects using the == operator.

let a = Person(id: 1, name: "Tom")
let b = Person(id: 2, name: "Bob")
print(a == b) // false
print(a != b) // true

NSObject’s default implementation for the == function simply calls isEqual. It probably looks like this:

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

isEqual checks for pointer equality. Consequently, so does NSObject's default implementation for ===. This means that, out of the box, ==, ===, and isEqual are exactly the same.

Calling == invokes isEqual.
Calling === does not invoke isEqual, but the implementations are identical.

let a = Person(id: 1, name: "Tom")
let b = a
print (a == b) // true; this returns result from isEqual
print (a === b) // true; this does not call isEqual, but has identical implementation

If you need further proof, check out Apple’s own docs (under the ‘Object Comparison’ section).

Here’s the thing you need to be careful about. Let’s say you override ==. Here we consider two Person objects equal if they have the same id.

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

Now we add a couple Person objects to a Set.

let a = Person(id: 1, name: "Tom")
let b = Person(id: 1, name: "Tommy")
var s1 = Set<Person>()
s1.insert(a)
s1.insert(b)
print(s1.count) // 2; doh!

Well, that’s a bummer. Sets are supposed to contain unique objects, and here the Set is storing two objects that we consider equal. The problem here is that we didn’t follow a basic rule of equality.

If objects are equal, then their hash values must also be equal
(a.isEqual(b)]a.hash == b.hash)

Nobody is going to force us to follow this rule; we have to be vigilant about it on our own. To fix this issue, we have to understand a little more about how a Set considers two objects equal (the same logic applies for evaluating if two keys are equal in a Dictionary).

A Set first looks at the object’s hashValue property. If two objects have different hashValues, they are considered NOT equal. If the hashValues are the same, the Set does a second verification by calling isEqual. Only if this second check returns true does it considers the objects equal.

This second equality check is required because its possible, although rare, for two different objects to have the same hash (i.e. hash collisions).

This second check does NOT call ==. It calls isEqual. So if you have only implemented ==, you are probably not going to get the result you want when your object is being hashed.

We can fix the Person class by overriding hashValue, and by using isEqual. Here’s the full implementation.

class Person: NSObject {
let id: Int
var name: String?
init(id: Int, name: String?) {
self.id = id
self.name = name
}

override var hashValue: Int {
return self.id.hash
}

override func isEqual(_ object: Any?) -> Bool {
guard let otherPerson = object as? Person else {
return false
}
return self.id == otherPerson.id
}
}

Note: You can override either the hashValue or hash property of NSObject. A Swift Set will ask hashValue for its value, but the default implementation of hashValue simply invokes hash under the hood.

Takeaways:

1. When subclassing NSObject, do not modify ==. Use isEqual instead.
2. When subclassing NSObject, always override hashValue if you override isEqual
3. Don’t subclass from NSObject if you don’t have to. isEqual is a tricky devil. Avoiding NSObject means you deal only with == and hashValue. And it means you have to explicitly conform to the Equatable and Hashable protocols yourself (i.e. there no default implementations that can mess you up).

There is less room for error if you don’t have to deal with a superclass that already implemented Equatable and Hashable (like NSObject). Take the following struct for example.

struct Person {
let id: Int
var name: String?
init(id: Int, name: String?) {
self.id = id
self.name = name
}
}
extension Person: Equatable {
static func ==(lhs: Person, rhs: Person) -> Bool {
return lhs.id == rhs.id
}
}

You’ll run into zero problems with this implementation. If you try to put this Person object into a Set, it will give you a compiler error because you haven’t implemented Hashable. Unlike NSObject subclasses, this will warn you that you need to define your own hashValue, because it hasn’t already been defined in a superclass.

struct Person {
let id: Int
var name: String?
init(id: Int, name: String?) {
self.id = id
self.name = name
}
}
extension Person: Hashable {
static func ==(lhs: Person, rhs: Person) -> Bool {
return lhs.id == rhs.id
}

override var hashValue: Int {
return self.id.hash
}
}

Hashable conforms to the Equatable protocol, so you only need to write that we’re extending Hashable here. The compiler will ensure we conform to both. And there we are.

--

--