Why can't existential types be compared?

Imagine the following:

let a : any StringProtocol = "d"
let b : any StringProtocol = "das"

a == b // Error: Binary operator '==' cannot be applied to operands of type 'any Swift.StringProtocol' and 'any Swift.StringProtocol'

Why can't existential types be compared? Does Swift not do dynamic dispatch in this case?

== is declared as:

static func == (lhs: Self, rhs: Self) -> Bool

Which you can think of as:

static func == <T>(lhs: T, rhs: T) -> Bool where T == Self

That is to say, both the left and right hand sides have to have the same type. Just as how you can’t compare an Int to a String, you can’t compare a String and a Substring, even if they both happen to have a protocol or two in common.

5 Likes

Although you can't do it directly with ==, Swift 5.7 does at least make it somewhat easy to accomplish:

public func equals(_ lhs: Any, _ rhs: Any) -> Bool {
  func open<A: Equatable>(_ lhs: A, _ rhs: Any) -> Bool {
    lhs == (rhs as? A)
  }

  guard let lhs = lhs as? any Equatable
  else { return false }

  return open(lhs, rhs)
}

This will allow you to check equality of any two types, and it will just return false if the types don't match or if they are not equatable:

equals(1, 1) // true
equals(1, 2) // false

equals((), ()) // false
equals(1, "Hello") // false
12 Likes

The behavior you describe is different than what AnyHashable provides.

Given these types:

class Base: Hashable {
  var id: Int
  
  init(id: Int) {
    self.id = id
  }
  
  static func == (lhs: Base, rhs: Base) -> Bool {
    return lhs.id == rhs.id
  }
  
  final func hash(into hasher: inout Hasher) {
    hasher.combine(id)
  }
}

class Sub1: Base {}
class Sub2: Base {}

We have:

let b: AnyHashable = Sub1(id: 1)
let c: AnyHashable = Sub2(id: 1)

print(b == c)   // true

But if my understanding is correct, with your function we would have:

let b2: Any = Sub1(id: 1)
let c2: Any = Sub2(id: 1)

print(equals(b2, c2))   // false

Because the generic parameter A will be Sub1, and the cast inside open will fail because Sub2 is not convertible to Sub1.

Yes, opening existentials of subclasses is more nuanced and tricky.

With subclasses you have to ask yourself if you want to allow traveling up the class hierarchy to find a base class where they are equal. I think in certain circumstances that can be reasonable, but also sometimes it may be surprising.

You could check if both values are castable as? AnyHashable and compare the results for equality if the casts succeed. If you really care about types that are Equatable but not Hashable, then you could do the opening sequence you laid out as a backup if the AnyHashable casts fail, since all of the types that currently have special equality handling are Hashable.

3 Likes

Oh, yeah that's probably easier than what I wrote :sweat_smile:

public func equals(_ lhs: Any, _ rhs: Any) -> Bool {
  func open<A: Equatable>(_ lhs: A, _ rhs: Any) -> Bool {
    lhs == (rhs as? A)
  }

  func openSuperclass<A: Equatable, B: AnyObject & Equatable>(
    _ lhs: A,
    _ rhs: Any,
    _ superclass: B.Type
  ) -> Bool {
      (lhs as? B) == (rhs as? B)
  }

  guard let lhs = lhs as? any Equatable else {
    return false
  }

  var superclassStack: [AnyClass] = []
  var lhsType: Any.Type = type(of: lhs)

  while let superclass = _getSuperclass(lhsType) {
    superclassStack.append(superclass)
    lhsType = superclass
  }

  for superclass in superclassStack {
    guard let superclass = superclass as? any ((AnyObject & Equatable).Type) else {
      break
    }

    guard openSuperclass(lhs, rhs, superclass) else {
      continue
    }

    return true
  }

  return open(lhs, rhs)
}

What harm would it be... if that was possible (and returned "false").

The harm is that it hides the category error of attempting to compare two things which are not logically comparable. Comparing an Int and a String is not a meaningful operation—they cannot possibly be equal—but a false return value is not sufficient to indicate that this sort of error was made. More so, the false result can only be returned at runtime, when the mistake was made by the developer at coding time, statically.

Where possible, Swift attempts to make this mistake impossible to represent in the first place, by leveraging features like generics on such operations to make it difficult to get wrong. (Yes, you can still cast to AnyHashable and attempt the comparison if you want, but you're going to need to put in effort to do so; just as you can write an overload for == which takes an Int and a String.)

12 Likes

Can you expand on this.. If such comparison was possible I would expect it to give a compile time warning (or error):

var s: String = ...
var i: Int = ...
...
if s == i { // warning: always "false" expression
    print("world") // warning: code is unreachable
}

The issue is that you're moving the failure mode to the point at checking the result of the operation, and not the operation itself. For example, the compiler easily warns when an if-condition is the literal false, but not when the value is in a variable:

// Inline
if false {
    print("Whoops") // warning: will never be executed
}

// Local
let b = false
if b {
    print("Whoops")
}

You could say "well, the compiler knows that b is false so shouldn't it warn there too?" Maybe, but this is a quickly-losing war — as soon as you go one level up and move the condition into a function, we've already lost:

// External
func f(_ b: Bool) {
    // The compiler _can't_ warn here -- this is a totally valid function which could
    // also accept `true`.
    if b {
        print("Whoops")
    }
}

// The compiler _can't_ warn here either -- this is a totally valid function call.
f(false)

Or just as bad — have b be defined conditionally:

let s1: String = "Hi"
let s2: String = "Hey"
let i: Int = 42

// Local, conditional
let b: Bool
if Int.random(in: 0 ..< 1_000_000) > 0 {
    b = s1 == s2
} else { 
    b = s1 == i // 0.0001% chance of happening
}

// Should the compiler warn?
if b {
    print("???")
}

The point is, these are constructions are cases where the compiler needs to figure out how to deal with the result of the comparison, which is chasing after the symptom; the underlying cause is the attempt to compare s1 == i, which doesn't make sense.

Swift defines this whole mess away by preventing you from trying s1 == i in the first place.

2 Likes

I'm with you.. Just note that we already have some precedents in swift:

var string: String? = ...
string == "Hello" // false. but why?

var dictionary: [String: Any] = ...
dictionary["intKey"] as? Int // nil. but why?

where you have some result of an operation, but have no clue what that result means without a further (or prior) investigation. The last example is actually a source of hidden bugs (the code that got the type will not realise the mistake and consider the case of absent value. For that reason sometimes I prefer a more verbose (and ugly) but more correct: dictionary["intKey"] as! Int?

Expanding on my pseudo example further:

protocol P: Equatable2 {}
extension Int: P {}
extension String: P {}

var s: String = ...
var i: Int = ...
var i2 = Int = ...
s == i // false
i1 == i2 // false
// a "further investigation":
s.type == i.type // false
i1.type == i2.type // true

This is calling Optional’s implementation of == which compares two optional values of the same type Equatable type

As a developer convenience, Swift lets you implicitly promote non optional values to optional where necessary, So in this example, the right hand value passed to == is actually also a String.

I’d argue that this isn’t really an issue, because you should write code like this.

  • if the value is non-optional, force unwrap (or safely unwrap and throw a descriptive error) first, then try to cast it,
  • if the value might not be an integer, force-cast (or conditionally cast, safely unwrap and throw a descriptive error).

The mistake here is combining the two steps. It only makes sense to do when you have no need to distinguish “nil because it’s not there”, “nil because it’s actually nil”, and “nil because it’s a non-nil, non-integer value” (which I reckon is unlikely)

1 Like

Sure — but we're talking about a different classification of error here.

Note that for instance, when checking why string == "Hello" is false, you don't need to check whether string is an Int. We're still working with two values within the domain of String?; why the values differ can't be determined statically.

In the second case, too: the value inside of dictionary["intKey"] is within the domain of Any (you actually couldn't have this error if dictionary was defined as [String: Int], if that were applicable here).

These can both be the result of regular old logic errors, which the compiler can't catch. Luckily, the surface area for investigating what's actually going on is significantly reduced by the strong typing guarantees the language offers, so there's a relatively clear direction of what values to check and how. (And again, the point is: Swift prefers to have the compiler catch the errors that it can, since that's pretty much strictly preferable to leaving the task to you; things that it can't catch... well, you're on your own.)

4 Likes

I found that, surprisingly, this returns true for equals((nil as Int?) as Any, (nil as String?) as Any) even though the types don’t match.

2 Likes

Good catch. Since rhs as? A fails and returns an A?, the equality check for two nils passes since they are now of the same type. Perhaps the condition needs to be beefed up to be:

type(of: rhs) == A.self && lhs == (rhs as? A)

I think that breaks when there is a subclass that should be considered equal. I was trying to fix this by using is to only require that it be a subtype of A, but then I discovered that:

(nil as String?) is Int? // true, though I expected false
5 Likes

This is one of many reasons you should still go through as? AnyHashable first when implementing a heterogeneous equality operator. AnyHashable handles subclassing, bridging, numeric representation differences, and other dynamic cases that should usually be considered equivalent in dynamic situations. (You can still do the as? any Equatable and existential opening thing as a second pass if you care about types that aren't Hashable, since all of the types that have special handling are Hashable.) Note that AnyHashable, and Swift's dynamic casting in general, consider nils of different Optional types to be equivalent, and I think that's the right thing for a heterogeneous equality operator to do for consistency.

4 Likes

AnyHashable still has its own confusing pitfalls:

1 Like

Yes, but it's good to have as few variations on confusing pitfalls as possible in the overall system.

1 Like