Description
Yet another enum proposal
First of all, what is the issue with const
? Why can't we use that instead?
Well first of all, iota
of course only works with anything that works with an untyped integer. Also, the namespace for the constants are at the package level, meaning that if your package provides multiple utilities, there is no distinction between them other than their type, which may not be immediately obvious.
For instance if I had my own mat
(material) package, I'd want to define mat.Metal
, mat.Plastic
, and mat.Wood
. Then maybe classify my materials as mat.Soft
, mat.Neutral
, and mat.Hard
. Currently, all of these would be in the same namespace. What would be good is to have something along the lines of mat.Material.Metal
, mat.Material.Plastic
, mat.Material.Wood
, and then mat.Hardness.Soft
, mat.Hardness.Neutral
, and mat.Hardness.Hard
.
Another issue with using constants is that they may have a lot of runtime issues. Consider the
following:
var ErrInvalidWeekday = errors.New("invalid weekday")
type Weekday byte
const (
Sunday Weekday = iota
Monday
Tuesday
// ...
)
func (f Weekday) Valid() bool {
return f <= Saturday
}
func (d Weekday) Tomorrow() Weekday {
if !d.Valid() {
panic(ErrInvalidWeekday)
}
if d == Sunday {
return Saturday
}
return d + 1
}
Not only is there a lot of boilerplate code where we define the "enum", but there is also a lot of boilerplate whenever we use the "enum", not to mention that it means that we need to do runtime error checking, as there are bitflags that are not valid.
I thought to myself. What even are enums? Let's take a look at some other languages:
C
typedef enum week{Sun,Mon,Tue,Wed,Thu,Fri,Sat} Weekday;
Weekday day = Sun;
This ends up being similar to Go's iota
. But it suffers the same pitfalls that we have with iota
, of course. But since it has a dedicated type, there is some compile-time checking to make sure that you don't mess up too easily. I had assumed there was compile-time checking to make sure that things like Weekday day = 20
were at least compile-time warnings, but at least with gcc -Wextra -Wall
there are no warnings for it.
C++
This section was added in an edit, originally C and C++ were grouped together, but C++11 has added enum class
and enum struct
which are very similar to Java's (next section). They do have compile-time checking to make sure that you don't compare two different types, or do something like Weekday day = 20
. Weeday day = static_cast<Weekday>(20)
still works, however. We should not allow something like this. #28987 (comment)
Syntax:
enum class Weekday { sun, mon, tues, ... };
Weekday day = Weekday::sun;
Weekday day2 = static_cast<Weekday>(2); // tuesday
Java
An enum is a kind of class. This class has several static members, named after the enum values you define. The type of the enum value is of the class itself, so each enum value is an object.
enum Weekday {
SUNDAY(), // parentheses optional, if we define a constructor, we can add arguments here
MONDAY,
TUESDAY,
// ...
SATURDAY;
// define methods here
public String toString() {
// ...
}
}
I personally like this implementation, although I would appreciate if the objects were immutable.
The good thing about this implementation is that you are able to define methods on your enum types, which can be extremely useful. We can do this in Go today, but with Go you need to validate the value at runtime which adds quite a bit of boilerplate and a small efficiency cost. This is not a problem in Java because there are no possible enum values other than the ones you define.
Kotlin
Kotlin, being heavily inspired by Java, has the same implementation. They are even more clearly
objects, as they are called enum class
instead of simply enum
.
Swift
Proposal #28438 was inspired by these. I personally don't think they're a good fit for Go, but it's a different one, so let's take a look:
enum Weekday {
case Sunday
case Monday
case Tuesday
// ...
}
The idea becomes more powerful, as you can define "case functions" (syntax is case SomeCase(args...)
, which allow something like EnumType.number(5)
being separate from EnumType.number(6)
. I personally think it is more fitting to just use a function instead, although it does seem like a powerful idea.
I barely have any Swift experience though, so I don't know the advantages of a lot of the features that come with Swift's implementation.
JavaScript
const Weekday = Object.freeze({
Sunday: Symbol("Sunday"),
Monday: Symbol("Monday"),
Tuesday: Symbol("Tuesday"),
// ...
});
This is probably the best you can do in JavaScript without a static type system. I find this to be a good implementation for JavaScript, though. It also allows the values to actually have behavior.
Okay, so enough with other languages. What about Go?
We need to ask ourselves, what would we want out of enums?
- Named, Immutable values.
- Compile-time validation. (We don't want to have to manually check at runtime to see if enum values are valid)
- A consise way to define the values (the only thing that
iota
really provides)
And what is an enum? The way that I have always seen it, enums are an exhaustive list of immutable values for a given type.
Proposal
Enums limit what values a type can hold. So really, enums are just an extension on what a type can do. "Extension" perhaps isn't the right word, but the syntax should hopefully make my point.
The enum syntax should reflect this. The proposed syntax would be type TypeName <base type> enum { <values> }
package mat // import "github.com/user/mat"
// iota can be used in enums
type Hardness int enum {
Soft = iota
Neutral
Hard
}
// Enums should be able to be objects similar to Java, but
// they should be required to be immutable. A readonly types
// proposal may help this out. Until then, it may be good just to either
// have it as a special case that enum values' fields cannot be edited,
// or have a `go vet` warning if you try to assign to an enum value's field.
type Material struct {
Name string
Strength Hardness
} enum {
Metal = Material{Name: "Metal", Strength: values(Hardness).Hard } // these would greatly benefit from issue #12854
Plastic = Material{Name: "Plastic", Strength: values(Hardness).Neutral }
Foam = Material{Name: "Foam", Strength: values(Hardness).Soft }
}
// We can define functions on `Material` like we can on any type.
// Strong returns true if this is a strong material
func (m Material) Strong() bool {
return m.Strength >= Hardness.Neutral
}
The following would be true with enums:
int enum { ... }
would be the type thatHardness
is based on.int enum { ... }
has the underlying typeint
, soHardness
also has the underlying typeint
.- Assigning an untyped constant to a variable with an enum type is allowed, but results in a compile error if the enum does not support the constant expression's value (That's a long winded way of saying
var h Hardness = 1
is allowed, butvar h Hardness = 100
is not. This is similar how it is a compile error to dovar u uint = -5
) - As with normal types, assigning a typed expression to a variable (
var h Hardness = int(5)
) of a different type is not allowed - There is a runtime validation check sometimes, although this can be ommited in most cases. The runtime check occurs when converting to the new type. For instance
var h Hardness = Hardness(x)
wherex
is an integer variable. - Using arithmetic operators on enums with underlying arithmetic types should probably either not be allowed, or be a runtime panic with a
go vet
flag. This is becauseh + 1
may not be a validHardness
.
Syntax ideas for reading syntax values:
Type.Name
- It's a common syntax people are familiar with, but it makes
Type
look like a value.
- It's a common syntax people are familiar with, but it makes
Type#Name, Type@Name, etc
- Something like these would make the distinction that
Type
is not a value, but it doesn't feel familiar or intuitive.
- Something like these would make the distinction that
Type().Name
- This one doesn't make too much sense to me but it popped in my head.
values(Type).Name, enum(Type).Name, etc
values
would be a builtin function that takes a type, and returns its enumeration values as a struct value. Passing a type that has noenum
part would of trivially returnstruct{}{}
. It seems extremely verbose though. It would also clash asvalues
is a pretty common name. Manygo vet
errors may result from this name. A different name such asenum
may be good.
I personally believe values(Type).Name
(or something similar) is the best option, although I can see Type.Name
being used because of it's familiarity.
I would like more critique on the enum definitions rather than reading the values, as that is mainly what the proposal mainly focuses on. Reading values from an enum is trivial once you have a syntax, so it doesn't really shouldn't need to be critiqued too much. What needs to be critiqued is what the goal of an enum
is, how well this solution accomplishes that goal, and if the solution is feasible.
Points of discussion
There has been some discussion in the comments about how we can improve the design, mainly the syntax. I'll take the highlights and put them here. If new things come up and I forget to add them, please remind me.
Value list for the enum should use parentheses instead of curly braces, to match var/const
declaration syntax.
- Advantage: More consistent with the rest of the language
- Disadvantage: Doesn't quite look as nice when declaring enums of structs
Perhaps changing the type syntax from <underlying type> enum ( values )
to enum <underlying type> ( values )
.
- Advantage:
int enum ( ... )
->enum int ( ... )
and similar become more readable and consistent with other languages. - Advantage: Ambiguities such as
[]byte enum ( ... )
get resolved to eitherenum []byte ( ... )
or[]enum byte ( ... )
. - Disadvantage:
struct { ... } enum ( ... )
->enum struct { ... } ( ... )
becomes less readable. - Disadvantage: (In my eyes) it doesn't illustrate how this enum implementation works quite as well.
Add type inference to enum declarations
- Advantage: Definitions become more concise, especially when declaring inline types with enums.
- Disadvantage: The concise-ness comes with a price to readability, in that the original type of the enum is not in a consistent location.
- My Comment: Type inference in Go is typically done in places which would benefit from it often, like declaring a variable. There really should be very few enum declarations "per capita" of code, so I (personally) think the verbosity of requiring the type is justified.
Use the Type.Value
syntax for reading enum values
- I've already talked about advantages and disadvantages to this above, but it was mentioned that we already use
Type.Method
to reference methods, so it wouldn't be quite as bad to reference enum values asType.Value
.
Ranging over enum values is not discussed
- I forgot about it when writing the original text, but luckily it doesn't undermine the proposal. This is an easy thing to fit in though. We can use
Type.Slice
which returns a[]Type
Regarding zero values
- We have two choices - either the first enum value, or the zero value of the underlying type.
- First enum value: Makes more intuitive sense when you first look at it
- Zero value of type: More consistent with the rest of Go, but may cause a compile error if the zero value of the type is not in the enum
- My Comment: I think the zero value of the type should be used. The zero value of a type is always represented as all-zeros in binary, and this shouldn't change that. On top of that, the only thing the
enum
"attachment" to a type does is limit what values variables of the type can hold. So under this rationale, I think it makes intuitive sense that if the enum for a type doesn't include the zero-value, then declaring a variable with the zero-value should fail to compile. This may seem strange at first, but as long as the compile error message is something intuitive (ieillegal assignment to fooVar: FooType's enum does not contain value <value>
) it shouldn't be much of a problem.