Swift: Optional Equality and Objective C Interoperability

tl;dr

An extension on the Optional type allows us to simplify NSObject equality checks that have the additional complication of supporting optionals.

-----

Objective C provides a standard way to define equality for objects descended from NSObject. When using Swift, especially in mixed Objective C and Swift code bases, it is still necessary to use these standard methods to check the equality of NSObject descendants or to use those objects in Objective C collections. Unfortunately, optionals in Swift throw an additional wrench into equality checks that can lead to repetitive, ugly, hard to read code.

Consider the code for a Search class below. It overrides the NSObjectProtocol methods of isEqual and hash for Objective C compatibility. When one Search object is compared against another with isEqual, there are four cases to consider for each optional property. Using the property keyword from the code as an example:

case 1case 2case 3case 4
"this" keywordnilnilnot nilnot nil
"other" keywordnilnot nilnilnot nil
isEqual outcometruefalsefalsetrue if unwrapped values are equal

The code to check these four cases can be simplified by assuming the properties are not equal until proven otherwise. So, the code can check for equality when both properties have non nil values (case 4 above) and it can check if both values are nil (case 1 above). If neither of those cases applies, then one of the values is nil and the other is not. We don't have to check for those cases because the outcome matches the assumed default outcome.

class Search: NSObject {
    var keyword: NSString?
    var location: NSString?
	
    override func isEqual(_ object: Any?) -> Bool {
        // Object must be of the same class as self
        guard let other = object as? Search else {
            return false
        }

        // No need to check further if other is the same object as self
        if self === other {
            return true
        }
        
        // Check keyword equality
        var equalKeywords = false
        if let thisKeyword = keyword, let otherKeyword = other.keyword {
            equalKeywords = thisKeyword.isEqual(otherKeyword)
        } else if keyword == nil, other.keyword == nil {
            equalKeywords = true
        }
        
        // Check location equality
        var equalLocations = false
        if let thisLocation = location, let otherLocation = other.location {
            equalLocations = thisLocation.isEqual(otherLocation)
        } else if location == nil, other.location == nil {
            equalLocations = true
        }

        return equalKeywords && equalLocations
    }

    override var hash: Int {
        get {
            // Hash of zero has no effect in an XORed set of values, so it's a good default value
            let keywordHash = keyword?.hash ?? 0
            let locationHash = location?.hash ?? 0
            
            return keywordHash ^ locationHash
        }
    }
}

The equality checks shown above are the product of repeated iteration and simplification. I've had much uglier iterations. In this form it is fairly clear, but there is a lot of boiler plate to perform what is conceptually an equality check (like an "==" operation). Since the complexity is caused by the use of optionals, I turned to an extension on the Optional type to simplify further.

extension Optional where Wrapped: NSObjectProtocol {
    // Compare two Optionals
    func isEqual<T: NSObjectProtocol>(_ other: T?) -> Bool {
        switch (self, other) {
        case (.some(let selfValue), .some(let otherValue)):
            return selfValue.isEqual(otherValue)
        case (.none, .none):
            return true
        case (.some, .none): fallthrough
        case (.none, .some):
            return false
        }
    }

    // Compare an Optional and a non-Optional
    func isEqual<T: NSObjectProtocol>(_ other: T) -> Bool {
        switch self {
        case .some(let value):
            return value.isEqual(other)
        case .none:
            return false
        }
    }

    // Convenient unwrap of Optionals to get their hash value
    var hash: Int {
        get {
            switch self {
            case .some(let value):
                return value.hash
            case .none:
                return 0
            }
        }
    }
}

Limiting the extension to Optionals that wrap values adhering to the NSObjectProtocol makes sure we are only targeting objects that already define an isEqual function of their own (or at the very least, depend on the NSObject implementation of that function). There are two versions of the isEqual function in the extension. The first allows comparison of two optionals. The second allows comparison of an optional and a non-optional. The isEqual function provided by NSObject already handles the case of comparing a non-optional and an optional. So, now we can use isEqual with any combination of optionals and non-optionals (for NSObjects only of course).

The other part of the equality story is the hash function. We can simplify the generation of hash values with this extension function that auto unwraps an optional and gets its value's hash.

Applying the extension functions to the Search class, the code is greatly simplified as shown below:

class Search: NSObject {
    var keyword: NSString?
    var location: NSString?

    override func isEqual(_ object: Any?) -> Bool {
        // Object must be of the same class as self
        guard let other = object as? Search else {
            return false
        }

        // No need to check further if other is the same object as self
        if self === other {
            return true
        }

        return keyword.isEqual(other.keyword) && location.isEqual(other.location)
    }

    override var hash: Int {
        get {
            return keyword.hash ^ location.hash
        }
    }
}