Access control for enum cases

+1. I've wanted this since forever.

My preference would be to add the notion of non-exhaustive, non-resilient enums first, then add non-public cases as a feature built on top of that. It makes the user model simpler - rather than saying that only enums with non-public cases are not exhaustive, we introduce the idea that some enums cannot be exhaustively switched for "some reason" (which may be due to non-public cases, or for any other reason).

I would guess that most people who would use this feature would be using it to block exhaustive switching for regular-old API evolution. It would be nice if we could just do that instead of tiptoeing towards the obvious while having developers write clunky code in the mean time.

Even if defaults are flipped in Swift 6 or some later language version, it would be easier to do that if we weren't also introducing new concepts at the same time.

Will cases themselves be able to be @usableFromInline? I should think so, but asking to confirm.

A little bit earlier in the thread, there was a discussion that we potentially don't need a separate notion of a non-exhaustive enum, instead we can define the semantics of something like:

enum E { case e; internal case f(Never) }
// is equivalent to
@nonExhaustive enum E { case e }

where pattern-matching on E values would be exhaustive inside the module (you have access to case f so you can see that f exists but you don't need to match on it due to Never) but non-exhaustive outside it (from the outside, E has some non-public case, so matching is non-exhaustive).

I'm not saying we should necessarily adopt this, but that's one alternative. If we choose that alternative, having a separate mechanism for non-exhaustive non-resilient enums is pure sugar and (arguably?) not necessary.

The other alternative is that impossible cases are ignored outside the module, the same way they are ignored inside the module. This makes the pattern space decomposition consistent across modules (i.e. Space(E) = Space(case e) both inside and outside the module, vs Space(E) = Space(case e) inside and Space(E) = Space(case e) + _ outside), however, this means that if you use an internal case f (no associated values) to emulate @nonExhaustive, you need to use fatalError inside the module. So in such a situation, it would be nice to have a separate @nonExhaustive (or similar mechanism).

Given that there are two alternatives, one of which (arguably?) cleanly describes non-exhaustive enums, my current thinking is to wait for the discussion to settle down for a bit before considering adding another pitch as a dependency.

As you saw, in Cory's pitch Extensible Enumerations for Non-Resilient Libraries, the idea of "version-locked dependencies" came up where potentially the enum could be exhaustive for a set of modules which are "version-locked" and non-exhaustive outside it. I don't want to go too much into the weeds here, but something like that could fit into a more general access control model (strawman syntax):

visible(declaration) = private
visible(file)        = fileprivate
visible(module)      = internal
visible(workspace)   = ??? // workspace = set of version-locked modules
visible              = public

If you want to add @nonExhaustive as a mechanism for governing where the exhaustivity-relation of cases is visible, you'd even be able to select @nonExhaustive(module) vs @nonExhaustive(workspace). [I don't want to derail this thread by discussing a more general access control model further, if you want to discuss it, let's do that over DM or in a new thread. :slight_smile:]

There's also the question about should this be an attribute or should the defaults be changed/warnings be added to remove the language dialect. That's a much more difficult question to answer... I don't have a good sense for what solution the core team would like to see here.

I understand what you're saying about adding non-exhaustive non-resilient enums first. However, I think that if we have some low-hanging fruit -- in terms of getting rough consensus from Swift developers and approval from the core team -- that can improve QoL for people, there's a good argument to be made that we should pick the low-hanging fruit first, instead of tackling the bigger challenge.


Yes. (Thanks for bringing this up, I hadn't explicitly thought about it.)

2 Likes

Isn't this like saying Swift doesn't need namespaces because we have enum Namespace {}? And wouldn't it mean that would couldn't switch exhaustively even internally? It seems like a real version of the feature would work even better, just like real versions of namespaces.

3 Likes
  1. I was trying to describe the alternatives and summarize the discussion to present the full picture. I'm not arguing that it should be done or that having a way to fake things is better than or equivalent to having an explicit spelling. If you see this comment, I'm actually weakly in favor of having an explicit spelling.
  2. Internally, it would work as it does today, so even if you omit the case with Never, the match will still be exhaustive.
2 Likes

I think it would be great to have a way to keep exhaustivity while discarding private cases.

The same way we have @unkown default, could we have @private default which would match any private case but keep exhaustivity on other cases ? Obviously "@private" might need a better name

I am actually more interested in whether this could be used to protect the invariants of the enum value.

For instance, a case immediate(Int) which actually requires the value be in the range 0..<24 cannot be enforced today without creating something akin to an ImmediateInt type and making that the associated value with the case. This impacts the ergonomics of using the enum type.

1 Like

Thanks for spearheading this! People seem to be covering the points I would have said, so I’ll try to be brief.

  • I always figured non-public cases were going to be added eventually, since Apple does something like that for NS_ENUMs today. (You can see it in Future Directions of SE-0192.)

  • At the time I wrote “frozen enums should not have non-public cases” just to simplify the story. I still think that’s a good idea, mostly for the simplified model (“you can always exhaustively match a frozen enum”). Even though “fixed representation but non-exhaustive match” is a valid combination of features, I imagine the need for such an enum would be very rare—and could also be added in the future if we change our minds.

  • Private cases should not be left out of RawRepresentable; that’s how you push an NS_ENUM through an NSNumber for anything that uses plist types, like Notification.userInfo. I suppose that could be limited to @objc enums but I don’t really see the point.

  • Honestly I see private(init) as a separable feature, but also a useful one and perhaps even more important for its ability to protect invariants. It doesn’t affect matching or representation, though, so it’s a lot simpler.

  • I like the idea that if you put custom access on one case, you have to put it on all the cases.

7 Likes

I understand the desire for simplicity, but I'm not entirely sure that justifies adding this restriction. Even without the restriction, the rule can has a relatively simple formulation "you can always exhaustively match if all cases are accessible."

  • Ordinary and frozen enums: you can't match exhaustively if you can't access all cases.
  • Non-frozen enums: they may be hiding some non-public cases from their swiftinterface, so you can never match exhaustively.

That said I'm somewhat ambivalent about this, as I can't think of a compelling use case for this particular combination.

I'm not entirely sure what you mean... Just to clarify, I'm not suggesting that we synthesize a RawRepresentable conformance that can only be used to initialize public cases. Instead, I'm suggesting that we don't support synthesis, but library authors are free to write the conformance if they'd like to do so. Are you suggesting that we should support conformance synthesis, even though it makes it easy for clients to create non-public cases?

I agree that it is separable, but I'm not sure why I should separate it out. :slight_smile: Are you saying that this pitch is already too big without it? Or are you saying that it is better to have multiple smaller proposals rather than a single bigger proposal (say for easier review)?

As you must've seen earlier in the thread, there's also discussion on a potential @nonExhaustive and @unknown in nested positions. The more pitches/proposals there are, the more overhead there is for me, especially since some of the discussions will be overlapping. If this one pitch balloons into four pitches, I may have a hard time keeping up. It would be nice if we could keep things down to say two pitches (say this one, and maybe another one for @unknown in nested positions). I hope we don't get to a stage where I need to draw a complex diagram for dependencies between pitches. :joy:

I agree. :+1:

1 Like

Yes, I think that situation is the common one and forcing the enumeration of cases to get RawRepresentable would be unfortunate (pun half-intended).

I do think smaller proposals are better than larger ones when they’re independent features, even if they’re related. The self-interest version of that is that you’re less likely to have to go through multiple revisions with smaller proposals. ;-)

1 Like

+1

For my particular need, I'm interested in the private(init) case part. It's easier to implement than having an associated value with a private init, and more straightforward to consume.

About AllCasesIterable and RawRepresentable, I would be in favour of

  • if all cases are initializable where the conformance is declared, it is synthesised
  • if at least one case is not initializable where the conformance is declared, the conformance must be met manually
1 Like

I agree that RawRepresentable should be synthesized; my simple argument is that in most cases the implementation people will want will be the default one :P. Although it can be used to skirt access control, as noted above, non-public cases are rather a matter of signalling intent for cases without associated values anyway (since one can simply store a non-public value for later), rather than enforcing that users never can create a value. I have a feeling that in most cases where RawRepresentable is needed, support for non-public cases will be required as well, so this is just creating additional work while leading to the same result in the end.

Instead, this seems like a problem of educating people on what RawRepresentable means and when it should (and shouldn’t) be used. I don’t think forcing them to rewrite the implementation every time is a proper way to go about doing that :P.

2 Likes

-1.

Access control.

As a user of a framework, I don't want to receive from the framework enum values that I cannot handle and need to design fallback behaviour without fully understanding the semantics.

I think private enum cases are valid only in positions where values are sent from the user to the framework.

As an author of a framework, I would like to audit enum usages to make sure that private enums don't escape to the clients.

I think the best solution here would be to replace enum into two:

// Instead of
public enum Message {
    case something
    case somethingElse
    internal case testing(SomeOtherEnum)
}

public enum Message {
    case something
    case somethingElse
}

internal enum InternalMessage {
    case something
    case somethingElse
    case testing(SomeOtherEnum)
}

public func send(_ message: Message)
internal func send(_ message: InternalMessage)
public func send(_ message: InternalMessage) // Error

Evolution

I think versioning is a better way to handle framework evolution. If users can be sure they are working with a specific framework version, they don't need to bother about unknown future cases. I would love to have versioning for Apple frameworks as well, decoupled from iOS versioning. Embed frameworks inside the apps (as Swift runtime used to do in early days), but share copies of the same framework version between apps to reduce device space usage.

3 Likes

Maybe we could introduce enum subtyping instead:

internal enum InternalMessage {
    case something
    case somethingElse
    case testing(SomeOtherEnum)
}

public enum PublicMessage : InternalMessage {
    case something
    case somethingElse
}

And only provide the subtype PublicMessage to the user.

2 Likes

I think overall it's a good idea - it naturally provides unconditional upcasting and conditional downcastning without writing any boilerplate code. But for enums I think it makes more sense to have 'supertyping' instead:

public enum PublicMessage {
    case something
    case somethingElse
}

// InternalMessage extends PublicMessage, but InternalMessage is a supertype of PublicMessage
internal enum InternalMessage: PublicMessage {
    case testing(SomeOtherEnum)
}

let pub: PublicMessage = .something
let int: InternalMessage = pub // unconditional
let pub2 = int as? PublicMessage // conditional

Access control rules are different compared to classes. With classes, you can have public superclass and internal subclass, but not the other way around. With enums is the other way around.

Also, classes have many-subclasses-to-single-superclass relationship, while for enums its single- subenum-to-many-superenums.

2 Likes

Yes, subtyping becomes inverse to inheritance relation, but then we should also use another operator than :.