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 
1 Like
tera
2
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
}
jrose
(Jordan Rose)
3
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.
tera
4
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?
jrose
(Jordan Rose)
5
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?
tera
7
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
}
tera
8
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 
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?
tera
10
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.
jrose
(Jordan Rose)
11
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
tera
12
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.
josephduffy
(Joseph Duffy)
13
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
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!
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!
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