Extensible Enumerations for Non-Resilient Libraries

I know this has its own drawbacks, but couldn’t in the short term we support building SPM packages as resilient libraries with a flag in the Package manifest? Rather than adding another annotation to the languages glossary?

That does not address the problem. I do not want to make my entire library resilient: I just want to be able to add new cases to an enum without causing compile errors in my downstreams. Resilience is a lot more than just adding extensibility to enumerations, and it comes with substantial performance tradeoffs that are rarely worth it for source-available libraries.

In general resilience transforms ABI stability into API stability: in most cases (not all! please be cautious!) an API-compatible change will be an ABI compatible change in a resilient library. What I’m trying to address is that resilient libraries have an extra layer of API compatibility available to them compared to non-resilient ones: they can add enum cases.

Presumably this change is source-breaking. Is it really necessary to wait for Swift 6 to resolve this issue?

1 Like

Well, it would mean that all uses of enums from other modules would need to handle unknown cases (unifying how resilient/non-resilient enums are handled), or import those modules with a special attribute.

So it would break a lot of code and will require a new -swift-version. Whether that is 6.0 or 5.4 or whatever isn’t hugely important IMO. It’s just a number, and presumably we would support the old (i.e. current) language version for a year or two, and users can upgrade module-by-module if they want.

+1 from me on this given I was required to write a "fake" enum struct just the other day. They are really cumbersome, especially for enums used as Errors

2 Likes

Right, to add more context: Where we can (no associated values necessary), we switched to this model for errors which we all hate.

2 Likes

This isn't entirely relevant, but it would be nice to allow enumerations to be extended with new cases regardless of whether they are @frozen from within the same file, or even just the same module.

Just seeing this now, extremely +1!

We are preparing for an upcoming 1.0 release right now and have been combing through our whole API to look out for forward compatibility concerns. We've now switched several types that were modeled very nicely by enums over to structs for exactly this reason :cry:

4 Likes

+1
My preference would be to make it a requirement to mark all enums @frozen in resilient libraries in Swift 6+ and make the default everywhere be extendable.

Frozen enums are a major drag for making frameworks that are just used internally by a single app as a way to avoid code tangle in the app by making it modular. The contortions required because extending enums isn't possible in this context are painful.

If this proposal makes that possible I'm +100 (inflation!).

Though (catching up) reading Enum cases as protocol witnesses - #20 by sveinhal , it uses a protocol to define common enum cases which can then be "extended" in concrete enum definitions that conform to the protocol which might actually solve our use case more cleanly.

Is it possible to help move this along?

I think we should also consider making enums which conform to Error extensible by default (with the ability to opt-out by marking them @frozen). Obviously this wouldn't handle retroactive conformances and introduces additional complexity to the language, but it would make a more sensible default for error-enums than our current implicit @frozen. I think the current situation qualifies as being 'actively harmful' and warrants a source-breaking change.

My hesitation with pushing this forward myself has been that @jrose and @Joe_Groff have both given responses that I've had categorised as "tentatively in favour of a different approach". I think for me to be motivated to continue this work would require hashing out whether or not that alternative approach is worth doing. I'm reluctant to invest too much time in a proposal the core team is not going to want to take.

3 Likes

Sorry to revive this after such a long time. I think this is still a pressing issue - I have some libraries that I would like to publish a 1.0 release for, to declare their APIs as stable, but I can't because significant parts of those APIs involve switching over enums and extracting associated values, and the only alternative design (wrapping it in a struct with optional computed properties) is highly inadequate.

Apparently the surface of the moon is +130C in sunlight, and around -110C in shadow (a 240C gradient). That's what these designs feel like, there is just such an extreme difference - the version with enums is elegant and pleasing, and the workaround is just... exceptionally inelegant and displeasing. I'm not really happy exposing this API at all if it can't use an enum, the workaround is that horrible.

The really frustrating part is that Swift has this feature. It just chooses to reserve it for SDK libraries and not make it available whatsoever if your library is a Swift package.

As for alternative approaches, I don't read the responses as rejecting this feature. Quite the opposite:

Version-locked dependencies are an interesting idea, but I don't think they would obsolete this feature; they would simply allow a library's clients to override it. For instance, even if the library author says their enum is non-frozen, since you've pinned its version, you can treat it as frozen regardless. A client with a version-locked dependency is immune to any library evolution considerations at all (because they locked it -- the concept of "versions" no longer apply to them).

Other dependencies - those without version locking - rely on source stability through concepts such as semantic versioning. They would still have this problem that some enums should evolve without breaking source, and some should not, and it isn't clear which evolution strategy applies to which enums.

Unless we are going to deprecate semantic versioning and require all dependencies be locked to a specific revision (and basically eliminate the package ecosystem in the process), we will still need a way to mark which enums may/may not evolve without breaking source.

2 Likes

It has been two years with no advancement on this front (because of advancements on many other fronts). I still think @nonfrozen is clunky, but it’s also practical and not hard to implement. (I do still think changing the default but only for enums would be too much churn; if we’re gonna do that, we should think about everything that library evolution mode controls, including places where clients could get more flexibility rather than less. And that’s a big ball of mud.)

2 Likes

I had to comment a +1 for badflavorerror :D

+1
This is sorely needed. The inability to make Error enums extensible for Swift packages can prevent SDK integrators from adopting critical bug fixes due to the major version change.

1 Like

At this stage I think the only thing preventing me from pitching the actual change is that no-one from the core team has spoken up confidently about a version of this design that they would be inclined to sponsor. Joe's comment:

Came before @jrose intervened, and was later followed by an exchange were @jrose said:

"I do see that there's a gap here, but I'm not convinced that adding an opt-in for extensible enums is the right way to go, as opposed to something like what @Joe_Groff is saying."

To which @Joe_Groff replied:

"I agree, deciding whether modules are "version-locked" or not sounds like a better basis for the effect we were originally trying to get with the design here than library-evolution vs not."

@jrose has clearly reconsidered, but I'd like to know if @Joe_Groff still wants to pursue a version-locked dependency approach. If he does, I'd much rather spend time trying to convince him otherwise before I update a patch or propose a new pitch. Frankly, pitches that go nowhere are an incredibly expensive use of my time, and it's much better to hash out the objection on a lightweight thread like this than for me to go and polish up my compiler skills again.

7 Likes

I don't interpret those comments in the same way - agreeing that there is a better, albeit much more complex approach which might be implemented one day does not necessarily preclude accepting a targeted patch today. I don't think Joe was advocating in favour of not doing anything unless we can do everything - of letting the perfect be the enemy of the good - but he can clarify since it seems to be ambiguous.

In any case, as I mentioned above, version-locked dependencies are not, by themselves, a solution for this problem. Even if they existed, non-version-locked dependencies will still exist, and are likely to be the norm (as they are today), because the idea of a package ecosystem falls apart when every package demands precise and inflexible revisions of its dependencies. Those non-locked dependencies will still need a way to evolve their enums without breaking clients.

The suggested alternative is in fact that we add both version-locked dependencies and flip the default to make all public enums extensible. Version-locking would then be how you opt back in to the current situation of enums being frozen. That is such an enormous overhaul ("everything") that it would be impractical for many packages to adopt. Whilst it would be an interesting thing to explore one day (and I also agreed with that 2 years ago), I always interpreted it as being a vague, distant idea, rather than an expression that we should all wait on using enums in public APIs until it is implemented.

3 Likes

This is what I mean when I say "version-locked dependencies". I agree that that is a lot of work that involves interactions with build systems and such, so I'm not opposed to also having a "nonfrozen" attribute in the meantime.

It would still be good to design a mechanism for distinguishing modules that are part of the same product from third-party modules, because I think we could pursue a lot of other ergonomic improvements for the former beyond just enums being frozen by default—for instance, making implicit memberwise initializers publicly available to version-locked clients, or reducing the ceremony of designated/convenience/required/open/etc. class initializers that are significant for API boundaries but become burdensome within code that is always built as a unit.

9 Likes

I see a lot of people whose opinions I respect a lot collaborating on this, and I know rather little about the current reality of library evolution since I don't and haven't really worked with that, which together make me think that I'm likely wrong in this doubt that I have, but with that disclaimer I'll raise it nonetheless.

The question that first popped into my head when reading the example about needing to add new pizza flavors was:

"Have we considered the idea that the reason for our problem with the pizza flavors (and anything similar) is not that this proposed language feature is missing from the language, but rather that this is a misuse of enums?"

I guess the idea would be "enums should only be used to represent finite sets (i.e., CardinalDirection), and structs should be used for the rest, adding static members if the convenience of creating the values is what's missed from enums."

I put the idea in quotes because I'm trying to make clear that I'm not actually endorsing it, I just considered it and don't know enough to agree or disagree.

I suppose what I would like to know is, what are the reasons for wanting to use enums when the collection of members is theoretically unbounded, besides the syntactic convenience of creating enum values (which can be added to structs using static members), and besides exhaustive switching (because that's precisely the thing causing the problem isn't it)?

I saw mention of associated values somewhere above - does that play an important role here?

I understand that this type of questioning can potentially get an otherwise fruitful and good endeavor stuck in the mud, so I welcome no answers or brief answers of all sorts, including links to relevant material to cure my ignorance, if that is what's afoot.

Associated values is comfortably the most important reason: structs simply cannot emulate that language feature.

Leaving that aside, the other less important reason is that sometimes we're wrong. Sometimes we think we've enumerated a finite set, but we missed something! Or, sometimes something is a versioned finite set, and the members change over time. Evolution is a nice capability.

The final reason is: the language already has this feature! Resilient libraries can already create extensible enums. The proposal is only to allow both language dialects access to the same features.

6 Likes