CustomHashable: Implement Hashable conformance with macros

For a long time I've wanted to keep hash and equality calculations in sync. Initially I did this using key paths but this was clunky and added overhead at runtime. With macros, and specifically some of the fixes in 5.9.2, it is now possible to do this with macros. I have put this together and released it at GitHub - JosephDuffy/HashableMacro: Swift macro to add Hashable conformance by decorating properties.

I've tried to prevent a few pitfalls with the library, namely:

  • Hash and equality checks will always use the same properties
  • hash(into:) is marked final to prevent overriding it in subclasses because == cannot be overridden
  • Detection and support for types conforming to NSObjectProtocol by overriding hash and isEqual(_:) rather than hash(into:) and ==

I'd love to hear any feedback on this, especially if there's something I've missed or that could be improved! Personally I'm excited to start using this :smiley:

1 Like

You may find this discussion interesting.

This doesn't look right as it opens an opportunity to break the hash invariant ("equal values must have equal hashes"). Simple example:

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

class ScaledValue: Number {
    var scale: Int
    var scaledValue: Int {
        value * scale
    }
    init(value: Int, scale: Int) {
        self.scale = scale
        super.init(value: value)
    }
    // can't override final hash!
    static func == (lhs: ScaledValue, rhs: ScaledValue) -> Bool {
        lhs.scaledValue == rhs.scaledValue
    }
}

var a = ScaledValue(value: 1, scale: 0)
var b = ScaledValue(value: 2, scale: 0)
if a == b {
    precondition(a.hashValue == b.hashValue) // 🛑 Precondition failed
}

Consider

var a: Value = ScaledValue(value: 1, scale: 0)
var b: Value = ScaledValue(value: 2, scale: 0)
if a == b {
    precondition(a.hashValue == b.hashValue)
}

The Hashable requirement is about the == that will be used by Equatable.

In your example (considering var a: Number = ...) the == will be false so the line with precondition will be skipped.

I would say that Number(...) and ScaledValue(...) typecasted to Number should always be "not equal" (even when they would be equal according to the base class EQ operation.


Something like this could be used to make "overridable EQ":

    // DON'T OVERRIDE THIS
    static func == (lhs: Number, rhs: Number) -> Bool {
        lhs.eq(rhs)
    }
    // CAN'T OVERRIDE THIS
    final func eq(_ other: Number) -> Bool {
        if type(of: self) == type(of: other) {
            return equatable(other)
        } else {
            return false
        }
    }
    // OVERRIDE THIS
    func equatable(_ other: Number) -> Bool {
        value == other.value
    }

Not sure if the "type(of:)" check is the best thing to do here. Is there a better way?

I'm just saying that overloading == doesn't count as "breaking the hash invariant". I agree it's still a bad idea though.

1 Like

Thank you for looking!

I initially thought that it wasn't possible to override == at all, but now I think I was confused by some tests. With the following, running in an XCTest:

class BaseClass: Hashable {
    static func ==(lhs: BaseClass, rhs: BaseClass) -> Bool {
        lhs.baseProperty == rhs.baseProperty
    }

    var baseProperty: String

    init(baseProperty: String) {
        self.baseProperty = baseProperty
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(baseProperty)
    }
}

class Subclass: BaseClass {
    static func ==(lhs: Subclass, rhs: Subclass) -> Bool {
        lhs.baseProperty == rhs.baseProperty
            && lhs.subclassProperty == rhs.subclassProperty
    }

    var subclassProperty: String

    init(baseProperty: String, subclassProperty: String) {
        self.subclassProperty = subclassProperty

        super.init(baseProperty: baseProperty)
    }

    override func hash(into hasher: inout Hasher) {
        super.hash(into: &hasher)
        hasher.combine(subclassProperty)
    }
}

let a = Subclass(
    baseProperty: "123",
    subclassProperty: "456"
)
let b = Subclass(
    baseProperty: "123",
    subclassProperty: "456-different"
)

XCTAssertFalse(a == b) // Does not fail test
XCTAssertTrue(a != b) // Fails test
XCTAssertNotEqual(a, b) // Fails test

a == b // false
a != b // false

What am I not understanding here? Or more likely: what mistake am I making?

One obvious idea is to compare identities, so that:

Number(0) == Number(0) // always false 

Making "different instances are always different no matter what contents they have" is quite appealing. Otherwise you might as well use value types.

    private var identifier: ObjectIdentifier { ObjectIdentifier(self) }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(identifier)
    }
    static func == (lhs: Number, rhs: Number) -> Bool {
        lhs.identifier == rhs.identifier
    }

You could fix your example by providing != in the subclass:

class Subclass: BaseClass {
    static func == (lhs: Subclass, rhs: Subclass) -> Bool {
        lhs.baseProperty == rhs.baseProperty
            && lhs.subclassProperty == rhs.subclassProperty
    }
    static func != (lhs: Subclass, rhs: Subclass) -> Bool {
        !(lhs == rhs)
    }

This feels like a pretty big footgun with Equatable!

I will update the macro to remove final from hash(into:) and also implement != in subclasses. I think that it is a better workaround for this than the current approach :smiley:

edit: actually, that doesn't work for XCTAssertNotEqual, I think because != is not defined as a requirement for Equatable but is instead added in an extension so it's calling the != added to BaseClass?

Codifying this idea into a protocol:

protocol IdentityHashable: AnyObject, Hashable {
    var identity: ObjectIdentifier { get }
    func hash(into hasher: inout Hasher)
    static func == (lhs: Self, rhs: Self) -> Bool
}

extension IdentityHashable {
    var identity: ObjectIdentifier { ObjectIdentifier(self) }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(identity)
    }
    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.identity == rhs.identity
    }
}

class Number<T: Hashable>: IdentityHashable {
    private (set) var value: T
    init(_ value: T) { self.value = value }
}

let a = Number(0)
let b = Number(0)
let c = Number(1)
precondition(a != b)
precondition(a.hashValue != b.hashValue)
precondition(!(a == b))
precondition(a.value == b.value)
precondition(a.value.hashValue == b.value.hashValue)
precondition(a.value != c.value)

Good catch!

Although I'm not fully convinced it makes much sense providing EQ / hash by doing anything other than identity comparing / hashing for reference types.

You should just not overload (note: not override!) == in subclasses. Providing != doesn't help; it still behaves differently based on the static type. The way to do overridable == is by calling out to a method, like NSObject does with isEqual, and even then you have to be very careful that subclasses are only more equal, not less. And if you're doing that, you have to do the same for hashing.

(Why doesn't the language help you more with this? Well, because that would be special-casing a particular operator and protocol. But maybe it would have been worth it.)

2 Likes

Obviously too late for Swift, but if we did it from scratch maybe it would be better making hash a static method so it is "in the same boat" with EQ:

// not Swift:
protocol Hashable {
    static func hash(_ value: Self, into hasher: inout Hasher)
}

Or go into the opposite direction making EQ an instance method:

// not Swift:
protocol Equatable {
    func equal(_ other: Self) -> Bool
}

C++ went the latter way with their operators: they are (overridable) instance methods.

I thought about supporting this via e.g. an isEqual function but there are too many decisions that I don't think I can make for most/a majority of users.

Not supporting subclassing and marking hash(into:) final feels like the simplest solution right now :sweat_smile: note: it is already possible to opt-out of marking it final by passing finalHashInto: false.

I have not added Equatable conformance to enough classes to know what the best thing to do is. I have always assumed it's best to not add it to classes at all!

Great work!

Some notes:

  • for me it would be more natural to mark properties as @Hashable or @HashableProperty instead of @HashableKey (because property and key are different meanings)
  • According to my experience I need more often to mark properties as @HashableExcluded instead of some kind of @Hashable, because most of properties are needed to be added to hasher and only one or several of them are needed to by excluded form hashing. @HashableExcluded is a property wrapper so default compiler implementation is used, not macros are needed. In general it seems that @CustomHashable can be helpful when properties are already marked by property wrappers and we need custom hashing (because property wrappers are poorly composable)

Thank you for the feedback!

I remember trying to name the macro @Hashable and it was causing some compiler errors, I think the generated extension MyType: Hashable {} would not compile because it would resolve Hashable as the macro. I tried it again and it's working! :smiley: Maybe this was a Swift 5.9.0 or 5.9.2 beta bug.

So the main macro is now @Hashable, properties are marked @Hashed, and there's also @NotHashed. So you can:

  • Use @Hashed to opt properties in
  • Use @NotHashed to opt properties out and use all non-decorated non-computed properties
  • Not decorate any properties to use all non-computed properties

Hoepfully that's as flexible as it can and needs to be. I think it's an improvement at least.

Please let me know if you have any other feedback.

1 Like