Equatable Pitfalls in iOS
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 = aprint (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 callsisEqual
. 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
==
. UseisEqual
instead.
2. When subclassing NSObject, always overridehashValue
if you overrideisEqual
3. Don’t subclass from NSObject if you don’t have to.isEqual
is a tricky devil. Avoiding NSObject means you deal only with==
andhashValue
. 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.