Enums as enum underlying types

I found myself in a situation where it would be convenient to have an enum be the underlying type for another enum. As far as I can tell, the only previous discussion on that topic was started by me 3 years ago, so that seems to be some type of enduring need for me. I'd like to be able to do something like this:

enum Foo {
    case a(Int)
    case b(Int, Int)
    case z
}

enum Bar : Foo {
    case a
    case b
}

The idea being that the enumerants of Bar are a subset of the enumerants of Foo, just like an enum Foo : Int is a subset of all the possible Ints.

Use cases include hierarchies of the kind that LLVM lets you do with its classof-based RTTI. You declare one large enum that has all the cases that you want to consider related, and you declare smaller enums that group those in related subsets. In a world with typed throws (has some consensus been reached on that topic? I'm out of the loop), this could be used to declare a large enum of all things that a module can throw, while tailoring smaller error sets for specific functions.

In the above example, Bar gets the a and b cases from Foo. Their payloads are implicit. No renaming allowed in this feature iteration.

Another strawman possible syntax could be:

enum Bar {
    case a(Int)
}

enum Foo {
    case z
    include Bar
    case b(Int, Int)
}

That may seem less backwards because you declare a once and get it in both enums, but that relationship gravely complicates the story if Bar can change from under your feet, as Bar is not allowed to add cases if it comes from another module.

Another syntax could be:

enum Foo {
    case z
    subgroup Bar {
        case a(Int)
    }
    case b(Int, Int)
}

Here, subgroup declares a type in the namespace that contains the enclosing enum, and creates the cases in both Foo and Bar. Bar's underlying type is Foo. On the other hand, that syntax is less powerful than the previous one, as it doesn't allow overlap between subgroups, and it can be implemented on top of one enum that uses the other enum as its underlying type.

Is this something that anyone else would use?

3 Likes

I explored ideas along these lines (as well as others) a couple years ago here: Value Subtypes and Generalized Enums, a manifesto.

I think the inheritance-based syntax is not the right thing to do here as the relationship is backwards from what most people seem to intuitively expect. You actually got it right here (aside from leaving off the associated types in Bar), but based on previous discussions I've seen most people want the syntax to be the other way around - saying Foo: Bar and then just declare the new cases.

The syntax I used in that gist is:

enum Sub {
  case one
  case two
}
enum Super {
  // Sub is defined elsewhere, it could even be in a different module if it is closed.
  cases Sub
  case three
  case four
  // Cannot declare cases named `one` or `two` because `Sub` already declares them.
}

I also showed an inline variant of the syntax:

enum Super {
  cases enum Sub {
    case one
    case two
  }
  case three
  case four
}

One obvious question is whether these enums would have a subtype relationship with the subtype implicitly converting to the supertype. You didn't directly address this in your post, but your use of inheritance-derived syntax seems to imply that.

3 Likes

People seem to expect that syntax to imply subtyping, but it is already well-established that for enums it actually specifies the raw value type. “enum Foo : String” is valid Swift, and it does not imply a subtype relationship.

That sounds like a reason to *allow* what is proposed here, so that people stop asking for the same syntax to mean something that does not make good sense.

Clearly not. “enum Bar : Int” is not a subtype of Int.

I disagree that this is a good reason to choose this syntax. My impression is that it is counter-intuitive to most people. That is a knock against it, not an argument for it. It also does not scale to some of the other things we might want to do with enums, such as allowing inline declaration of the subtype

Of course not in this case. It makes a bit more sense when limited to an enum supertype that just adds cases though. I was trying to infer whether @fclout was proposing subtyping or not. I may have guessed wrong. Either way, it’s not a totally unreasonable idea.

1 Like

It has been a constant in the public releases of Swift that in enum Foo : Int, Foo is a subset of the possible values of Int, and does not imply an inheritance relationship for enums. It's also how it works in Objective-C, C++ and C#, and probably in several other languages that otherwise define A : B as an inheritance relationship. I'm not looking to change that with a new feature.

There are issues with including enums in other enums (using the cases syntax) that are beyond what I'd be willing to tackle as a source change. For instance, if an enum is included in two larger enums, then the compiler has to do at least one of:

  • supplying a conversion function between the included enum and the larger enum that is more complex than a bitcast to the larger enum (for its rawValue); or
  • allocating discriminants to various enums by solving constraints on which enum is included in what other enum, which may not be possible if you get more than one enum from an external module anyway.

I feel that foo.rawValue is an acceptable way to get a value of the larger enum back, and there's an easy point to make that as for "upcasting" and as!/as? for "downcasting" are worth having too, regardless.

1 Like

Imho it would be really nice if we had a (optional) full separation of inheritance and polymorphism:
Protocols give us the latter without the first, but there is no inheritance without subtype relationship.

I don't know a single language that ever tried that, but it has some nice options for code reuse we can't have now - not only for enums, but also structs:

struct Person {
  var firstName: String = ""
  var lastName: String = ""
}

struct Customer inherit Person {
  var customerNumber: Int = -1
}

var customer = Customer()
customer.firstName = "John"
customer.lastName = "Doe"
customer.customerNumber = "3141592"

For enums, there could be a subtype relationship that allows polymorphism (only possible to remove cases), and inheritance which would create a completely new enum that can't act as its parent type, but has all of its behavior.
But imho we need to collect some real use cases first - and I can't bring up a real good one for enums...

That may be possible with somewhat costly ABI changes. However, it's a scary amount of work and bargaining, and I'm more comfortable working towards a feature that's about as small as it can functionally be. I think that this should be discussed in a separate thread, if people are interested.

There’s some discussion about whether this is subtyping. It’s not, and can’t be — at least as I understand what @fclout is describing.

In the code above, the values of Bar are not a subset of the values of Foo: all Foo.a have an associated Int, and the value Bar.a does not. Creating any subtype relationship between the two thus violates Liskov substitution.

Absolutely, that’s why my original comment said “aside from leaving off the associated values”. Without the associated values the relationship between Foo and Bar doesn’t make sense to me at all.

Whether there is a formal subtype relationship or not I would expect the pitched syntax to mean if I have a Bar I can get a Foo either via a a subtype relationship with an implicit conversion or via a rawValue property. For this reason I (perhaps incorrectly) assumed the omission of the associated values in Bar was accidental. If this was not accidental I would like @fclout to elaborate on exactly what is intended here.

I should have been clearer about that part: I imagined that the payload would be inferred from the original enum, because I can’t think of any good reason for duplicating it.

Enum cases can’t be overloaded, so this is analogous to referring to a method using Foo.bar or Foo.bar(arg:); it’s the same if there is no overload of bar.

SE–0155 says otherwise

I stand corrected. Thankfully, it’s trivial to salvage: it works as long as there is no ambiguity.

If we were to have this feature, I'd really want sub-cases to be able to be part of multiple sets at once.

enum Foo { case a, b, c, d }
enum BarAB: Foo { case a, b }
enum BarAC: Foo { case a, c }

So the syntax proposed by @anandabits in the message above (or the subgroup alternative syntax proposed in the original post) wouldn't work for that, as it would assume cases can only appear in one subgroup of the enum

Some potential use cases for that include some classification of data which, depending on the context and on the functions, would only make sense for only some subsets of the enum cases:

enum ItemType {
  case list([String])
  case dict([String: Any])
  case text(String)
  case number(Int)
  case date(Date)
}

extension ItemType {
  subenum Enumerable { .list, .dict }
  subenum Countable { .list, .dict, .text }
  subenum SingleValue { .text, .number, .date }
}

func printAll(item: ItemType.Enumerable) {
  switch item {
  case .list(let a): a.forEach { print($0) }
  case .dict(let d): d.keys.forEach { print($0) }
  // this switch would be considered exhaustive
  }
}
func getCount(item: ItemType.Countable) -> Int {
  switch item {
  case .list(let a): return a.count
  case .dict(let d): return d.count
  case .text(let s): return s.count
  // this switch would be considered exhaustive
  }
}
func printSingle(item: ItemType.SingleValue) {
  switch item {
  case .text(let s): print(s)
  case .number(let n): print(n)
  case .date(let d): print(DateFormatter.localizedString(from: d, dateStyle: .long, timeStyle: .long)
  // this switch would be considered exhaustive
  }
}

Thoughts?

I agree that overlap between sub-enums is useful. One thing to keep in mind, though, is that static typing may get in the way of your use case. If you know that an item is Countable, you can’t infer that it is Enumerable, for instance.

Yeah true, tbh I came up with the example on the go without thinking any further; as you point out, Enumerable is in fact indeed a subset of Countable, and for that use case we might actually want multiple levels of subenums, like make Enumerable a subenum of the Countable subenum itself, if we actually want that explicit relationship indeed

This kind of hierarchy / nesting works very nice in the cases syntax I posted above when expanding to support inline enums.

enum Super {
  cases enum Sub {
    case one
    case two
  }
  case three
  case four
}

It also works very well when you have more than one sub enum:

enum Super {
  cases enum Sub1 {
    case one
    case two
  }
  case three
  case four
  cases enum Sub2 {
    case five
    case six
  }
}

The inline syntax cases Sub would naturally be used when the type itself is nested. In other cases the cases Sub syntax would be used to reference an enum declared elsewhere.

I'm not sure I see how your syntax would address my original concern about the same case being able to be in multiple subenums at once like in my example above? Could you show how that would look like with it?

Because if I understand your syntax correctly inside your nested sub enums you declare new cases, you don't reference existing ones…

Or are you saying your syntax would allow both and be the same for declaring new cases and referencing existing ones?
If so, that seems error prone to me as a typo in the name of a case that was intended to be a reference to an existing case would instead be interpreted as a new case because of the typo instead of producing a compilation error…

You're right, it wouldn't, I read the previous posts a bit too quickly and missed that there were overlapping cases in the sub enums. It seems to me like that could get complex pretty quickly.

Trying to represent all possible relationships of cases would quickly lead to the request for "multiple inheritance". You haven't brought that up explicitly yet but it is a logical next step, and is already implicit.

If you think of this in terms of OO inheritance, the analogue is that each case is a leaf class with no subclasses. The OO analogue of an enum is a superclass that has a subclass for each case (or sub enum). So what you are asking for with overlapping cases is already analogous to giving a class more than one superclass, i.e. a: BarAB, BarAC.

I would want to see pretty compelling evidence that the complexity required for this degree of flexibility is warranted before I would consider supporting it. That is why the design I wrote up does not handle this use case. Keep in mind that protocols are still available.

It looks like exhaustiveness checking in switch is part of what you are interested in here. If Swift supported closed protocols you would be able to switch exhaustively over existentials of a closed protocol type (and generics constrained to a closed protocol).

1 Like

I see your point. But tbh I was more seeing this as composition and with a parallel to protocol conformance more than a parallel to inheritance.

Also because one would just opt-in to a subset of cases than inherit a list of cases from a super enum, but also because we don't want the diamond problem

See it more maybe like one case would be similar to a protocol with only one static func (the enum constructor), and subenums being typealiases to composition of multiple of those protocols, if that makes more sense? (Even if I'm not sure the comparison is really relevant or the best one…)

The analogy of enums to protocols that I am familiar with is that an enum is analogous to a closed protocol and a case is analogous to a type that conforms to the protocol. I'm not sure how to make the analogy of a case with a protocol work.