Extensible Enumerations for Non-Resilient Libraries

I'm +1 on this, but I wonder if a protocol might be better than an attribute. Something like this (please don't looks too closely at the names):

protocol HasUnknownCase {
  static var unknown: Self { get }
}

Then, a switch can know if the value it is switching over conforms to this protocol, which could even be a conditional conformance (though I can't think of an example where this is needed off the top of my head).
Another benefit is that you can handle the unknown case anywhere in your switch: (before, the default case duplicated the logic in .pepperoni)

switch pizzaFlavor {
case .hawaiian:
    throw BadFlavorError()
case @unknown .unknown, .pepperoni:
    try validateNoVegetariansEating()
    return .delicious
case .cheese:
    return .delicious
}

An added benefit would be that this protocol could be used in conditional conformances to provide different behavior if an enum is "frozen" or not even outside a switch. One example of this is decoding: GitHub - airbnb/ResilientDecoding: This package makes your Decodable types resilient to decoding errors and allows you to inspect those errors.

I am fairly strongly disinclined to invent a new system for this. SE-192 defined a system for enum extensibility already, and provided machinery for controlling it with an attribute: it just didn’t provide for doing this in non-resilient libraries. It’s a substantially broader change to try to provide new machinery via a “magic” protocol, and I don’t think it’s worth the added complexity.

4 Likes

Do you think there should there be a way to opt-out of the warning (for missing @unknown default) even when the enum is marked @extensible? I understand we generally don't want to have fine-grained functionality for enabling/disabling warnings, but I can see a certain subset of users be willing to update their switch statements when upgrading between library versions, because that might be preferable to throwing errors (or doing something else) for unhandled cases.

For example, if I am maintaining several modules in a monorepo and those modules are rev-locked, I might want external clients to use @unknown default but internal clients to keep matching exhaustively.

1 Like

You don‘t have to take my post seriously, but it would be kinda cool if the attribute was an antonym of frozen such as hot or unfrozen. :hot_face: Other than that I think it‘s a good idea to support both variants to help library authors.

1 Like

Yeah, this is a bit awkward. There is no such functionality for doing this today across Swift modules. We could arrange to do so, but I’d want to do so holistically across both resilient and non-resilient modules, and so I kinda think it’s out of scope for this pitch.

I think this shows that we might've made a bad call with diverging the behavior between resilient and non-resilient libraries here. I support adding an attribute to allow for explicitly opting enums into extensible behavior. There could also be an opportunity for us to eliminate the language dialect here in Swift 6, and make it so that the next breaking change of the language always defaults enums to non-frozen behavior in all modules.

12 Likes

I agree with @typesanitizer that there's something interesting to investigate in the idea of version-locked vs. non-version-locked dependencies, though—the choice about whether to version-lock is usually up to the client, not the library vendor. This could also enable more optimization when a client app (i.e. an end product) bundles a module with it that has library evolution enabled—it can make more assumptions because its dependency is bundled. (This has come up before but I'm not sure what terms to search for.)

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. Maybe it should be a warning to have a missing @unknown default when the dependency is not resilient, and you can opt out of that warning by saying the dependency is "rev-locked" or "shipped with the product", either through an attribute on the import or a command line flag passed by SwiftPM or Xcode.

(As to why this wasn't considered during the initial round of SE-0192, that proposal was contentious enough, and did generate a bunch of annoyance from users about what it did start requiring.)

4 Likes

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. That would still support the "typical app developer" use case, where someone uses modules only for code organization still gets exhaustivity checking for enums for free without added ceremony, while allowing for uniform behavior for libraries that use modules for versioned distribution, whether they do as resilient binaries or source packages.

2 Likes

Presumably this would bring @frozen to non-resilient libraries, such that developers can say that specific enumerations will never be evolved, regardless of whether the dependencies are version-locked.

3 Likes

I feel like it would make more sense to make this a compiler error. When I use the -enable-library-evolution compiler parameter when compiling a library, that is me (the user) saying 'I know that an enum could add a new case later'. But when a developer adds @extensible to an enum, that is the developer telling the user 'hey, I'll probably be adding more cases down the road', and so I think it makes sense to force the user of that library to handle that case, instead of potentially ignoring it and having their code randomly break when they pull a new version of the library.

If you want to argue that in the end, it's the user's fault if they ignore the warning, I'm fine with that also.

Apologies if it was already mentioned and I missed it. Do I understand correctly that this would allow us to fix the core libraries’ issue where they’re not entirely source‐compatible with themselves on different platforms simply because some use library evolution and others don’t. Here are some old threads describing what I’m talking about:

I agree - whether or not an enum in a library will evolve to add cases is a decision for the library (and it makes sense that it should default to yes/maybe). The client may decide that it doesn’t care about new cases in new library versions and regain exhaustive switching.

That really does make the most sense, and I think it’s an overall simpler model for users to understand. It’s also nice that it applies module-wide rather than on a per-enum basis.

The only place where it gets hairy is if we want to allow third-parties to add cases to enums (which IIRC was something we weren’t sure about in the initial proposal). I would say it's not worth supporting that usecase. It would mean that even the declaring module can’t exhaustively switch over its own enum, so we would need some kind of special annotation for it, and if you’re doing that anyway, you could always add a .custom(Any) case to get the same behaviour.

1 Like

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.