The main question is how this can be represented in the type system. I think the term “subtyping” is more appropriate here. This doesn’t necessarily have to conflict with potential inheritance, but yes, some confusion could arise.
I’m interested in your idea of how this integrates into Mojo’s semantics. If an enum type compiles down to a struct, does case struct mean that Mojo has to allow embedding structs into other structs?
Sure, I agree having some kind of sugar could be nice. But I’m not convinced it’s worth making this sugar specific to enums. People don’t declare enums very often, so needing to name each field is not a big deal IMO. If we have sugar, maybe it should be for something that is more general / has broader utility.
Specifically, I am wondering whether Mojo should have a feature akin to Haskell’s newtype feature, where you can declare a new nominal type that has the same API as an existing type. For enums, you would then be able to write something like:
enum Color:
# `RGB` is a new nominal type that has the same affordances as Tuple.
# It is NOT an alias.
case struct RGB = Tuple[Int32, Int32, Int32]
This feature need not be enum-specific:
struct UUID = Int128
Although Color.RGB would still need to contain enough room for a discriminant. So It wouldn’t be “just” a tuple of integers.
If you’re not convinced such a feature is worth adding to Mojo, then I’d say just declare RGB to store a tuple as an ordinary field. It’s really not that much boilerplate:
enum Color:
@fieldwise_init
case struct RGB:
var value: Tuple[Int32, Int32, Int32]
For further concision, hopefully at some point we can replace @fieldwise_init with a trait:
enum Color:
case struct RGB(Record):
var value: Tuple[Int32, Int32, Int32]
Regarding:
A lot of the opportunities I presented in my last post depend on this discriminant being available. Note that when I talk about the discriminant I don’t necessarily mean a separate tag field, I mean some kind of information embedded in the case struct that allows it to be “upcast” to the enum. (And then pattern matched on etc.)
The subtyping opportunity completely breaks down if the case structs don’t provide this guarantee.
In situations where you want the payload to have no discriminant, you can use the standard nesting trick: have the case struct wrap a separate payload struct.
Unless I’m misunderstanding you, this would force every function that takes enum values to become generic. That’s not going to be practical. It would make Mojo enums far more difficult to work with than (e.g.) Rust and Swift enums.
I’m not really sure what you’re asking. The compiler would translate this enum feature down to standard structs, and in the MVP the compiler doesn’t need to know anything about subtyping. But I’m not a compiler engineer, so I can’t really speak to the implementation complexity.
I’m not proposing the case struct syntax be nestable under ordinary struct definitions. But some day, we could explore that kind of direction. (It takes you down the road of predicate typing / refinement typing.)
As a reference of old proposed ideas, mainly related to syntax.
The result type would be the next interesting test for enums.
I think there are multiple aspects to consider.
First, how common are Tuple cases, that they would need special cased syntax.
Consider the following preferred syntax:
enum UpTo3Tuple:
case a: Int
case b: Tuple[Int, Int]
case c: Tuple[Int, Int, Int]
Now if we compare this with the first proposed brain dump of Chris ( which basically translates to, let’s copy everything from Swift ), I don’t really see a syntactic advantage here:
enum UpTo3Tuple:
case a(Int)
case b(Int, Int)
case c(Int, Int, Int)
So now the syntax or type narrowing discussion.
For match the syntax is settled because of the python precedent.
This now leaves us with if let (or if var) and alternatives.
I will intentionally ignore compiler complexity for now.
There are 2 decisions to make, we decide the compiler complexity is to high for now and leave the door open for later or we decide that the compiler complexity would be too high in general.
This leaves us with if var PAT = EXPR and if EXPR is/as PAT. There is a lot of room to bikeshed here, a discussion I would not participate in.
EDIT: I also have some concerns regarding the case keyword. I would prefer var, since this would leave the door open for demoting pointer with ref, imm and mut as sugar.
Note: My detailed feedback on the design of enum hasn’t received any attention from Modular yet.
Hi Nick, I’m sorry for the delay responding, thank you for the ping.
Why is this a goal? Your proposal is hard for me to understand, because it starts with an elaborate design, but doesn’t have a motivation section. The motivation is spread through the rest of the document.
There are significant problems that I see with it though, for example:
var highlight = Color.Red
...
if ...:
highlight = Color.Blue
Would fail to compile, because ‘highlight’ would not infer to being type “Color”, it would infer to being the type of the case. This would be very surprising to users, and attempting to correct this would add special cases and break composability.
I don’t understand the use-case for this - I personally haven’t experienced this need in the sorts of things I’ve programming in other languages. That said, I also don’t understand how this works. Pointer isn’t automatically covariant on its element type, we’d have to add implicit conversions etc to enable this. That would add a lot of complexity.
The design doc mentions anonymous cases and anonymous unions because they came up in previous discussions, but it also lists a number of significant problem they introduce. While they’re appealing, it is far from obvious to me that they justify the complexity needed to solve this. In this case, I can definitely see use-cases for them, e.g. it would be convenient to provide “typed bools” e.g. :
def set_light(state: .on | .off): ...
That said, while this is “handy”, this is “just sugar” for something like:
enum LightState:
case on, off
def set_light(state: LightState)
I am far from convinced it is worth adding complexity to support this.
Separate from that, the biggest concern I have is the fundamental design. By turning cases into types, you’re making another way to define a new type. Why do we want “another way to define a type” vs using the existing way we have to do this?
I don’t see why:
enum Color:
case Red
case Green
case Blue
@fieldwise_init
case struct RGB:
var r: Int32
var g: Int32
var b: Int32
@fieldwise_init
case struct HSL:
var h: Int32
var s: Int32
var l: Int32
Is better than:
@fieldwise_init
struct RGB:
var r: Int32
var g: Int32
var b: Int32
@fieldwise_init
struct HSL:
var h: Int32
var s: Int32
var l: Int32
enum Color:
case red
case green
case blue
case rgb(RGB)
case hsl(HSL)
The later has much better separation of concerns, composability, and stacks more cleanly on top of all of the other things that you can do with structs. I wouldn’t want to see trait conformances, requirement clauses and all the other decorators on enum cases: I’d much rather have “simple things that compose”.
Thank you for taking the time to write this up - it is an interesting design point and one that could certainly work, but I don’t think it is aligned with what we want Mojo to be.
-Chris
Nick and I both want this, so let me take another stab at motivating it.
Consider an actor system. In this actor system, each kind of actor has a set of messages that may be sent to it. These messages may contain a “payload”, data in addition to the information necessary to identify the message type. This makes the definition of the set of messages that may be sent to a particular actor map extremely well to a sum type. This means that there would likely be something like this:
struct Actor0:
def recv_message(mut self, var message: Actor0Message):
match message:
case MessageTypeOne(payload):
self.handle_message_one(payload)
...
If we aren’t able to treat the value of a case as a struct, then each message type requires the developer to write a unique struct so that the payload can be passed around as a group. While mildly annoying for a small number of cases, consider something like a “remote control plane” API for MAX, where an actor exposes the NVML API remotely to the cluster head node. That API has 380 functions, and writing out those structs as a human or as an LLM is going to result in a lot of boilerplate, and it will also result in a lot of invocations of decorators and reflection functions. Then at the enum declaration site, there needs to be a big set of highly repetitive “mappings” of case to struct.
While it is clearly technically possible to accomplish this without the feature Nick and I want, it results in what I consider some fairly bad UX due to the amount of repetition and namespace pollution it creates.
Thank you for the response Chris. I know you are very busy, so I appreciate you making time for this.
The “Opportunities” subheading is my motivation section. I should have made this clearer.
I don’t think this would be a problem. I addressed the concern in the following paragraph from my post:
In line with that, I would treat Color.Red, when used as a value, to be inferred as having type Color. Yes, this does require the construction of a case/case struct to have “special treatment”, but AFAIK you can explain this through an appropriate desugaring of the constructor call. It’s not particularly complicated, unless I’m missing something.
In OOP languages this feature is basically subtyping. So if you have ever used subtyping in OOP languages, I’d say you have experience with (approximately) this feature. I can put together some examples if necessary, but they all boil down to “subtyping can be useful”, when used carefully.
Yes Mojo will need either some form of implicit conversions, or support for subtyping. That’s why I’m listing this as an “opportunity” that we can pursue at some point in the (distant) future. If/when Mojo adopts classes, we will need to implement the exact same “type compatibility” rules that my Pointer example depends upon. This is not a coincidence: What I’m basically saying is that with a suitable design for enums, we can treat enum cases as being subtypes of the enum type. This keeps the language small, because we don’t need to implement a lot of enum-specific language features, we just need to implement some flavour of subtyping or type coercion.
Yes I did see anonymous sum types presented in the design doc. Despite the syntax looking similar, I consider them to be a completely different language feature from the case unions that I am presenting. So please do not mistake my case unions concept as a pitch for anonymous sum types. I am not interested in the latter feature; we are in agreement there!
The purpose of my post was to explain this. Clearly I have not done a very good job of that! All of the “Opportunities” presented in my post are consequences of my design, and AFAIK most of them are not achievable with your original design I am providing feedback on. I hope this is now a bit more clear.
Owen provides further motivation in his most recent post. ^
For this specific example, I would recommend checking out how swift actors work. Instead of “go style” manually written dispatch loops and pattern matching, you just define actor methods and the compiler does the work for you. It is /far/ less boilerplate than that you’re describing, because you get rid of all the match and dispatch logic entirely.
Swift concurrency has lots of weird issues, but this core design has worked well. Here’s a foundational doc I wrote about it back in 2017:
I haven’t seen many other examples where you want to do this sort of thing, and swift (for example) has had enums with broadly this structure and it hasn’t been a problem for people.
You’re right that there are other usecases that aren’t serviced well by enums, but that’s ok - we can have Variant types and other library defined structures as well.
Sure, I understand the broad category of feature space this lives in, but Mojo doesn’t have it outside of traits. If/when it does (eg when we introduce classes) we would have to define this, but we haven’t even done so for traits yet.
-Chris
Sure, I understand the broad category of feature space this lives in, but Mojo doesn’t have [subtyping] outside of traits. If/when it does (eg when we introduce classes) we would have to define this, but we haven’t even done so for traits yet.
I agree: Mojo doesn’t need subtyping in the short-term; we can think about it later. But my point is that if we want to leave open the possibility of modelling enum cases as subtypes some day, we need to design enums with subtyping in mind today, from the outset. This leads to the case struct model that Owen and I have presented.
Note: “Enum cases as subtypes” is not a risky/unproven concept. It has already been battle-tested in Scala. Since Scala 3 (2021), Scala’s enum cases are subtypes, and work almost exactly as I presented in my post. I actually didn’t know about Scala enums at the time I wrote my post, this is just serendipitous, but it provides evidence that my proposal is sensible and well-defined.
For example, here is Scala’s Option type. The main thing to notice is that Option.Some and Option.None are types.
You can add methods to Scala cases as follows:
enum Shape:
case Circle(radius: Double)
case Rectangle(width: Double, height: Double)
extension (self: Shape.Circle)
def diameter(): Double = 2 * self.radius
Here’s an example of using Shape.Circle as a type:
val circles: List[Shape.Circle] = List(
Shape.Circle(radius = 3.0),
Shape.Circle(radius = 5.0)
)
for c <- circles do
println(c.diameter())
@clattner I have a simplified proposal that is actionable immediately, and still preserves the future opportunities that Owen and I care about.
When you wrote your initial PR, you required case fields to be named. You later changed your mind. Here’s what you said:
I think there might be some confusion here, which is my fault. I wrote the proposal like this:
enum Token: case eof case identifier(value: String) case integer(value: Int) case source_range(start: Int, end: Int)But as I think you’re pointing out, tuples don’t have names on them.
In short: I think the syntax in your initial PR was the right choice! It’s just the desugaring we need to iron out.
Let’s repaint my case struct syntax using your original syntax. For reference, here’s case struct:
enum Color:
case Red
case Green
case Blue
@fieldwise_init
case struct RGB:
var r: Int32
var g: Int32
var b: Int32
@fieldwise_init
case struct HSL:
var h: Int32
var s: Int32
var l: Int32
We can repaint it using your original PR’s syntax as follows:
enum Color:
case Red
case Green
case Blue
case RGB(var r: Int32, var g: Int32, var b: Int32)
case HSL(var h: Int32, var s: Int32, var l: Int32)
That’s a lot more concise!
I’ve made one small change to your original syntax: I’ve added var in front of each argument. This seems important for a few reasons:
- It matches the syntax of field declarations in structs: we declare fields using
var, to indicate ownership. - It reflects the signature of
Color.RGB.__init__: the initializer takes ownership of its arguments. - It leaves open the possibility of supporting trait conformances on cases, e.g.
case Foo(var x: Int)(SomeTrait). The presence ofvarin the field list makes it clear to both human readers and Mojo’s parser that the first bracket introduces a field list, not a trait conformance list.
Some day, we might also want to use this var syntax as shorthand for declaring structs that have the @fieldwise_init decorator. This makes the case syntax “less special”:
struct Point(var x: Int, var y: Int)
var p = Point(0, 0) # fieldwise initializer is available
match p:
case Point(x=a, y=b): ... # pattern matching is available
For the enum MVP, this covers all of the syntax that Owen and I need as the foundation for a future “type-based” treatment of enum cases. If you were to ship this syntax (i.e. the syntax of your initial PR!) for the MVP, we leave open the possibility of (some day) pursuing the opportunities I presented in my feedback post, without needing to redesign the syntax and mental model of enums to accommodate it.
For the MVP, I don’t really mind whether each case desugars to its own type or not. You can implement whatever desugaring you already had in mind. My goal is just to have the MVP design be forward-compatible with the opportunities Owen and I presented. The above syntax sets us up for that. All I need is for the components of each case to be “field-like”, i.e. accessible by name, rather than being “Tuple-like”.
@anatolvitold Could you please split your proposal off into another thread? As best I can tell you don’t have requests that would change the basic implementation of enums, so this can be discussed separately to avoid clogging up this thread.
@owenhilyard - Not the basic enums. But I do have requests that would change the implementation of pattern matching to make it compatible with inductive types.