How to implement the same Hashability for different values?

Let's say we have a type Foo that can be created locally or delivered from cloud.

struct Foo: Hashable, Sendable {
    public enum ID: Hashable, Sendable {
        case local(String)
        case cloud(String)
        case whole(local: String, cloud: String)
    }

    let id: ID
}

It holds an id property that has 3 cases, which represent 2 workflows.

  1. If it gets created locally first, and synchronized with cloud later, then it will be like:
    /// created locally first
    var fooLocal = Foo(id: .local("aaaa"))
    /// synchronized with cloud
    fooLocal = Foo(id: .whole(local: "aaaa", cloud: "bbbb"))
    
  2. If it gets created on cloud first, and delivered to device later, then it will be like:
    /// parse the payload from the cloud
    var fooCloud = Foo(id: .cloud("bbbb"))
    /// finalized on local device
    fooCloud = Foo(id: .whole(local: "aaaa", cloud: "bbbb"))
    

In summary, all Foos will come with partial ID and become whole at a later time. So what I want to achieve is, to treat the them equal if they share the same partial ID or the whole ID.

It is easy to implement that logic for the Equtable protocol of ID:

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

    case (.cloud, .local):
        return false

    case (.local, .cloud):
        return false

    case let (.whole(local: l1, cloud: l2), .whole(local: r1, cloud: r2)):
        return l1 == r1 && l2 == r2

    case let (.whole(local: l, cloud: _), .local(r)):
        return l == r

    case let (.whole(local: _, cloud: l), .cloud(r)):
        return l == r

    case let (.local(l), .local(r)):
        return l == r

    case let (.local(l), .whole(local: r, cloud: _)):
        return l == r

    case let (.cloud(l), .cloud(r)):
        return l == r

    case let (.cloud(l), .whole(local: _, cloud: r)):
        return l == r
    }
}

But I am not entirely sure how to implement the Hashable of ID, so that the hashValue could be the same if the Foo instances share the same id partially or wholly.

Basically, I want this to happen:

var fooIDSet: Set<Foo.ID> = []

let fooIDLocal = Foo.ID.local("aaaa")
let fooIDCloud = Foo.ID.cloud("bbbb")
let fooIDWhole = Foo.ID.whole(local: "aaaa", cloud: "bbbb")

print(fooIDSet.insert(fooIDWhole).inserted)    /// <= This prints `true`
print(fooIDSet.insert(fooIDLocal).inserted)    /// <= This prints `false`
print(fooIDSet.insert(fooIDCloud).inserted)    /// <= This prints `false`

All helps are appreciated!

I'm afraid you can't. And part of the reason is you haven't really defined a reasonable equivalence relation either: it breaks the transitivity law where a == b and b == c should hold if and only if also a == c!

Consider the example of:

let a = Foo.ID.local("1")
let b = Foo.ID.whole(local: "1", cloud: "2")
let c = Foo.ID.cloud("2")

According to your definition of ==, a == b and b == c, but not a == c.

Now, if you find a way to implement == so that all the three laws (including also reflexivity and symmetry) hold, your challenge will be to implement Hashable in such a way that for all a and b which compare as equal, their hash values are also equal.

The trouble is if you somehow define a .local(x) to be equal to another .whole(x, y), then you must necessarily ignore y in the hash computation; OTOH if .whole(x, y) shall compare equal to .cloud(y), then you must also ignore x in the hash computation. That leaves you with very little wiggle room for the hash algorithm, with practically all ID values having the same hash value.

7 Likes

I think you need some process that redefines IDs locally from whatever was pulled from the cloud, so ultimately there's only ever 1 relevant ID per object

While you can do it, you probably don't want doing it:

public func hash(into hasher: inout Hasher) {}

this way the hash/EQ invariant is not broken.


Note that the end result (of redefining EQ/hash for your ID values) could be surprising. Example:

struct S: Hashable {
    var value: Int
    public static func == (lhs: S, rhs: S) -> Bool {
        lhs.value >= 10 && rhs.value >= 10 ? true : lhs.value == rhs.value
    }
    func hash(into hasher: inout Hasher) {
        let v = value >= 10 ? 10 : value
        hasher.combine(v)
    }
}

let a1 = Set((0 ... 10).map(S.init))
let b1 = Set([S(value: 20)])
let c1 = a1.union(b1)
print("first:", c1) // contains S with 10

// same but in a different order
let a2 = Set([S(value: 20)])
let b2 = Set((0 ... 10).map(S.init))
let c2 = a2.union(b2)
print("second:", c2) // contains S with 20

precondition(c1 == c2)