SE-0309: Unlock existential types for all protocols

How about flipping the arguments of areEqual and doing areEqual(lhs, rhs) || areEqual(rhs, lhs). Ideally we could return nil if the casting fails in areEqual to avoid unnecessary equality checks.

1 Like

That's true but can be fixed quite easily. Since == is an equivalence relation it is symmetric, so we can definitely do either a == b or b == a to get the correct result.

I don't see a problem with something like this:

extension any Equatable: Equatable {
    public func ==(lhs: any Equatable, rhs: any Equatable) -> Bool {
        areEqual(lhs, rhs) || areEqual(rhs, lhs)
    }

    private func areEqual<T: Equatable>(_ a: T, _ b: any Equatable) -> Bool {
        if let b = b as? T {
            return a == b
        }
        return false
    }
}
1 Like

How would you avoid unnecessary equality checks?

EDIT:

Nvm, I didn't fully understand what you meant at first. You could do it like this:

extension any Equatable: Equatable {
    public func ==(lhs: any Equatable, rhs: any Equatable) -> Bool {
        if let equal = areEqual(lhs, rhs) {
            return equal
        }
        return areEqual(rhs, lhs) ?? false
    }

    private func areEqual<T: Equatable>(_ a: T, _ b: any Equatable) -> Bool? {
        if let b = b as? T {
            return a == b
        }
        return nil
    }
}
1 Like

That isn't a great solution, either - when we downcast sub to base, the implementation we use is Base.==, which only considers state that the base-class knows about. So base == sub can return true even if sub contains unique state that doesn't exist in base!

And what about if we now introduce a second value of the subclass, sub2? Now we can get results that

  • base == sub returns true
  • base == sub2 returns true
  • sub == sub2 returns false!
3 Likes

That is true, however this is unfortunately already the case if you are not using existentials:

class Foo: Equatable {
    let int = 5
    
    static func ==(lhs: Foo, rhs: Foo) -> Bool {
        lhs.isEqual(to: rhs)
    }
    
    func isEqual(to other: Foo) -> Bool {
        self.int == other.int
    }
}

class Bar: Foo {
    let string: String
    
    init(string: String) {
        self.string = string
    }
    
    override func isEqual(to other: Foo) -> Bool {
        guard let other = other as? Bar else {
            return false
        }
        
        return super.isEqual(to: other) && self.string == other.string
    }
}

let foo = Foo()
let bar1 = Bar(string: "1")
let bar2 = Bar(string: "2")

print(foo == bar1) // true
print(foo == bar2) // true
print(bar1 == bar2) // false

This is just an inherent flaw when using Equatable together with class inheritance. In this case == is not an equivalence relation anymore...


If we wanted, we could implement this differently for existentials, e.g. like so:

extension any Equatable: Equatable {
    public func ==(lhs: any Equatable, rhs: any Equatable) -> Bool {
        areEqual(lhs, rhs)
    }

    private func areEqual<T: Equatable, U: Equatable>(_ a: T, _ b: U) -> Bool? {
        if T.self == U.self {
            return a == (b as! T)
        }
        return false
    }
}

But this would be inconsistent with the behaviour that you get when not using existentials.

4 Likes

Yeah, my point is that generic cross-type equality is just an awkward operation to begin with. You can define your way out of some of the issues, but it's better to avoid them. Generally the solution ends up being "make sure these things have exactly identical types", so IMO it's better to prioritise efforts which express that directly using a model which preserves type identity.

3 Likes

Although I wouldn't mind a way to check all kinds of values for equality, I agree strongly when it's about ==: It can happen too easily that you compare two values which can never be equal; just imagine using two different numeric types:

let a: UInt = 0
let b = 0
a == b // :-(
2 Likes

The integer types define correct comparisons with integers of other bit widths. So your example not only compiles, but will produce in the correct result.

I think the real solution to this is to not conform non-final classes to Equatable, unless it's a closed class hierarchy that knows how to compare all of it's subclasses.

Which makes sense, because doing otherwise breaks the semantics of the protocol.