[Pitch] Non-Frozen Enumerations

To me the this feels like missing the target. Does anyone have an actual use case that would ask for this?

Here is another example that illustrates the issue I think needs addressing:

Image this API:

public enum Temperature {
    case cold
    case veryCold
 
    // adding this is currently considered a breaking change -> major version
    case soColdYourTeethHurt 
}

public struct AtTheBar {
    public func haveADrink(temperature: Temperature) {}
}

Obviously, if these were separate methods, adding a new one is considered non-breaking (semver minor):

public struct AtTheBar {
    public func haveAColdDrink() {}
    public func haveAVeryDrink() {}
    
    //added in a minor version
    public func haveADrinkSoColdYourTeethHurt() {}
}

Neither of the cases is particularly important or ignorable - and all is fine with the world as long as as nobody exhaustively switches over the enum. But currently we cannot tell a package's user "my friend, this enum is not meant to be exhaustively switched over - things might be added".

Also, nobody would argue "I need a warning when a new function appears in the API, I could miss something".

Return values/errors are just the same but reversed: some enums are just meant to be extended over time.

Of course, one does not HAVE to use an enum - but I think we can all agree that swift's enums are a fantastic construct in many cases (pun intended). Without a solution in this space, having an enum in a public API is punished so hard that people tend to avoid them.

1 Like
enum PublicChatRoomEvent {
    case newMessage
    
    // A
    @ignorable case userJoined
    @ignorable case userLeft

    // B
    case newEncryptedMessage
}

At some time (A) some additional minor events were added to show when people join & leave the chat room. The author reasonably determines that these can be ignored by existing clients because not showing joins & leaves is benign (this is a public chat room, there was never any expectation of privacy or an exclusive audience).

At some later time (B) encrypted chat support is added. The author decides that existing clients should probably to be notified of this, and encouraged to support it, because messaging is the fundamental point of the chat room.

In the latter situation, if enum users were using default, they'll ignore the new case. If they were using @unknown default, they'll be told about it. This is an important example because it demonstrates how @unknown default is the safer option in general and as such should be the "default" (pun? intended?). Nonetheless it is not always the best option - e.g. if the enum user is a bot it might not care about anything but public messages (and be confident it won't care about any new message types in future).

A hypothetical (C) case is e.g. a case encryptionEnabled, which turns on encryption for all messages, including those delivered through newMessage. In this case you could argue that even clients using default handlers should be 'broken' (source-incompatible change, at least) because otherwise they'll misbehave by misinterpreting ciphertext as plaintext. But as I noted earlier, I'm not certain this extra layer of 'flexibility' is worth the complexity. Enum authors can avoid the need for this through different design choices (such as not incompatibly modifying or overloading existing cases).

Also, making a plain default handler not handle everything is, well, just contrary and confusing. IMO default handlers should not be used carelessly - that's the whole point of this long discussion about how to make enums more usable as opposed to hacks like static member variables.

I think that's the crux of why I'm not swayed by your argument. I get what you're saying - enum has some nice aspects that are in principle orthogonal [to exhaustivity checking] but unfortunately are unique to it in the current language implementation. I'm just not convinced that it's worth limiting enum because of limitations elsewhere in the language.

1 Like

There might be holes here worth highlighting. Starting from:

enum PublicChatRoomEvent {
    case newMessage
}
  • the corresponding switch doesn't have default statement.
  • a newly introduced "@ignorable case userLeft" will be a compilation error (issue #1)
  • to fix the compilation error user might reach out for "default" case handling
  • adding a subsequent 'important" case newEncryptedMessage would be caught by that default and won't result into the (wanted) compilation error (issue #2)

It might be possible to fix those patch holes by rules like so:

  • default could only match "unimportant" cases (similar to what @unknown default is now)
  • all "important" cases must be handled explicitly.

I have't thought it through thoroughly though, there might be holes in the logic still. Note that this is only tangentially related to the concept of frozen / not frozen (e.g. you might want to have an unfrozen enum with "important" cases that is set to grow in the future).

If you add an ā€œignorableā€ case, this is a breaking change because a user of your library might have handled all cases (ignorable or not) explicitly so no default is implemented.

Yes it will be. That's part of the proposed change - refer back to my proposal.

By definition (of using plain default) the user doesn't want a compilation error. That's what @unknown default is for.

That's a similar but distinct approach, of defaulting new cases to 'unimportant' as opposed to 'important'. I mentioned it is a possible extension, as well - 'belt and suspenders' kind of thing.

The latter - that new cases are implicitly 'important' - is essentially what we currently have, and I think it makes sense to stick with that. It's the safer default, as it means by default new cases do require some explicit acknowledgement from users.

Also, thinking about the interplay with Typed throws, another example is:

struct APIError: Error {
    case unreachable
    case loginRequired
    case clientVersionUnsupported

    @ignorable case outOfMemory
    @ignorable case internalInconsistency
}

It allows delineating errors that the enum author (the API author in this case) thinks clients will probably care about and/or should consider handling specifically, versus those that realistically aren't going to be of individual interest (other than for logging / bug reporting purposes).

In the above case you could, as a user of the API, do:

    …
} catch APIError {
    switch error {
        case unreachable:
            // Handle the network being down,
            // e.g. "Check your internet connection"
            //      message to the user.
        case loginRequired:
            // Execute login flow (and then retry).
        case clientVersionUnsupported:
            // Trigger 'Check for updates' process, or at least
            // inform the user that an app update is required.
        @unknown default:
            // Generic error case, probably nothing intelligent
            // the program can do.  Maybe suggest the user try
            // again later, or contact customer support.
    }
}

If additional meaningful errors are added (plain cases), they'll be "source-incompatible" in that they'll produce an exhaustiveness warning. e.g. a case for quotaExhausted.

If additional "internal" errors are added (@ignorable cases), there'll be no incompatibilities (nor compiler messages) and they'll automatically be handled by the existing @unknown default handler. e.g. a case for wrapped errors like ioError(POSIXError).

It's of course not perfect - API users might disagree with the API author on which error cases are 'interesting' or 'important'. The good thing is that API users can still explicitly name and handle @ignorable cases, if they want. It's just, then, a matter of finding out about them without the compiler's help. Which has many natural means already - e.g. release notes for the API library, error reports from testing and end-users, etc.

The @unknown default nomenclature isn't ideal - the ignorable cases aren't unknown per-se.

What do you thing about ?

enum Enum {
case a
case(ignorable) b
}

For clarity, how is this expected to apply to app developers who's apps are broken into many modules / libraries?

Maybe I missed something here, but I wouldn't expect in that case the burden to be on the app developer to either add @frozen or @unknown default everywhere for their own internal modules' / libraries' enums. It's defeasible that the best default will always be exhaustive pattern matching for those enums only the app developers & teams use internally, likely even in error cases. (Where this may not be true is very very large orgs, but they would likely use library evolution in that case I'd assume.)

Requiring extra syntax will (I would expect) result in more senior code reviewers expecting @frozen to be added to all enums in their own control, even though ABI stability isn't a concern, simply because it provides the right defaults. @nonExhaustive seems like the best option from this perspective, because it puts the burden of requiring additional syntax (and semantics) explicitly where it is needed, most usually on public library authors of open source libraries who should understand these nuances anyway.

It's actually largely irrelevant whether there's multiple modules in play. The question applies irrespective - what do you want to happen if a given enum changes?

Either you want to presume it won't ever change (or want to handle that if & when it happens) - so mark it @frozen and deal with the errors¹ if that's violated - or you expect it will change and you want to accomodate that in advance - by putting in default or @unknown default handlers.

What we currently have (outside of resilient mode) is a fragile and ambiguous ternary-state option, "dunno", where your code isn't required to be clear about its intentions nor resilient to changes. As a result of the default being that unadorned enums are implicitly treated as frozen.

That said, there is perhaps a conflict here between semantic intent and mechanics. You might not logically consider an enum frozen - e.g. your general error type enum, to which you expect to add new cases over time - but you do want to utilise exhaustivity checking to avoid default handlers.


¹ What the errors are depends on your module's use. For most people this is just compiler errors from the source-breaking change. For others - e.g. those distributing opaque (binary-only) libraries - it could mean runtime errors.

1 Like

What I'm attempting to say is it's not irrelevant to app developers (assuming I understand your proposal correctly). Additional syntax to get the default a developer always wants (frozen-ish) for types they control affects how teams need to then operate, it puts more burden on communication and code review expectations; expecting @frozen always for owned types (which is what I expect will generally become the convention) has to be clearly communicated across engineering teams and code reviewed. The ambiguous ternary-state appears only relevant to oss semvar so I'd prefer we don't create burden outside of that.

In the case of @nonExhaustive the only burden on app developers is to enforce @unknown default usage (or default in rarer cases) when using such libraries, which seems like a fair compromise given it's already required for apple libraries too.

Though, tbh I expect, most apps are likely going to be statically linked against oss dependencies with no concern for ABI nor semvar and always bumped to the latest as soon as the team realizes there's a new version. I've never seen anything less across swift and similar, phone or cloud. However, I know how much of a burden this maintenance is to library authors especially since semvar does matter to oss which depends upon oss, and I can't think of a better compromise than what's essentially been proposed (other than a name change to @nonExhaustive).

for as long as i have been reviewing code, i have expected @frozen to be added to public enums, not because we had any concept of ABI stability, but rather to work around optimization pitfalls when compiling SPM projects.

it has certainly annoyed me that @frozen (and its sibling @inlinable) need to be sprayed everywhere simply because SPM is utterly helpless without the attributes. (especially in the past with older versions of the toolchain.) but it’s the de facto reality, at least in the code i work on, that @frozen is already everywhere.

Fascinating, I had no idea that would reduce optimizations in a statically linked case, thanks for sharing. Though, I'd hope that could be addressed without syntax at some point...

I think the "semver" aspect is in fact very important. Languages live and die by their package ecosystem and, more generally, experience. Bumping the major version of a package - that is to say, breaking compatibility with the preceding version - really is a problem today.

  1. The tooling never notifies you that there is a new major version of one of your dependencies (unless you don't pin to a major version at all, which has even worse usability problems and is impractical for most projects). So bumping the major version is a great way to silently leave behind most of your users.

  2. Major version bumps are viral; every downstream package has to bump its major version as well, as a direct consequence. If they don't, their "minor" or "patch" release is going to force a major version upgrade of the dependency, which might be in independent use by downstream packages, breaking dependency resolution as a result of the conflicting package version requirements. Bumping the major version of all downstreams at least lets the leaf nodes (e.g. developer's applications) decide when they want to move the universe forward.

#1 could be fixed trivially, but it's been years since Xcode integrated SPM support and it's made no effort in this area, so anyone that was holding their breath is long dead.

I fear #2 is intractable. Therefore it's important to minimise major version bumps, by making sure they are genuinely for a valuable, necessary change and not merely the result of an oversight.

2 Likes

Hey all,

The language steering group discussed this pitch thread in one of our recent meetings and wanted to give feedback that we agree this issue is an important one to address, and we'd be supportive of further proposals in this area. While this feature has been proposed and decided against in the past, it is worth revisiting since experience and feedback from the ecosystem is that this can be a major impediment when evolving packages.

The LSG also suggests that more consideration be given to actually changing the default in the language, as part of a language version bump. This would be out of scope for Swift 6, which is primarily focused on concurrency, but may well fit within the scope of a Swift 7 language version. If this were the direction, adoption of the feature could follow the established path of opting in via an upcoming feature flag, allowing earlier adoption by specific packages.

This could be a little odd because ordinarily, changing your language version changes the behavior for you but here the desired effect is changing it for your clients. This is an interesting area for future discussion threads to explore.

Thanks everyone for the discussion so far.

Ben

12 Likes