Unlikely-to-suceed pitch: remove implicit Hashable synthesis for enums without associated values

Hi all,

Ever since SE-0185, Swift synthesizes Hashable and Equatable conformances for enums that explicitly declare them, as long as all payload cases are Hashable or Equatable, respectively. However there is an older feature which dates back to Swift 1, where an enum where all cases are empty implicitly gets a Hashable and Equatable conformance, in the obvious way where each case maps to an ordinal.

There is no way to opt out of synthesizing this conformance. This has two downsides:

  • There is a code size cost; if the enum is public, the optimizer cannot safety strip it out since it might be used from outside the module.

  • It breaks resilience. In a resilient module, new enum cases can be added to non-frozen enums without breaking ABI. However, if the enum only had empty cases previously, adding a new payload case removes the Hashable conformance, which breaks ABI, unless the new case payload is itself Hashable and the developer explicitly conforms the enum to Hashable. This might not even be possible if, eg, the enum represents error codes, and the new case is an error case with a payload that is not Hashable.

I'm most concerned about the second point. Resilience is not yet a feature in Swift 5, and it's too late in the release cycle anyway, but I'm wondering if the core team would consider taking a proposal to eliminate the implicit conformance synthesis here, even though its a source breaking change.

27 Likes

We could strip it out in Swift 5 mode. That's a little weird in that Swift 4 code would be less resilient by default, but it seems like the best we could do.

I agree that automatic conformance synthesis makes the cost of requiring explicit conformance quite reasonable.

Does that mean we remove Equatable too? Or is the removal of Equatable implied when removing implicit Hashable?

Yeah, I'm talking about removing both behaviors.

This seems like an obvious thing to do for the sake of consistency now (as long as we're willing to accept the source breakage it causes)

1 Like

This seems like a great thing to do.

Would it be possible for the migrator to slap on : Hashable/Equatable only for the enums that are using the implicit conformance? Perhaps for such enums there could be a fixit when these enums would emit a "does not conform to Hashable/Equatable" error which the migrator could automatically apply.

6 Likes

a. If there is a circumstance where few dozen extra bytes of conformance is a problem, you probably have bigger fish to fry.
b. This doesn't seem to be a "the default is wrong" issue, so much as a "the edge case behavior is undefined" issue. (besides the part where removing equatable from more enums just seems hellish :slightly_smiling_face:)

More generally speaking, I would be very hesitant to remove a feature that has been there from day one, and in many ways is foundational to what an enum is. I know this would be source breaking for almost all of my projects.

Given all the extra considerations/evolution/research in SE-192, my guess is there are people that have thought through this issue thoroughly and have an idea of how it could/should be handled, so I'll defer on how exactly that should be handled.

However this certainly is an issue that should be considered during the decision process non-exhaustive enums in Swift. Or at least something to keep in the back of my mind :slight_smile:

2 Likes

If we make this break, then at a minimum, I think the migrator should slap on : Equatable, Hashable where it's currently implicit.

However, I'd also argue that any enum that is RawRepresentable where RawValue: Equatable should continue to be Equatable (and likewise for Hashable) as though there were conditional conformances to that effect.

2 Likes

If we wanted to minimize source churn, we could take a similar approach to what we did with frozen enums—no-payload enums remain implicitly hashable in "source-only" libraries, but must be made explicitly Equatable or Hashable in resilient libraries.

6 Likes

Are you suggesting that if I have struct Foo: Equatable {} and add a RawRepresentable conformance to an enum with RawValue = Foo that this enum should be receive synthesized conformances for Equatable and Hashable? I can't think of any good rationale for that. Or are you only suggesting that this should only happen when Swift also synthesizes the RawRepresentable conformance?

I think there is more value in being consistent about this than there is in minimizing source churn. The migrator can add explicit opt-in conformances where necessary (which will still be synthesized).

1 Like

I'm suggesting that enum Foo: Int { case x = 1, y, z } remain Equatable.

If the most consistent model for that would be notionally a conditional conformance extension AnyEnum /* cannot actually be expressed in Swift today */ : Equatable where Self : RawRepresentable, RawValue : Equatable, then let the chips fall where they may.

I don't find an idiosyncratic, inexpressible model to be the most consistent with the rest of the langue.

: Int and : String is syntactic sugar for synthesizing RawRepresentable. I think it would be much more reasonable to just add Equatable and Hashable to the synthesis that syntax opts into. The cool thing about this approach is that it does not require RawValue to be Hashable or Equatable in order for the enum to get those conformances. The raw value is not a part of the representation of the enum value internally and need not be used for implementing Equatable and Hashable.

2 Likes

The principal value of this feature was in progressive disclosure. I see it much in the same category as the implicitly synthesized initializers. Without it, there's not much one can do with an enum as a beginner: "opt-in conformance" implies that the user understands protocols and conformances, which in most pedagogical approaches is considered a more advanced concept than enums.

Consider the status quo with synthesized initializers, where they are internal only and have no effect on API. If internal-only protocol conformances were possible, then it would make sense to preserve this feature of enums as though it were spelled enum Foo: internal Equatable, internal Hashable. It would avoid both the code size cost for public types and would avoid breaking resilience. Since (if?) we can't do that, taking a similar approach as was done with frozen enums, as Joe suggests, seems like the next best thing.

Sure, I can buy into that approach too.

If we wanted to minimize source churn, we could take a similar approach to what we did with frozen enums—no-payload enums remain implicitly hashable in "source-only" libraries, but must be made explicitly Equatable or Hashable in resilient libraries.

Yeah, I think this would be reasonable.

Another way of looking at this (in the long run — we don't have this concept right now) is that the conformance is internal in a resilient library and must be made explicitly public to be usable outside of it.

3 Likes

There has been a former discussion on this topic: [Proposal] Explicit Synthetic Behaviour.

I was neutral, but mildly annoyed by automatic Equatable/Hashable synthesis for enums:

I had no idea, +1 on removing it.

This is one of those things I wish I had proposed alongside SE-0185, but I didn't potentially want the whole proposal to get bogged down with a source-breaking change.

You had me at consistency and code-size reduction, but resilience is the one that pushes this completely over the finish line for me. Between Codable, Equatable, Hashable, and CaseIterable, we've continued to enforce a belief that synthesized behavior like this should be opt-in precisely for reasons like that, and I think it's absolutely worth the source break to make sure that we don't paint ourselves into a corner based on legacy behavior that, if we had it to do all over again with the knowledge we have now, would probably go a different way.

9 Likes

I’m on board with removing the implicit synthesis for public enums, but I think it has value and should remain for internal (and below) enums.

The rationale regarding public enums is straightforward: we have a philosophy in Swift that nothing is made public by default, so if the conformance is not spelled out in source code then it should not exist publicly. Plus the active harm regarding code size and resilience, as mentioned by Slava in the original post.

As for internal (etc.) enums, there are several points in favor of retaining the current behavior:

Universality
The only thing you can do with a simple enum is check two instances for equality. Sure, you might pass the instances to a function which does the checking, or you might store them in a collection (eg. Set), but at the end of the day they’re going to get compared.

Boilerplate
It would be needless boilerplate to require adding Hashable conformance to every simple enum just to use it in the only ways it can ever possibly be used.

Convenience
It would be inconvenient to require a switch for every enum comparison, when an if or guard would be better suited sometimes. Not to mention loop conditions and pattern-matching where clauses, which can’t use switch at all.

Progressive disclosure
New developers (or those new to Swift) just want to try thing out and see how it works. They should be able to create and use simple enums in the obvious way without having to learn about protocol conformances first.

Source compatibility
We already have this feature, and it is not actively harmful for non-public enums, so the bar for removing it from them is not met.

4 Likes
Terms of Service

Privacy Policy

Cookie Policy