[Pitch] Default implementation of Hashable for AnyObject

This is typically solved in the language by implementing a copy-on-write struct that wraps an underlying class

Yeah, I'm aware of how the stdlib implements CoW types. That design decision was made a decade ago to overcome an existential threat to shipping a major rewrite. When we had to expand it beyond generated code, we didn't feel that it was appropriate to ask folks to write every struct in duplicate. Also to be clear, this has nothing to do with performance and everything to do with binary size.

My point isn't that this is a best, or even a common, practice. But instead that not all Swift programs, or even all iOS programs, have the same needs.

This would be nice, but is a band-aid for a more widespread problem. You'd also need an overload for navigationDestination(item:), and navigationDestination(for:), and if you ever want to hold onto a model in a Set, would we want an overload of insert for Set<AnyHashable>? And contains? And every other Set API?

This doesn't sound very widespread to me: it's the family of navigation controller related APIs. I assume your Set example comes from something you've built on top of this for swiftui-navigation; I doubt many other folks have this problem. As a SwiftUI user I agree that I also want the Swift language to prioritize things that make it better, but imo this goes too far.

But even aside from SwiftUI the problem exists where identity is a common thing to hash on. Off the top of my head:

  • KeyPath

KeyPath is actually a great counterexample. Firstly, identity wouldn't be right for most subscript keypath use cases. Second, you're assuming that the compiler doesn't create a new instance for each KeyPath reference. And finally, even if it did, calling appending() on a KeyPath would break it. KeyPath needs a special Hashable impl and it's good that it has one (though it would probably be good if it implemented Identifiable so users could find the specific member(s) involved as well).

If it was possible to make AnyObject conform to protocols, I think a more useful idea would be to default to an Identifiable conformance. That seems a lot closer to what you (and SwiftUI's navigation APIs) really want.

Macros at least may make overcoming this easier these days!

Not all have the same needs, but an extremely common Swift program is the iOS app, and it is extremely common for them to use SwiftUI, and this same SwiftUI pattern can be employed in UIKit, AppKit, the browser via Wasm, Android, Windows, etc. (see the swift-navigation library). And in general it seems like the majority of Hashable implementations for objects in Swift is identity.

That said, I agree that an explicit @HashableObject macro that implements the conformance, or a HashableObject: Hashable protocol with a default conformance, would address the outlier concerns.

Widespread as common to SwiftUI apps, and as general as holding objects in a Set, which you may want to do for a variety of non-SwiftUI reasons (you may want to tie their lifetime to another object in some bucket, you may just want a queryable set of model objects, etc.). And the SwiftUI APIs you are suggesting would need to be fixed in a closed-source framework when it is addressable in the language.

Points taken :smile: It was a hand-wavey example that only passes when you squint.

This already exists in the standard library. You can extend any class with : Identifiable and get a synthesized conformance for free. SwiftUI does rely on Identifiable for some presentations (sheets for example), but not others.

An existing gotcha with structs that synthesize Hashable and override == :slight_smile:

1 Like

Yes, it's a surprisingly common issue since nothing tells the developer that the properties they use for == must be exactly the same as those used in hash(into:). It would be really nice if the compiler could verify the implementation, or if Swift Testing could ship the conformance verification tests from the standard library so everyone can easily test their implementations.

1 Like

Yeah, I'm in favor of this. One big reason that comes to mind is that, in day-to-day usage of classes, you don't really ever reach for ObjectIdentifier. I could see scenarios where, even if this is the correct behavior, folks would get stopped in code review trying to merge it because of the conventional wisdom illustrated in this very thread. Codifying it in the standard library feels like the right choice.

I finally looked at the documentation, and it says

You can use any type that conforms to the Hashable protocol in a set or as a dictionary key.

I confused Hashable with Equatable. I was wrong. Please disregard everything that I said in this thread. I am sorry.

5 Likes

I didn’t want to step into the discussion yesterday, as I needed to gather my thoughts and there was already enough confusion for me to add more.

The pivotal question now, I believe, is what is the expected behavior of hash persistence through object lifetime. From what I understand, and what was I believe demonstrated in this thread, is that the hash of an object should stay constant throughout its whole lifetime. Please, if you have more insight about the stdlib expectations around this, let me know.

If that is the case, I think the only sensible way to hash an object is by using its ObjectIdentifier, or, when talking about a final class with only immutable values, by combining all of the constants.

To address some of the mentioned alternatives:

  • HashableObject - this is the path libraries take when shipping this functionality and I would be okay with that alternative. The only thing I am worried about is whether having two separate protocols, albeit with the same prefix, wouldn't create more confusion for newcomers. I do believe the number of people being confused by this would be greater than the number of people being confused by the default implementation of using just the object identity (which was mentioned as a possible downside). But that belief isn’t based on anything meaningful.
  • @HashableObject macro - while this sounds intriguing, there are a few reasons I wouldn’t be a fan. First of all, I’m not a huge fan of using macros where a better designed compiler/stdlib could be used. Secondly, I’m not sure what the macro should actually do. Should it calculate hash based on values for final immutable classes and then default to object identity when the class contains any variables?

Where I see the biggest issue is the performance implications when a developer decides to override the default Equatable conformance but doesn’t override the hashing at the same time, as @David_Smith mentioned. I wonder whether there is anything that could be done when the compiler encounters this situation, but I have zero knowledge how that works, so no idea if there’s anything meaningful there.

1 Like

As Hashable objects are used in sets, and/or as dictionary keys, then yes, you don't want set members or dictionary keys to change during the lifetime of these sets/dictionaries.

Yes, this makes sense. Though see also Equality is Separate From Identity.

You can use this approach provided that you take additional measures to ensure uniqueness of the hash in every instance of a Hashable class.

1 Like

That's a good point, thanks for the link. I'll need to think how we can square this in the proposal.

1 Like

Perhaps this concern could be alleviated if this proposal came with a custom diagnostic to catch these situations for all Hashable implementations? That would make not only classes but structs safer as well. Reminds me of the custom diagnostic about unhandled Tasks with non-Never error type that was added recently.

2 Likes