[Pitch] Non-Frozen Enumerations

The point of default is to provide fallback code for cases not handled earlier in the switch. The point of @unknown is to warn you if the compiler knows about cases that will reach the default. If a new case appears, default can be reached and it'll run print and fatalError. You'll also get a warning when recompiling.

Of course, if you use -warnings-as-errors you'll get an error instead, so adding a case to the enum will be source breaking for you.

2 Likes

Is this a fair test:

enum E { case a, b, c, d }

let futureValue = unsafeBitCast(UInt8(4), to: E.self)
switch futureValue {
case .a: print("a")
case .b: print("b")
case .c: print("c")
case .d: print("d")
@unknown default: print("some other case") // πŸ”Ά Warning: Default will never be executed
}

to test the following scenario:

  • the app uses a dynamic library v1.0
  • the app was not recompiled and the library version is upgraded to v1.1
  • the library got a new enum case (e) with the discriminator equal 4
  • the library passes this new case to the app

Note that there is a compile time warning above "Default will never be executed" (ditto happens with normal "default"). Presumably the code of "print("some other case")" could be stripped away because of this.

This test crashes with EXC_BAD_ACCESS (and there is no "some other case" in the output) but I am not sure if this is a fair test.

Are you compiling in library evolution mode? If not, E is implicitly @frozen, so @unknown default won't work.

1 Like

No, you need a resilient library.

2 Likes

On the name:

I don't think it does, because I don't think @frozen actually is very general or nonspecific.

Firstly, just so everybody is clear about this: it is the library evolution compiler mode which grants a compiled library a stable ABI, not the @frozen attribute or lack thereof. It is a property of the module, and it applies module-wide; I can create a library where I do not declare anything to be @frozen, build it with library evolution mode, and the result will have a stable ABI.

@frozen is one of a number of attributes which developers can use to trade certain future evolution opportunities for various benefits, such as more efficient access patterns. It is not the only attribute of its kind - @inlinable and @usableFromInline similarly allow developers to trade future flexibility for performance.

Is it too broad?

On the topic of whether the term is overly broad: @frozen does not promise that the type will never evolve at all. For instance, a frozen type may still add functions, nested types, computed properties, and protocol conformances, private/internal fields may be made public, immutable fields may be made mutable, etc. They may even contain non-frozen types, meaning they don't necessarily have a fixed size and can indirectly add storage for new information.

However, a type annotated as @frozen does promise that it will not make certain changes, such as adding stored properties or enum cases. In fact, the term @frozen was coined in SE-0192 specifically for enums. SE-0260 Library Evolution later broadened the term, acknowledging that something availability-shaped might be better:

Since this feature is only relevant for libraries with binary compatibility concerns, it may be more useful to tie its syntax to @available from the start

But deciding against it:

But that said, @frozen still has the right connotations to describe a type whose stored instance properties or cases can no longer be changed.

And so I do not agree that the name @nonfrozen implies some kind of overly-broad "maximum source compatibility" mode. It has a specific meaning, "not frozen" - i.e. that we explicitly do not promise the kind of restricted evolution that @frozen does. I think it is reasonable that the attributes, inverses of each other, must be understood as a pair.

Whenever partial consumption lands, it is (IMO) rather unlikely that we would allow structs to be destructured across module/package boundaries without the author opting-in (which would otherwise be analogous to allowing exhaustive switching by default). But if we did go that route, a @nonfrozen attribute would naturally be appropriate in order to opt-out of that behaviour and preserve evolvability of the type.

Of the other names that have been suggested:

  • "extensible" is even broader. Moreover, the root of the word extensible is, of course, "extension", which is already a concept in Swift. So I believe this name to be misleading; this feature has nothing at all to do with extensions.

  • "nonExhaustive" is much less broad, but since it lacks symmetry with frozen, IMO it increases the complexity of the language and is more difficult to learn. Consider again a package, usable in either source/binary distributions, which wishes to explicitly annotate every enum @frozen/@nonfrozen. I think it's easier for developers to understand as opposed to @frozen/@nonExhaustive.

Weighing everything up, I still believe @nonfrozen is the best name. It is certainly no worse than any of the other suggestions, and I think the symmetry between @frozen and @nonfrozen is meaningful and valuable to developers learning and using the attribute.

4 Likes

if nonfrozen is the opposite of frozen, what does it mean for a declaration to lack either attribute? half frozen? implicitly frozen?

2 Likes
  • Frozen means "I promise not to change the enum in way X, Y, Z"
  • Non-frozen means "I do not promise not to change the enum in way X, Y, Z" (double negative)

I feel the term "opposite" is perhaps a bit too strong (maybe that's just me). As the name implies, it's a negation.

If you don't promise either way, it is (unfortunately) inferred by the compilation mode. That's the state we're in today, where source libraries make an implicit promise not to evolve their enums, because they are considered frozen.

So non-frozen opts out of that promise that is otherwise implicit.

To me, this is exactly the issue: Trying to find symmetry where there is at best a rather small overlap (imho).

If I think about how things are right now (not what the words could mean, but what @frozen actually does and the established defaults of language modes are):

@frozen: Only makes sense in library evolution mode, mainly for optimizing ABI. Controls a ton about binary layouts of structs, enums cases are just one a detail here.

@nonfrozen: Only makes sense in "normal" mode, means "you cannot exhaustively switch over enums, because future cases might me added". There is no other use case (eg: @nonfrozen structs?), it has nothing to do with ABI, it is not the opposite of @frozen.

I know I am repeating myself, but I just do not see the need for "finding the symmetry", and I fear tying "extensible enums in packages" to a language-mode/dialects/@frozen discussion will only make it more unlikely to get it off the ground.

3 Likes

but that’s a different kind of frozenness, without the attribute, those types are still abstracted and this changes how they behave when it comes to things like overlapping access during mutations.

2 Likes

Precisely. It's true that the only current scenario in Swift where one can encounter an unknown case when switching over an enum is one where the type is defined in a resilient library and not @frozen. However, it is not intrinsic to the concepts of @frozen-ness and non-exhaustiveness that they be mutually exclusive.

Consider, for example, a language which supports private or internal enum cases: In such a language, an enum can be @frozen with three public cases and two private cases for all time. The layout will never change, with all the benefits that come with that in terms of compiler optimizations, etc. Yet for the end user, those private cases would not be instantiable nor utterable for the purposes of pattern matching: switching over such an enum requires handling @unknown cases even if the enum is @frozen.

To be clear, this is not to argue that we ought to incorporate such a feature into our plan of record nor to optimize our design for such a hypothetical feature, merely to show how @frozen-ness and exhaustiveness are not yolked together conceptually but rather by circumstance.

For this reason, I agree with @Jumhyn that we shouldn't neglect to explore a syntax like what @Jumhyn described earlier, something like:

enum E {
  case a, b, c
  @unknown case
}

If we were ever to extend access control to enum cases, then the generated interface for a non-exhaustive frozen type would naturally look something like:

// Generated interface:
@frozen
public enum F {
  public case a, b, c
  @unknown case
}

// ...for the corresponding implementation:
@frozen
public enum F {
  public case a, b, c
  private case d, e
}
6 Likes

This is a mischaracterisation, and even though many of us (including myself, of course) want the feature to be accepted in to the language, fear of delays should not be the driving force behind the discussion.

If there is symmetry (and it is most definitely more than "small overlap"; I have responded to it in detail above), we should explore it, unfazed, to arrive at the best model for the language. It's been years; there is no rush.

For instance:

Firstly, see my points about @frozen above. Originally it only meant enums, but was expanded based on its connotations (which still apply in this usage). It allows library-evolution mode to perform more efficient accesses based on promises, but it is certainly not the only attribute which does so.

@taylorswift mentions that explicit @frozen may also influence checks for overlapping accesses when library evolution mode is disabled (it would be nice to have an example).

Moreover, there are upcoming proposals which rely on very similar promises about the contents of structs, and which have proposed using @frozen to make promises of restricted evolution even outside of library-evolution mode. This would be perfectly harmonious with that proposed usage.

I understand the desire for this, but perhaps I could propose an alternate approach?

We already have concepts of types that are not "frozen" and are allowed to evolved between releases and behave almost exactly like enums in almost every single regard: structs.

A struct with static members looks exactly like an enum. It is used exactly like an enum.

The two major areas where structs differ from enums are:

  1. Simplicity around cases with associated values
  2. Exhaustivity checking.

This proposal is about loosening exhaustivity checking on enums, which would basically make them on-par with structs for how they're used.

What if, instead, we guided folks to use structs instead of non-exhaustive enums and then taught the compiler how to do exhaustivity checking on structs?

1 Like

It's about controlled loosening, not making it equivalent to structs. And having tried errors as structs, the ergonomics are generally terrible if you have a lot of unique payloads.

2 Likes

Structs with static members come close but they have significant disadvantages. Just to name a few:

  • developers are used to use enums so teaching them the static members dance is hard
  • often you want to still exhaustively iterate inside your package. This requires an internal enum with an extern struct with static members
  • you cannot fully express enum cases with payloads with static struct members

These are just a few but we have learned over the last years that enums are just the right tool and we feel the pain of not being able to use them in the server ecosystem quite significantly.

IMO, we should enable developers to use the right tools to model their APIs while providing good defaults for developing public APIs. Similar to how the default access level is internal IMO the default public enum should be extensible. This would mean that developers would need to add an attribute to make the frozen similar as in library evolution mode and that they have to actively think about it.

8 Likes

i seem to have been mistaken, a basic test on 5.9 using

public
struct S
{
    public
    var x:Int
    public
    var y:Int
}

and an out-of-module function

import S

public
func f(_ s:inout S)
{
    {
        s.y = $0
    } (&s.x)
}

does not raise any errors, even though by all reasoning based on @frozen-less abstraction, it should.

i don’t know if this is intended behavior or if the compiler is just failing to emit a diagnostic. but i found it surprising that an out-of-module consumer of a non-frozen type has knowledge of the layout of that type. this seems to contradict a lot of what we have been told about stored properties.

Yes, that seems like a bug to me :-(

1 Like

filed it here: compiler failing to diagnose overlapping access of non-frozen types Β· Issue #69832 Β· apple/swift Β· GitHub

1 Like

What about demanding a default case by writing the following, no introduction of @nonfrozen (or @extensible) or usage of @unknown default necessary:

public enum FormatStyle {
    case short
    case verbose
    default
}

Usage:

    switch formatStyle {
    case .short:
        ...
    default:
        ...
    }

And if you think you defined all sensible cases and want to make it "fixed", just remove the default:

public enum FormatStyle {
    case short
    case verbose
    case veryVerbose
}
2 Likes

Big +1 from me.

Having danced the struct wrapping an enum dance about a million times, I cannot state how much of an improvement this would be.

I also want to note that the struct wrapping an enum dance does not allowed switching over structs with associated values and only really works for enums with associated values.

2 Likes

I like the simplicity of it.


Speaking of the example above could anyone reassure me that cases 2a and 2b could not happen in practice. i.e. if I don't recompile the app but replace a v1.0 library with v1.1 library at runtime everything will just work fine (I can't see how that's possible, e.g. the app will refuse to launch claiming the library incompatibility?), and if I do recompile the app I will be forced to fix the new compilation errors (or at least the new compilation warnings treated as errors) when compiling against v1.1 library.