Improving Swift’s Equatable for Complex Class Comparisons

gguedes

Gustavo Guedes

Posted on September 20, 2024

Improving Swift’s Equatable for Complex Class Comparisons

A little context

Recently, I needed to use the Equatable protocol in some entities within my application, and I encountered an interesting aspect of it: the requirement to manually compare the properties of a class. So I thought: there must be a better way.

What does Equatable offer us?

Without diving too deep into its implementation, Equatable forces us to create a method to compare objects.

Imagine the following class:

class PersonEntity {
    let name: String
    let age: Int

    init(name: String, age: Int) {
        self.name = name
        self.age = age
    }
}
Enter fullscreen mode Exit fullscreen mode

If we try to compare two instances of PersonEntity, we will get an error like this:

Binary operator '==' cannot be applied to two 'PersonEntity' operands

By implementing the Equatable protocol, we can finally compare instances using the == operator:

class PersonEntity: Equatable {
    static func == (lhs: PersonEntity, rhs: PersonEntity) -> Bool {
        return lhs.name == rhs.name && rhs.age == rhs.age
    }
Enter fullscreen mode Exit fullscreen mode

Now the comparison is possible. But imagine a class with many properties, including other classes. Manually validating each property isn’t necessary in languages like Dart.

Inspiration

In Dart/Flutter, we have the Equatable package, which automates comparisons.

The code that handles this works as follows:

@override
bool operator ==(Object other) {
  return identical(this, other) ||
      other is Equatable &&
          runtimeType == other.runtimeType &&
          equals(props, other.props);
}
Enter fullscreen mode Exit fullscreen mode

To summarize:

  1. First, it checks if instances "A" and "B" are identical using the identical method (similar to AnyObject in Swift);
  2. If they are not identical, it checks if the other instance (or rhs in Swift/Equatable) is of type Equatable;
  3. Then, it compares the types of the instances using type(of:);
  4. Finally, it compares the properties of both instances.

But what are these properties? They’re what we will create on our side to simplify comparisons.

Let’s get to work

We will create an array of properties, where we will store all the props used for comparison. This eliminates the need for manual comparisons.

protocol CustomEquatable: Equatable {
    var props: [Any?] { get }
}
Enter fullscreen mode Exit fullscreen mode

I decided to extend Equatable to follow the language standard. Here's the implementation:

extension CustomEquatable {
    static func == (lhs: Self, rhs: Self) -> Bool {
        return lhs.isEqual(to: rhs)
    }

    func isEqual(to other: any CustomEquatable) -> Bool {
        return self.props.elementsEqual(other.props, by: { lhsElement, rhsElement in
                switch (lhsElement, rhsElement) {
                case let (lhsElement as any CustomEquatable, rhsElement as any CustomEquatable):
                        return lhsElement.isEqual(to: rhsElement)
                case let (lhsElement as AnyHashable, rhsElement as AnyHashable):
                        return lhsElement == rhsElement
                default:
                        return lhsElement == nil && rhsElement == nil
                }
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

In summary:

  1. We implement the Equatable protocol;
  2. We check each element in the props array;
  3. If the element is of type CustomEquatable, we use recursion to validate complex objects;
  4. For simple types, we compare using AnyHashable;
  5. And, of course, we handle null values.

Pretty cool, right? But does it work? Let’s test it!

Unit tests

Here are the classes we will use for testing:

import Foundation

class PersonEntity: CustomEquatable {
    var props: [Any?] {
        return [name, age, preferences, nickname, luckNumbers]
    }

    let name: String
    let age: Int
    let preferences: PersonPreferencesEntity?
    let nickname: String?
    let luckNumbers: [Int]

    init(name: String, age: Int, preferences: PersonPreferencesEntity? = nil, nickname: String? = nil, luckNumbers: [Int]? = nil) {
        self.name = name
        self.age = age
        self.preferences = preferences
        self.nickname = nickname
        self.luckNumbers = luckNumbers ?? []
    }
}

class PersonPreferencesEntity: CustomEquatable {
    var props: [Any?] {
        return [darkmode]
    }

    let darkmode: Bool

    init(darkmode: Bool = false) {
        self.darkmode = darkmode
    }
}

Enter fullscreen mode Exit fullscreen mode

The first validation is to check if the object we are comparing implements CustomEquatable. To do this, we instantiate PersonPreferencesEntity inside PersonEntity.

func testCompareCustomEquatableProps() {
    let equalPreferencesOne = PersonPreferencesEntity()
    let equalPreferencesTwo = PersonPreferencesEntity()

    let personOne = PersonEntity(name: "John Doe", age: 25, preferences: equalPreferencesOne)
    let personTwo = PersonEntity(name: "John Doe", age: 25, preferences: equalPreferencesTwo)

    XCTAssertTrue(personOne == personTwo)

    let diffPreferencesOne = PersonPreferencesEntity(darkmode: false)
    let diffPreferencesTwo = PersonPreferencesEntity(darkmode: true)

    let diffPersonOne = PersonEntity(name: "John Doe", age: 25, preferences: diffPreferencesOne)
    let diffPersonTwo = PersonEntity(name: "John Doe", age: 25, preferences: diffPreferencesTwo)

    XCTAssertFalse(diffPersonOne == diffPersonTwo)
}
Enter fullscreen mode Exit fullscreen mode

With this test, we ensure that complex properties are compared correctly.

The second test validates values that do not implement CustomEquatable.

func testCompareCommonProps() {
    let personOne = PersonEntity(name: "John Doe", age: 25, luckNumbers: [1, 2, 3])
    let personTwo = PersonEntity(name: "John Doe", age: 25, luckNumbers: [1, 2, 3])

    XCTAssertTrue(personOne == personTwo)

    let diffPersonOne = PersonEntity(name: "John Doe", age: 25, luckNumbers: [1, 2, 3])
    let diffPersonTwo = PersonEntity(name: "John Doe", age: 25, luckNumbers: [4, 5, 6])

    XCTAssertFalse(diffPersonOne == diffPersonTwo)
}
Enter fullscreen mode Exit fullscreen mode

Now, for nullable values:

func testCompareNullablesProps() {
    let diffPersonOne = PersonEntity(name: "John Doe", age: 25, nickname: "john")
    let diffPersonTwo = PersonEntity(name: "John Doe", age: 25)

    XCTAssertFalse(diffPersonOne == diffPersonTwo)
}
Enter fullscreen mode Exit fullscreen mode

Finally, a test to ensure that we are not comparing the object to itself.

func testCompareInstances() {
    let diffPersonOne = PersonEntity(name: "John Doe", age: 25)
    let diffPersonTwo = PersonEntity(name: "John Doe", age: 25)

    XCTAssertFalse(diffPersonOne === diffPersonTwo)
}
Enter fullscreen mode Exit fullscreen mode

With this last test, we validate that our protocol works as expected.

Conclusion

With this solution, we avoid having to manually create a chain of comparisons inside the == method. Since the original Equatable implementation required comparing each property manually, we maintain a time complexity of O(n).

Important: whenever you add a new property to your class, don’t forget to update the props array.

That’s it! I hope this was helpful. See you next time!

💖 💪 🙅 🚩
gguedes
Gustavo Guedes

Posted on September 20, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related