Enums in Go
dizzy.zoneThe problem isn't so much the lack of enums, it's more that there is no way to do exhaustive pattern matching at compile time. I have seen production systems go down because someone added a variant to an 'enum' but failed to handle that new variant everywhere.
There are two linters that add those checks: https://github.com/nishanths/exhaustive for values and https://github.com/alecthomas/go-check-sumtype for types. Both are integrated into golangci-lint. I use both a lot and have only a positive experience.
Having support from the language would be nice, though.
Yeah, it makes a huge difference whether this is the default, and that's not a Go thing, it's not a programming language thing, it's the whole of human existence.
Literacy is an example. We're used to a world where it's just normal that other humans can make marks and interpret our own marks as to meaning. A thousand years ago that would be uncommon, ten thousand years ago vanishingly rare.
I really celebrate tools which take an idea that everybody agrees is a good idea, and bake it in so that everybody just has that, rather than agreeing it's a good idea but, eh, I have other things to do right now.
And it annoys me when I see these good ideas but they're left as an option, quietly for a few people to say "Oh, that's good to see" and everybody else misses out, as if "Literacy" was an optional high school class most of your peers didn't take and now they can't fucking read or write.
Example: Git has a force push feature, necessarily we can (if we have rights) overwrite a completely unrelated branch state, given any state X, now the state is our state Y instead. This isn't the default, that part is fine... Git also has "force-with-lease". This is a much better feature. Force-with-lease says "I know the current state of this branch is X, but I want to overwrite it anyway". If we're wrong, and X is not (any longer perhaps) the current state, the push fails. But force-with-lease isn't the default.
[Edited to fix clumsy wording in the last sentence]
These both look like great tools. I'll give them a spin!
The overarching lesson of my career has been that people are fallible and processes that rely on humans to not make mistakes are bound to fail. Recently I've also been thinking about the importance of being able to reason "locally" about code, it might be the single most important property of a software system. "locally" typically means a function, class, or module, but I think it can also be applied to "horizontal" cases like this. For example, if you add an enum variant the compiler should guide you to reason about each place where the enum is no longer exhaustively matched.
In theory yes. But practically there are always locations where we can't match every case, so we either have to live with a warning or add a catch-all arm. And as soon as a catch-all arm exists, we are in "not checked any more" state, but with a compiler that is supposed to check for exhaustiveness. Which is way worse if the catch-all arm isn't a `panic("Unhandled match branch!")`.
Yes, you can counter that with GADTs and match against exhaustive subsets. But to ergonomically handle these cases, you need something like Pattern Synonyms or you drown in boilerplate.
You don't have to add a catch-all arm, you can still explicitly match i.e
now when you add C you still have to match it(given the enum is exhaustive).match value { A => { do_stuff() }, B => { unreachable!("B is not applicable here because <reason>") }, }Yes, I see, I should have mentioned that.
Same as above: this solution stops working as soon as you're handling 5 out of 50 cases (or, more realistically, 10 out of 200). Lexical tokens are which always trigger the mentioned problems in my code - often you match against subsets, as there are _way_ too many of them to add them all explicitly.
First of all, I think there is a problem with your design if you have 50 or 200 cases. Most of the code I have seen -- in any language -- have no more than 10 cases, rarely 20. Maybe that is the issue to look at first.
Then, this "limitation" is not an argument for not running the exhaustive check. In the vast majority of cases where there are about 5 enum entries and most cases need their own path, they would be explicitly written out (i.e. no _ =>), and this works extremely well. I have had good experience, and I believe other people can attest this.
If you only have 1 case matched and everything else goes in _, later needs to add one case, you just do that, likely in every other language, there is nothing that can help. But what's described above is already a big improvement.
> this "limitation" is not an argument for not running the exhaustive check.
That's not what I wanted to express. What I wanted to say is, that even when using Haskell, which has all the possibilities to actually handle matches of subsets quite ergonomically, you can't be sure that there isn't at least one čase which isn't caught by the exhaustiveness checker (and sometimes it's just wrong, but then we're not talking about enums). So you always have to check all manually, but the checker makes that easier.
This is true, but in my experience these are edge cases and explicitly matching everything works well 90% of the time.
This is actually the problém. I'd even say that it works like 95% of the time, that's why people (of course, I don't make such silly mistakes ;) aren't used to check where it matters. In reality the policy must be to always (whether there are or aren't working exhaustiveness checks) manually check each and every occurence when adding. Don't get mé wrong, I prefer having exhaustiveness checks, but they make the manually searching a bit less tedious, not alleviate it in total.
As long as the set is limited (as enums usually are), you can always go with the middle ground between omitted case and catch all:
This should still catch adding new value into the enum and not handling it.case FILE_NOT_FOUND: /* This is normal, nothing to do. */ break;I should have added that this solution stops working as soon as you're handling 5 out of 50 cases. Lexical tokens are which always trigger the mentioned problems in my code - often you match against subsets, are there are _way_ too many of them to add them all explicitly.
Isn’t that the first thing you would do if you add a new variant?
Only if you remember, which you, or someone else, is bound to not eventually
But you're not adding variants without a reason? You want them to have some effect.
It's hard for me to think of an example where it would make even sense to "having to remember to handle the variant" rather than "handling the desired effect of the variant".
People are stressed, get distracted, are tired, don't have complete knowledge etc. This is kind of like arguing that null pointers aren't a problem, you "just" have to check all usage of the pointer if you make it null. In practice we know solutions like this don't work
added that every switch/if should handle this exhaustively. For any project with more than a few dozens of files, it is basically impossible to remember all the downstream code that uses the enum -- you have to track it down, or better, let compiler automate check all usages
Huge codebase. It was handled in 13 places, but they missed the 14th.
I'm implementing a somewhat complex software in both Go and Rust.
The Rust version is bith shorter and more readable - and probably more efficient - thanks to Rust enums and Rust error handling. I don't understand why golang doesn't copy Rust here. The error handling in particular could be a very simple change.
I am not a huge fan of go:generate and similar projects. They add a level of unknown that goes against the core Golang design values.
> I am not a huge fan of go:generate and similar projects. They add a level of unknown that goes against the core Golang design values.
I'm not sure I would agree with that. go:generate is a core part of Go since v1.4 and the enum generators are the kind of thing that was intended. https://go.dev/blog/generate
That said, enums would be a welcome improvement to the language. But even then, I think go:generate has a place.
"add(ing) a level of unknown" is a very fair description from a code comprehensibility standpoint, since the go:generate directive is about running arbitrary executables.
The is ameliorated somewhat by the fact that go generate is not part of the build process. Its output would typically be checked into the repo. So as long as the generated code is reasonably readable, it should not have too much of an impact on code comprehensibility.
True.
go:generate is mostly just a marketing failure. If they'd called it 'procedural macros', everyone here would think it was the bee's knees.
To be honest, I'm not a huge fan of Rust's #derive() magic either.
I’ve found the enumer [0] library does the job of generating usable helpers without too much pain or any obvious downsides. The ability to generate JSON [un]marshallers is particularly handy.
Still, the lack of enums and enum/sum types remains by far my biggest gripe about Go.
The posted article mentions https://github.com/abice/go-enum, which does the same thing, no? It even generates the consts based on a list of values in a comment.
It does indeed seem to be similar, and viable option, with the difference being that the enum values are specified in a comment. You seem enthusiastic about that design decision/feature, howeever I am not sure about it, it scares me a little.
It's absolutely just a hunch and personal preference but I worry that keeping the enum value inputs in comments might not be great for new contributors and in terms of maintaining the codebase over time, so I think I prefer the approach taken by enumer.
Four candidates mentioned so far in the HN comments so far. Here's 45?
https://github.com/search?q=enum+generator+language%3AGo&typ...
https://github.com/search?q=enum+generation+language%3AGo&ty...
Enums in Go are not good. Code generation just makes it worse. Both because code generation has a bad smell and because nobody can agree on how to do enums in Go so we just end up with lots of diverging solutions.
Go 2 needs to have more usable enums. And while I'm not a big fan of "adding more stuff" to languages, it wouldn't hurt Go to learn a couple of things from Rust.
I can't see why they wouldn't be in a future 1.x release
I guess it depends on how much of a departure from the current language it would be and if that introduces complicating factors. It would make sense to seriously consider adding matching (like in Rust) but then you might want to consider more than just enums?
I'm not a language design expert, but I suspect there be worms in that there can.
I recently wrote a little tokenizer in Go.
The data structure for a token that makes most intuitive sense to me is a tagged union.
So I defined an „const iota“-style enum. Stuck it into a struct that has the appropriate fields to cover all the cases and it was fine.
Having some syntax sugar for tagged unions would be nice. Having exhaustiveness checks if you switch over them, could be useful in some cases.
But that’s not where my mental energy went at all.
Reading the bytes (runes) efficiently and correctly into the data structure however is the part that needs focus. Once the data is in shape, I‘m not „worried“ at all anymore. Sure a bit of extra support is nice, but also kind of superficial.
Also going further, dispatching on them is again never the tricky part. It’s handling the cases correctly and efficiently that has my focus.
In Clojure, a common thing is to write multimethods that dispatch on (namespaced) keywords. Similar in spirit and structure, but each method might now reside in a different namespace or not even be written by you. But I have never worried about exhaustive matching or similar. What’s in the method bodies is the important part.
I really miss value enums from Rust while working with Go, but overall I find Go more intuitive. Didn't know there was a way of auto-generating with stringer, so thanks for the information!
Besides the iota dance hack of "enums" in Go, apparently Go 1.23 is going to bring another one, magic fields.
https://pkg.go.dev/structs@master
What more sane languages would use attributes for, Go 1.23 will do it like this,
type myStruct struct {
_ struct.HostLayout
}
Lovely design.I wonder if go ever will get some sort of enum type? Or if int/string based types will be the goto way?
If there is one thing that I find missing is proper enum support in the Go language.
There are quite a few subtle caveats you can run into without proper support in the language like the article mentions. (E.g. Using iota, passing in an undefined enum)
This might be a very simple and ignorant thought, but I'd be fine if they completely worked the same as Rust's enum's. [if let] is such a powerful feature, along with match arms.
Rust got those basic type system and pattern matching things so incredibly right it’s not even funny. I even like Go more for other reasons but I still cringe every time I have to use the error prone hacks mentioned in this post. Not to mention the largest source of noise: `if err != nil`, which Rust solved so elegantly using partly their enum types.
That’s not an enumeration like the parent and grandparent are discussing (think of a collection of consts), that’s a tagged union you’re talking about. Confusing because rust used the name enum in their syntax for this (Haskell calls it data - well, Haskell uses data in their syntax for both product and sum types).
What we're discussing here is always an actual sum type though, it's just a question of whether you're interested in half-assing it, and Rust is not.
There are people who want C's "surprise it's actually just an integer" which lets them use C's enums as bit flags, and that I agree isn't just a sum type, but the fact that Rust will let you sum things which aren't units isn't that this is "really" a tagged union, that's a possible implementation detail but not the core idea.
This is not C++ std::variant which really is just a tagged union. Option<OwnedFd> isn't a tagged union, it's "just" much nicer sugar for C's signed integer type. Option<&str> isn't a tagged union it's sugar for a fat pointer which can be null. And so on.
Sure, but "the same as Rust's enums" including a mention of pattern matching, is a really big expenditure on the novelty budget from the point of view of Go. What Rust does there is perfectly normal... for an ML. But before Rust you didn't really see an ML down there counting CPU cycles, so this wasn't even on the radar when Go was invented.
I think to do that you'd probably give up a lot of the simplicity Go is aiming for. I personally think that simplicity is somewhat illusion (Amos' "I want off Mr. Golang's Wild Ride" https://fasterthanli.me/articles/i-want-off-mr-golangs-wild-... says it better than I could) but we should be clear that it's not that Go aimed to do what I want and missed but that it was never interested in that at all. An F1 car doesn't want to be useful for taking the kids to school, so it's silly if we're scoring it poorly for lack of child seat fixtures.
> Sure, but "the same as Rust's enums" including a mention of pattern matching, is a really big expenditure on the novelty budget from the point of view of Go.
Following the addition of type sets for generics I think go actually has all the pieces for union types already and it’s a matter of putting them all together at the compiler level:
- allow type sets as types (currently they’re only valid for constraints), probably excluding those using “underlying types”
- implement match completeness for type switches over type sets
And there you go, you’ve got unions from which you can easily implement sums via type declarations:
Is this perfect? Not even remotely, this suffers from the usual Go issues of zero values, unenforceable constructors, and nil interfaces.type Foo int type Bar struct {} type FooOrBar interface { Foo | Bar } func Thing(v FooOrBar) { switch vv := v.(type) { case Foo: // you have a foo case Bar: // you have a bar // a default case is required if the cases are not exhaustive, forbidden if they are } }But these are issues of the language, they should be fixed in the language in a hypothetical Go 2, I don’t think there is a good reason to try and work around them here.
Also completeness requirements could probably be extended to all “trivial” switches (types or values, not generalised expressions) via a go.mod stricture, similar to the new loop semantics.
Use a slice of strings?
type Color int
const (
Red Color = iota
Green
Blue
)
var Colors = []string{ "Red", "Green", "Blue" }
Now (Colors[Red] == "Red") and (slices.Index(Colors, "Green") == Green).You could do the same with a map[Color]struct{} and the lookup would be "if _, ok := Colors[Green]; ok {"
If you add an enum entry but forget to update the slice, BOOM
And that's exactly the kind of thing people are discussing here.
Compile-time safety is not achieved which is the point.
It does not exist in the implementation. We can discuss workarounds which make it more convenient and directly address one of the points in the article, or we can discuss something which is definitely not going to change in the lifetime of the language.
There is no language that completely isolates you from runtime hazards.
+1. Here is another alternative. I find this generator more lightweight and better syntax (generated benchmarks and tests are also nice):
Oh, the missing wonders of `[...]`: https://go.dev/play/p/GlVp_z3IOEe
I can't take this language seriously. No enums, letter cases defining if something is "public" or "private", generics as an after-thought. To name just a few
To be fair, generics was an after-thought because Go was originally a "small" language used internally at Google, and they needed to ship the language in a working state. Generics was too complicated to handle for the first versions (according to Russ Cox). If you look at Java, generics didn't exist until a few major versions in.
Although I agree it probably should be done quite a bit earlier.