Subclass operator

Found an interesting corner in Swift:

infix operator <->
class Base {
    static func <->(_ lhs: Base, _ rhs: Base) -> Int {
        print("base implementation"); return 0
    }
}
class Derived: Base {
    static func <->(_ lhs: Derived, _ rhs: Base) -> Int {
        print("derived implementation-1"); return 1
    }
    static func <->(_ lhs: Base, _ rhs: Derived) -> Int {
        print("derived implementation-2"); return 1
    }
    static func <->(_ lhs: Derived, _ rhs: Derived) -> Int {
        print("derived implementation-3"); return 2
    }
}
let base = Base()
let derived = Derived()
let derivedAsBase: Base = Derived()
_ = base <-> derived
_ = derived <-> base
_ = derived <-> derivedAsBase

Guess what's being printed.

Base implementation in all three cases!
But why?!

This is somewhat related but more important as it is about EQ / Hashability rather than using a custom operator:

class A: Hashable {
    var x = 0
    init(x: Int = 0) { self.x = x }
    func hash(into hasher: inout Hasher) {
        hasher.combine(x)
    }
    func eq(_ other: A) -> Bool {
        x == other.x
    }
    static func == (lhs: A, rhs: A) -> Bool {
        lhs.eq(rhs)
    }
}

class B: A {
    var y = 0
    init(x: Int = 0, y: Int = 0) { self.y = y; super.init(x: x) }
    override func hash(into hasher: inout Hasher) {
        super.hash(into: &hasher)
        hasher.combine(y)
    }
    override func eq(_ other: A) -> Bool {
        if let b = other as? B {
            super.eq(other) && y == b.y
        } else {
            super.eq(other)
        }
    }
    static func == (lhs: B, rhs: B) -> Bool {
        lhs.eq(rhs)
    }
    static func == (lhs: B, rhs: A) -> Bool {
        lhs.eq(rhs)
    }
    static func == (lhs: A, rhs: B) -> Bool {
        rhs.eq(lhs)
    }
}

var a = A()
var ab: A = B()
var b = B()

precondition(a == ab)   // ✅
precondition(ab == a)   // ✅
precondition(a == b)    // ✅
precondition(b == a)    // ✅

precondition(a.hashValue == ab.hashValue) // ❌
precondition(a.hashValue == b.hashValue)  // ❌

This seemingly "doing everything right" implementation breaks hash invariant (equal things must have same hash values).

This implementation of == doesn't "do everything right" because what you are trying to express violates transitivity: two distinct instances of B named b1 and b2 can compare equal to a but compare not equal to each other.

2 Likes

Is there "the right way" here? Or, as with floating point, we obey one rule (IEEE semantics) to break another (a == a).

Say, I want to correct the above code and make subclasses always not equal to base classes. transitivity rule saved, hurrah! But then Liskov substitution principle is broken as I won't be able replacing an object of a class with an object of a subclass without breaking the app (the chosen object previously compared equal to its peers and subclass won't).