SE-0192 — Non-Exhaustive Enums — review #2

Ah, yes, that is the long-term goal. I had hoped that would be clear from the section in "Future directions", but it could have been made more clear in the intro sections for sure. There's a lot to talk about there, though. :-)

1 Like

The trouble here is that chained if statements aren't always matching the same input. The only reason you can do exhaustivity analysis in a switch is because you know that all the cases are checking the same input, matching different (sometimes overlapping) subsets of the values that can appear there. You could do this with a general flow-sensitive analysis, or split the difference by only looking at chained if-statements, but that's not only a lot more work in the compiler but makes the language feature a lot more wobbly. (For example, it would be easy to accidentally not match against the same value each time, and then get a pile of errors later. And it's odd to have things that work with if but not guard.)

But besides that, you're right that the arguments against making it a pattern aren't very strong. Part of the problem there is that we just don't have a good name; ._ doesn't obviously have this "future-or-private" connotation, despite the association with members, while #unknown doesn't have the catch-all implication. That said, adding a top-level unknown: now doesn't mean we won't be able to add a pattern in the future if we come up with a good name and have a bunch of real-world code demonstrating that it would be useful. There's precedent with default and _, though you could say default's primary motivation was familiarity to C-family programmers.

As for the discussion of private cases based around raw values, well, not all enums have raw values, and many have payloads. This strategy works okay for C-like enums, and indeed that's what people basically do in C, but we want something that actually works for the way people use enums in Swift.

  • What is your evaluation of the proposal?

+1

  • Is the problem being addressed significant enough to warrant a change to Swift?

Yes

  • Does this proposal fit well with the feel and direction of Swift?

More like a necessary evil due to Apple adding cases to enums in the standard library. As noted in the proposal changing Apple policy is not an option.

  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

Yes and the Java and Scala ways work better. But as the proposal notes it is now too late to change for Swift.

  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

Participated in the discussions and previous reviews.

  • Comment

I think the evolution process has worked well and the second iteration is a great improvement over the first. Congratulations to all involved, particularly Jordan for his hard work.

mh, what's the second important reason? ;-)
Imho it's better to add a special case to the compiler than adding it to the language: if "unknown" is the inferior solution for a future Swift with advanced flow-sensitive analysis, I wouldn't take that route just because it's easier to implement.

No, no, we'd probably never want to move to a flow-sensitive analysis here anyway. See the response to Andrei above.

A future proposal will introduce the -enable-resilience flag and associated behaviors. There's no reason to pull that into this proposal now.

2 Likes

Part of the problem there is that we just don’t have a good name; ._ doesn’t obviously have this “future-or-private” connotation, despite the association with members, while #unknown doesn’t have the catch-all implication.

Normally, when we see an enum having a case foo declared, we should be prepared to use the pattern case .foo to match that member. With that in mind, if we were told that non-frozen enums are declared by adding a case whateverName to them, we would be almost prepared to use .whateverName pattern to match that case. So, probably, any name would suffice and it would likely to come at no surprise if that particular name were actually declared in an enum.

What I'm saying is that before looking for a pattern matching syntax, we'd better start from coming up with the non-frozen enum declaration syntax. The pattern matching syntax could come naturally from the declaration syntax once we have one. According to that logic, the default-label-style syntax should be proposed last, not first, and it should be consistent with both the declaration syntax and the pattern matching syntax.

However, as it seems likely for this proposal to be accepted soon, I suggest choosing a name more neutral than unknown:, as unknown: is tightly coupled with realizing the particular use case mentioned in the 'Motivation' section. A neutral name is less likely to be inconsistent with future use cases.

From what was suggested, fallback: is definitely more neutral.

I'm sorry for the delay posting this, I've been preoccupied with other things, and forgot to repost my comments from the first iteration of the review cycle. The comment below was written in a bit of a rush so I appologize for any small mistakes or an overly harsh tone:

I'm +1 on the proposal, the resolved direction, and yes, this is very important for Swift. Over the course of months, I've spent a huge amount of time on this proposal.

My primary concern with this is a syntax one, which may appear trivial to some, but it is actually not just a question of paint color. I elaborated extensively about this on the previous review thread. To summarize the proposal:

  1. The behavior of the "unknown:" proposed label is exactly a "default with a different warning behavior".
  2. It introduces a new grammar production, a new context sensitive keyword with bespoke disambiguation rules.
  3. The logical next step (as discussed in the proposal) is to allow this in arbitrary grammar positions, introducing yet another way of spelling this.
  4. This feature will not be prominently used, because this second round of proposals makes it so most enums you encounter in your code will be exhaustively switchable. It will occur, but it will occur much less than default on enums, which occurs much less than default in general, which occurs much less than 'case'.
  5. If someone were unaware of this low-prominence feature and encountered it in someone else's code, it will be very hard to search for. What would "swift unknown:" return in google?

To speak frankly, this doesn't make sense to me at all. This is not a new top level concept that is a peer to case. It does not deserve to be prominently featured in the language at the same level as the other things we have keywords for. It is a tweak on an existing feature we already have (default), and even default was heavily debated (given that it is merely sugar for case _:) and was frequently suggested that we remove it. This exacerbates the problems and really seems like the wrong direction for Swift.

Fortunately, there is a simple answer here because we have a system to spell "an existing feature with a small tweak to behavior": attributes. All we need to do is spell this thing as something like:

@warnIfUsed default:     // I'm not attached to the specific attribute name.  

Attributes solve all of my concerns here: they are used intentionally for tweaks on existing behavior, they have much lower prominence, they compose on top of the existing grammar without requiring special disambiguation rules or grammar productions, and given the low prominence of this, it is better to use a verbose name that is googlable because "swift @warnIfUsed default" will return useful hits.

In short, I'm hugely +1 on the feature and direction, but think we need to fix the spelling.

-Chris

21 Likes

I'll admit that I've struggled to keep up with the sheer amount of discussion on this topic, so if this has been mentioned, I apologize! But as I was working on my PR to add advanced Unicode properties to Unicode.Scalar, I realized that the subject of this proposal may apply, especially because those changes are going in the standard library, and I'm trying to wrap my head around a potentially odd case.

I'm writing wrappers for ICU C APIs. Some of the APIs I'm wrapping are C enums, and they're non-frozen (not just because of this proposal, but conceptually—the Unicode Standard could introduce new values for enumerated properties later on. So it seems like I need a way of mapping the unknownness of a C enum to the Swift API I'm wrapping it with.

Imagine that v1 of a C API has this enum:

// C API
typedef enum CFoo {
  FOO_A,
  FOO_B,
  FOO_C,
} CFoo;

When wrapping the above C enum in a "Swiftier" one, I might be tempted to do something like this today, pre-SE-0192:

// Swift API
public enum Foo {
  case a
  case b
  case c

  // Initializer used internally by the library to map the C values to Swift values.
  internal init(_ cfoo: CFoo) {
    switch cfoo {
    case FOO_A: self = a
    case FOO_B: self = b
    case FOO_C: self = c
    // This is a C enum I don't control, so I need a default clause.
    default: fatalError("Got unexpected value from the C API")
    }
  }
}

And we can imagine that there's some Swift function that the user calls, which calls the underlying C function, gets the CFoo value back, and then calls the Foo initializer to convert it to the Swift-friendly API.

Now let's say the system version of ICU changes underneath us, and v2 added a case FOO_D to that enum. Until the Swift standard library changes, any Swift code that tries to use the API will fatalError if it returns FOO_D now.

That's bad; this is an ideal situation for an unknown case. Ideally, my Swift clients should be able to write something like this, post-SE-0192:

func someFunc(_ foo: Foo) {
  switch foo {
  case .a: // do something
  case .b: // do something
  case .c: // do something
  unknown: // do something graceful
  }
}

But there's no way I can see to propagate the "unknownness" of the C enum to the Swift enum in a forward-compatible way:

/* non-frozen */ public enum Foo {
  case a
  case b
  case c

  // Initializer used internally by the library to map the C values to Swift values.
  internal init(_ cfoo: CFoo) {
    switch cfoo {
    case FOO_A: self = a
    case FOO_B: self = b
    case FOO_C: self = c
    unknown: /* what can I say here to make the switch statement above work? */
    }
  }
}

As far as I can tell, there's no way for the initializer to "instantiate an unknown case", if that even makes sense (it doesn't sound like it does). I also don't want to force the property/function result to be Optional, since that doesn't mean quite the same thing.

One option would be to add my own case unknown to the Swift enum and map all C unknown case statements to it, but that feels like it goes against the spirit of this proposal. Is this a case worth trying to address, or is it so specialized that it's unlikely to occur frequently? Am I missing something obvious?

2 Likes

To me this sounds like the best way to model it if you want a user of the Swift Wrapper to know about cases the Wrapper does not support yet.

1 Like

That may be the case. And this is probably a rare situation that doesn't come up with Apple's own frameworks, because they can annotate the C enums directly that they own, rather than wrapping them in a separate type.

It would still be good to know if this is a use case that needs special consideration, though. Having to add a separate unknown case, to me, makes it feel like the wrong solution based on what this proposal wants to achieve, and it's one that I wish I had encountered earlier in the review process.

Personally, I think that is the right way to do it (with your own unknown case).

There are lots of ways you might map an unknown case. Your Swift enum might not be an exact 1:1 mapping (and if it is - well, you can always write a typealias and extensions on the underlying C enum).

Also, you might decide to preserve data from the C API which will help clients handle the new case. That will vary from enum-to-enum, so, IMO, I don't think we need a special language construct for it.

One thing to note is that, in your example, when you go from CFoo -> Foo, you lose the underlying C value. If you wrapped the C value in a struct instead of an enum, you could preserve the underlying value, even if you didn't know about it to give it a special name for it in your overlay. You would lose the concept of exhaustive switching, but maybe that's okay:

struct Foo: Equatable {
  private var value: Int

  func checkSomeProperty() -> Bool {
    return CFooCheckSomeProperty(value) // works for future cases, too!
  }
}

// Special names.
extension Foo {
  static let SpecialA = Foo(value: CFOO_SPECIAL_A)
  static let SpecialB = Foo(value: CFOO_SPECIAL_B)
}

// Switching.
switch aFoo {
  case .SpecialA: break
  case .SpecialB: break
  default: // handles unknown cases, but properties still work.
}

Another option would be to put the known cases in a separate enum, and implement the ~= operator so you can switch them against a Foo.

1 Like

Chris, I buy your arguments that this should be an attribute. I'm not sure it should be engineered to work better as a modifier for default: or for case _:.

I think something like @unnamed might be a good choice, because the warning message could then read something like warning: default case marked @unnamed would match named enum case .newlyAddedCase; add a new switch case to handle it, or accept that the default case will handle known enum cases in the future.

I think encouraging it to be written as @unnamed case _: might handle my earlier concern that unknown default doesn't describe an unknown default.

IMO, this feels quite clunky.

I'd like to reinforce the point that the code I'm talking about is not third-party code, but is proposed to be added to the Swift standard library. The proposal write-up specifically says that non-frozen enums that live there get this behavior, so needing to introduce a physical "unknown" case seems like a possible hole in this feature (albeit a small and rarely occurring one). Effectively, it special cases enums in Apple-authored frameworks (since they can add whatever annotations they want to avoid wrapping the enum) but leaves out other low-level C libraries that Swift depends on but for which Apple doesn't control the code (like ICU).

The consequence of this is that clients of standard libraries and frameworks have to write different code depending on the origin of the underlying enum: for Apple frameworks, they can use unknown: as expected, but for the wrapped ICU enum I want to introduce, they have to write default: (even if they're otherwise exhaustive) or case .unknown:. And what if unknown is a legitimate name for an actual case from the underlying enum? I would need to come up with a unique name for that case, which may be different than other similar enums, and document what that special case means. That's jarring, and a consistency problem.

I worry about this now because, based on how the proposal for Unicode.Scalar properties go, I can see many more effectively-non-frozen C enums from ICU wanting to be exposed by Swift.

...hmm. After typing all of this out, I wonder if something like this would work: instead of wrapping the C enum in a Swift enum, can we duplicate the enums on the C side (we already do, in fact, in UnicodeShims.h) in such a way that they have equivalent values, but we annotate them with the necessary Clang renaming attributes to get them imported with better names? We'd have to make sure the documentation got pulled in correctly, but maybe something like that could work. @jrose, does this seem feasible?

I don't think there is a way in Swift to actually define what the underlying value for an enum is - even RawRepresenable just synthesises a computed getter and initialiser, and the value gets compacted and optimised by the compiler. So if you want to preserve the underlying ICU value (and I assume you do, because the library can and does evolve, and we don't want to duplicate its logic), you're going to have to use C-importing or wrap in a struct (which doesn't support unknown, because it can't be exhaustively switched anyway).

At this moment, I don't—on the Swift side, I'm not making an effort to preserve the underlying numeric value of the enum because they're purely the result of queries and not used as inputs elsewhere, and exposing the raw values feels like a leak of information.

But I suppose that the Apple framework C enums that are imported into Swift also have the same "leak"—you can access the raw value of NSTextAlignment, for example, so perhaps there's precedent for just biting that bullet.

If there is still room and time left for syntax bikeshedding I want to ask if this was previously considered. (I did not searched the threads for this idea, I just checked the alternative section of the proposal.)

We‘re discussing whether or not the attribute should be applied on a case or on default, but in reality we want the whole switch to emit the warning if there are new cases available. This made me think why not put the attribute before the switch itself or extend the syntax a little?

@attribute switch ... { ... }
 // alternative
switch(guarded) ... { ... } // or a different keyword

I think an attribute somewhere different would be less discoverable. That said, I‘d prefer the second solution because it is more extensible (if we ever need to extend it for something different) and it does not require a new attribute. It‘s fair to say that everyone would prefer Swift to not evolve into a language overflowing with tons of attributes.

2 Likes

I think ultimately it comes down to what kind of experience you want clients to have when they encounter an unknown case. This was something lots of people (including myself) worried about when the proposal was first raised: how is it even possible to handle an unknown case?

The best answer(s) IMO were:

  • Inspect the value, with whatever API you know exists
  • Read the library documentation very carefully
  • Show an error

The first case is ideal - you can maybe even "properly" handle the value, depending on what information you need and which properties the enum exposes. My concern was: if I can pretty-much handle the case just by inspecting computed properties, why bother trying to exhaustively switch the value at all? All the benefits of enums fall away and you're left with an awkward struct. If you used a real struct instead in that case, you could get all kinds of benefits (like less boilerplate... basically everywhere).

The other cases are just ¯\_(ツ)_/¯. Even developers with great documentation don't always keep every little bit of it current and accurate (even Apple doesn't). And it's not always possible to abort up to a sensible, stable point of recovery whenever a new enum value is detected.

The discussion about attributes and naming is interesting, but we also need to consider the guidelines we're setting about how to design a resilient API in Swift, so clients can still do useful things if values evolve.

I think that if you're trying to do a 1:1 mapping from C->Swift, you're better-off with a struct. Otherwise you get an experience which is worse than using the library's native C interface directly.
If you have a non-1:1 mapping, you might lose information going from C->Swift anyway. So you should use a failable initialiser and collapse all unknown values to nil. That might be best for ICU - I'm not an expert, but I assume we're only going to be exposing a small subset of its functionality?

I like the suggestion above from Chris.

Also I would prefer if you had to annotate a Swift enum with non-frozen even inside an Apple library. Happy for C/Obj-C enums to be automatically non-frozen (no annotation required).