Access control for enum cases

Yes, that would be allowed, except if the enum is @frozen, in which case X needs to be @usableFromInline or public. More generally, the access rules for associated values should be similar to those for stored properties; after all, in some sense, associated values for enum cases are "conditionally stored properties."

4 Likes

Excellent, thanks for confirming. Will be good to call these out explicitly in the final proposal

1 Like

A quick thought on this: how should copying behave under this rule? That is:

// An initialiser of some struct/class:
init(_ val: SomeEnumWithPrivateCases) {
    // Technically, this is initialisation, but we can't know if we're copying a private enum case...?
    self.field = val
}

Copying would work as usual, similar to how copying works for structs with private stored properties or non-frozen enums under library evolution.

2 Likes

I'm just a bit concerned about the generality of this rule: imagine an enum as in your example:

public enum B {
  case v
  internal(init) case w
}

— that is, it has no associated values, so only the discriminator (v vs. w matters). If the module P ever passes me a value of B.w, then I can match it, copy it into some global memory perhaps, and then initialise it however I want as if this internal(init) restriction never existed:

var globalB: P.B!

func someFunc(_ b: B) {
    switch b {
    case w:
        // Aha, I've got that sweet B.w, let's store it
        globalB = b
    }
}

func someOtherFunc() {
    // Now I can use B.w surpassing the initalisation constraint
    let bw = globalB
    ...
}

— the important part, that is, is the lack of associated values (and thus any entropy): the fact that B has (in general) just two possible values makes it very easy for me to circumvent the restriction, collecting all possible values of B and using them as if I could write let _ = B.w all along.

In other words, the only way for me, the vendor of B, to ensure that B.w never gets constructed by a client is to never pass this case at all – but that defeats the purpose of allowing to match against the case. So either one shouldn't be able to match B.w at all or private(init) doesn't protect the value from being initialised in an indirect way.

Has exhaustivity of nested patterns been discussed? It would be a shame to have to nest switch statements just to get public exhaustivity. For example, if I wanted to switch on an instance of this:

enum Parent {
  case child(Child)

  enum Child {
    case tap
    internal case response
  }
}

I must nest switches to ensure a warning when new public cases are added:

switch parent {
case let .child(child):
  switch child {
  case .tap:
    break
  }
}

I'd like to flatten that nesting, but I imagine the warning will be lost in the following:

switch parent {
case .child(.tap):
  break
case .child:
  break
}

It'd be nice if @unknown could be used as a sub-pattern:

switch parent {
case .child(.tap):
  break
case .child(@unknown):
  break
}
5 Likes

This is true for any value of a type with publicly-viewable, privately-settable API, though, no? Like, if I have:

public struct S {
  public internal(set) var x: Int
  public init() { self.x = 0 }

  init(x: Int) { self.x = x }
  static var one = S(x: 1)
}

Then a client of S can inspect x, see if x == 1, and then save that "private" value for later use, effectively circumventing the internal restriction on S.one. I don't see that private(init) enum cases actually create a problem that's different in kind to the way access control already works in Swift.

3 Likes

I think there's a misunderstanding for the motivation behind private(init). It exists to (1) protect invariants and (2) guide users to not create those values ("these values should be treated opaquely"). It's not to say "you can't create this case", copying does let you do that but copying doesn't break invariants, so it's fine. Same goes for the Codable synthesis: in fact, you could make up arbitrary values by "fuzzing" the Decodable conformance, which is more flexible than copying a value you obtained from an API.

If you want to have API consumers not be able to get their hands on the particular case at all, then the right thing to do is to have two types, not one.


This example needs an @unknown default: on a inner switch for a warning when new cases are added (or default: for no warning when new cases are added), otherwise it's a compilation error.

That is correct; .child: will behave like .child(_), and _ corresponds to default:, not @unknown default:.

I agree, that's a good point. I'm a little bit surprised that it isn't already supported today; maybe we can make a smaller pitch and get this to work first? (I haven't thought it through fully, but I imagine there won't be too many complications, apart from maybe some syntax discussion on whether it should be @unknown or @unknown _ or something else.)

4 Likes

Yeah to be fair this is an existing problem :slightly_smiling_face: Your pitch just makes that problem more of a glaring omission, so would love to see it patched up!

+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.

6 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
Terms of Service

Privacy Policy

Cookie Policy