Safer Enums in Go
npf.io
npf.io
PLT and PL Design don't live in vacuum, it's a vast field that has been developing exponentially in the last years (just like any other CS discipline).
Any development in Haskell, Rust etc... Or other relatively mainstream frontier languages are fundamentally relevant to any other language's design. Period. All this bickering about "Rust in every thread" is excruciatingly misguided. Lessons we can learn from Rust will be highly influential on other languages and it's worth discussing these. Please keep this in mind before complaining for the 999th time.
Is my comment any better? No probably not.
Every programming language is an experiment: it tests whether an well-defined, particular model of computation ends up having advantages on human engineering results. Maybe it causes fewer bugs. Maybe it makes development faster. Maybe it makes certain features easier to implement.
So whether one language has a feature that's highly missed in other languages is the very basis of the debate.
I personally am not a Rust programmer, but not knowing Rust, but knowing another programing language that has sum types, it seems understable to me that it worths mentioning this for every language that doesn't have sum types. And when you do do that you might as well mention Rust as an example. (Maybe because it's more mainstream?)
Personally I think people can't help but notice the trend of Go users rediscovering bug-by-bug and frustration-by-frustration why Rust was designed the way it is.
The article does not even mention Rust. Not every attempt to make something 'safer' is an attempt to make it 'fundamentally more Rust-like'. (And you're also misusing the word "fundamental" - even if we grant this is "Rust-like" this is adapting their code to make their Go programs practically more Rust-like.)
You gotta give the people what they want!
Particularly, decryption should be Authenticated, and so the correct return type of a decrypt function is a Sum type, and if you don't have Sum types you can't do that.
Hah, I agree. I switched over to F# and never looked back. It plays nicely enough with C# that my colleagues couldn't stop me (but didn't join me either).
> But there is always an article about go that makes you appreciate what you do have.
Yes. Go seems like a time machine back to about 1985.
MS seems committed to F# as at least a source of future C# language enhancement ideas (although C# will never fully catch up). There’s also a vibrant F# community on Stack Overflow, Reddit, etc. I never have trouble getting help if I need it.
It has "enumeration types" but their behaviour is exactly that of C's, with various additional conveniences but critically (and unlike Java for instance) no more type-safety.
Rust's popularity is certainly helping that, though I wish they hadn't repurposed the existing word "enum" to serve as a new synonym for a concept with an existing name ("algebraic data types").
A step further: I was hearing talk of enums being a code smell and that they should all be replaced with interfaces as they represented alternative implementations and you might want to be more extensible or insert a mock for testing
As an aside, tagged unions weren't a big deal to me (though I thought it neat to merge tagged unions with enum constants) but what blew me away was when I learned they could have methods and implement traits. Would love it if Rust took it a step further and made the variants types. There are cases were id like to have builder methods on variants. The closest I can get is having an explicit struct and implementing From to the enum. Also wish there was variant visibility so I wouldn't have to wrap my enums in structs to hide the variants.
(It is also true that structs are products. But that isn't relevant.)
You're definitely in a bubble.
What keyword would you prefer they use instead, "datatype" like SML?
Is Rust actually popular? I know that question is inviting downvote oblivion on here but on the off chance of attracting a thoughtful comment (and because I secretly want to really fall for rust, i love the idea and think we badly need the next step on from C-based foundations, but the pragmatic side of me just rolls around laughing at rust) here’s where i’m coming from:
- There’s not much in the way of job postings
- There’s no halo product yet, servo appears to have gone nowhere? If it makes it into Linux *that* will launch the lang for sure
- There’s no halo company championing it, mozilla has never moved away from churning out c++ and js primarily, microsoft made some noise but then crickets
- it’s been around about as long as Go yet its google trends search traffic is around 1/16th that of Go’s
- The only large rust code bases on github are servo and the rust compiler/stdlib itself
The only metric i can find where rust scores well is the stack dev survey and the number of tutorials posted online. If there was actual traction alongside these two metrics, rust would be a rocketship. But without any obvious successes in the world so far, it’s beginning to look like a handy way to identify devs susceptible to stockholm syndrome.There’s all sorts of weird signs in the community that makes me think the median rust “user” has a rust career something like:
1. Downloaded rust
2. Installed the wrong plugin in VSCode (why is the correct plugin - rust analyzer - so low on downloads and votes, yet the dead plugin is still getting tons of traffic? Is noone actually reading the documentation?)
3. Followed a hello world tutorial, marveled at the genuinely awesome compiler errors
4. Full of enthusiasm, started a side project and… gave up after a few nights but who knew, they wanted to solve a problem not add lifetimes onto the existing stack of problems to wrestle with.
AFAICT the future’s not looking bright for rust right now. It’s coasting on a wave of enthusiasm from relatively junior developers who aren’t making impressive things with the language yet.This is a strongly typed language with lifetimes understood by the compiler - that’s a rocket suit compared to a language like the tire fire that is javascript. Enums? Pah excuse me while i pass a string literal…
With this rust exo-skeleton surrounding a developer, it should be unlocking the ability to manage 10million+ LOC code bases like it’s hello world.
My personal belief is that over time Rust will inevitably eclipse C and C++. But it will take time. C and C++ will never fully go away, but projects that would have been written in them will be over time be preferentially written in Rust. The Linux kernel starting to write drivers in it is a harbinger of this, and once that effort has made it into released kernels we'll quickly start to see other parts of the kernel adopt Rust.
> AFAICT the future’s not looking bright for rust right now. It’s coasting on a wave of enthusiasm from relatively junior developers who aren’t making impressive things with the language yet.
Frankly this is my perspective of Go, which is (from my vantage point) mostly popular amongst junior developers. Rust is not even remotely marketed towards novices, and in many of the communities it's openly acknowledged that it's perhaps not the best choice for someone just setting out. C and C++ programmers are the ones primarily flocking toward Rust, while Python, Ruby, and Node developers are the ones who are primarily adopting Go as they're starting to see the value of static types, even as anemic as Go's type system is.
Anecdotally I know many, many engineers who have grown disillusioned with Go once they've worked on larger projects and had to repeatedly reinvent wheels that other languages provide as built-in features. Many of those engineers have gone on to champion Rust, and the flow of engineers between those two languages from my point of view heavily favors Rust.
But without a big champion like Microsoft, Google, or Sun/Oracle, this growth is going to happen organically and take time. Maybe there will be an enormous driver like Kubernetes for Go, Rails for Ruby, or ML for Python, but even without that I think Rust will gradually continue to snowball.
But it hasn’t become popular yet, it’s not competing with something like Go or C++, it’s competing with Erlang or Clojure for popularity
https://madnight.github.io/githut/#/pull_requests/2021/4
P.s. thumbs up, that was a thoughtful reply! But the predictable down votes arrived too :-)
Or do you think anyone would care if it was some random joe/jane posting about this Rust language they were developing.
Thank you!
It's about 300loc to implement and then sum types can be defined declaratively just like any other type (and without any boilerplate).
There hasn't been any fanfare about this capability because it doesn't require any special syntax or new keywords. Just know that the suggestion that "Go 1.18+ doesn't support sum types and/or enums", is very wrong.
https://github.com/qlova/tech/blob/c6379c9c32e5b7b2973bc02ba...
https://github.com/qlova/tech/blob/c6379c9c32e5b7b2973bc02ba...
That's a big difference from other languages where this is all handled at compile time.
More and more people will use Rust and Typescript and realize what a joke go's type system is compared to those. But you don't need to go as far as those languages have -- lacking sum types, exhaustive switches, discriminant unions, proper enums, unchecked nils, etc is just lazy. Most reasonably sized software projects end up having some ad-hock crappy workaround to any one of these issues.
After years of waiting for generics. We got the most stunted and simplistic implementation of generics imaginable. You can't even do something as simple function chaining.
You make this sound like something got lazy about rather than something with major implications for the language semantics/runtime. Serious question: Would you prefer a) the current generics implementation, b) a runtime capable of code generation, c) generics cannot participate in interface implementation, d) some other option?
Even if it’s a silly bug to make, it really annoys me a lot knowing you could change an enum value. Is it so hard for Go to support consts of any-type?
1) I don't need you to prevent me from mutating this variable, just trust me to not mutate it.
2) I don't need you to ensure I handle every case of this enum, just trust me to update all the relevant code whenever I add a new case.
3) I don't need you to prevent me from reading the result without checking the error, just trust me to always handle errors.
4) I don't need you to track nullability in the type system, just trust me not to dereference null pointers.
5) I don't need you to let me hide the default constructor, just trust me to always use the smart constructor that establishes the invariants that I need.
6) Until recently: I don't need you to let me abstract away this type, just trust me to keep all the copies of the function/type in sync.
This philosophy may make sense for a low-level language like C, but not for a high-level language for building applications, services, etc. It amazes me that people voluntarily give up the guarantees of other languages in favor of Go for these use cases. It's almost as if the language wants bugs to slip into your code.
Even for a low level language like C it really doesn't, it makes the implementation easier but all the things you mention (and more) are useful both to ensure the foundations are solid (security, correctness) and to make it fast: the weak type system and feeble guarantees of C are why compilers lean so much on UBs to infer constraints they can then optimise based on.
Edit: https://github.com/nishanths/exhaustive is available in golangci-lint
That's wrong, otherwise dynamically typed languages like Python wouldn't be so popular.
Python with types, on the other hand, is pretty great for catching bugs before runtime.
At least they accepted some Oberon-2 influence as well.
But lack of real enums for me falls into the "How is this a thing?" bucket.
Like Java introduced what are (IMHO) a pretty good enum almost 20 years ago. Java enums are classes, basically. They can have state and methods. Super-useful.
I like Hack but Hack's enums are a little weird in the sense that you have a bit of an odd syntax to indicate if automatic coercion is allowed.
I like the idea in this post as being better than straight strings or ints but there are still 2 downsides:
1. As a variable (not a constant) it gives the compiler less options on how this can be optimized; and
2. (This is the big one) One of the best things about enums is using them in switch statements such that adding a new value will cause a compiler error (assuming you don't use a default branch, which should be discouraged for this).
Rust enums and match expressions, for example, cover all of this.
type MySumType interface {
sealed()
}
like in the code example for go-sumtype, serialization is easy enough: type MySumType interface {
json.Marshaler
sealed()
}
But deserialization runs into a roadblock, because you cannot implement interfaces like json.Unmarshaler on an interface type. Something like type MySumType interface {
json.Marshaler
json.Unmarshaler
sealed()
}
will not work: Given `var s MySumType`, json.Unmarshal() will croak because it cannot call UnmarshalJSON without knowing the concrete type of `s`. So for deserialization, you'd need an extra wrapper type that provides the json.Unmarshaler implementation.In addition to introducing the wrapper type, this could also be fixed by using a JSON decoder implementation that supports type/location hooks. In other words, this is more of a limitation of the custom unmarshaling mechanism chosen by the builtin encoding/json package.
You'll probably need something along those lines either way, for the simple reason that there is no single mapping between sum types and JSON (or equivalent / similar)
For instance serde provides 3 different serialisation schemes, and once in a while you still need a bespoke deserialiser.
I'd argue that needing this enums safety so bad that you go out of your way to create a struct with a protected field also lies in the same category of something you need that is mostly in the theoretical realm too.
Don't get me wrong, sometimes you want that kind of protection indeed and it's easy to mess up if you need to add validation everywhere. However, most times you can get away with just rethinking your code.
More importantly, I think the downside of using variables rather than constants makes it less safe than the alternative of relying on you always having to do validation.
It can cause headaches in test code (where developers try to override the package variable value for test cases, and forget to restore/change it back when done, etc., i.e. they engage in bad practices that I shouldn't have to catch in a PR). It can easily cause production applications to blow up unexpectedly when the variable is changed in a function and that mistake passes through all the tests and the PRs. This practice is one of the worst in the go community, in my opinion.
The first time I saw these package level globals in go code I cringed. It is an absolutely terrible design decision to not have package level immutables that aren't PODs.
FWIW here is a proposal to expand const in Go: https://github.com/golang/go/issues/21130
I hate that go doesn't have enums, I hate that it doesn't have tagged structs, but if I spend my time trying to massage the language into simulacra of them, I'm losing time that I could spend writing productive code, or I muddle the waters for future developers that will have to deal with those abstractions in the future. Overall I think it's not worth it, outside of highly theoretical implementations like this, that can exist independent of code that actually does things.
Eh yeah, but if you’re writing a library you have to assume someone will do it, because it’s possible to do. Which makes it a non-starter IMO.
var Animal = struct {
Cat string
Dog string
Dodo string
} {
"Cat",
"Dog",
"Dodo",
} var Constants = struct {
FooBar string
FizzBuzz string
} {
"FooBar",
"FizzBuzz",
}
If you must have type FlagID string
then this should work also var Constants = struct {
FooBar FlagID
FizzBuzz FlagID
} {
"FooBar",
"FizzBuzz",
}Worse, they're not constants, they're mutable. So how is that different from this?
var (
Cat = "Cat"
Dog = "Dog"
Dodo = "Dodo"
) type FlagID string
var Constants = struct {
FooBar FlagID
FizzBuzz FlagID
}{
"FooBar",
"FizzBuzz",
}
func p(flag FlagID) {
fmt.Println(flag)
}
func main() {
p("hi")
}If it is some sort of a library though, then there should be a better way to do this that to pass some enums as parameters to functions. But yes, it is a shortcoming that Go should probably fix.
func IsEnabled(id FlagID) bool {
would be satisfied just fineThe way that I tackle the problem is to use iota, and then to emit the numeric value into logs.
Later, if I have some time to get fancy, I’ll implement fmt.Stringer using a switch statement.
Sometimes, I’ll implement a ParseFoo function to convert the string representation into the typed representation.
All of this work takes about 5 minutes, and no thought. That’s what we’re talking about here: ~5 minutes of effort per enum.
IMO, you should be spending far more time thinking about which values to add to the enum than the mechanics of string support.
If you look at one of the Go inspired languages, such as Vlang (that has the enum type), it really should not have become an issue. Now, with Go being advanced in age, it comes off as just odd. It becomes reminiscent of the generics debate, where there was such long denial of it being necessary, then suddenly it is understood it should have been done earlier.
Same goes for the original problem. I’ve heard a lot of people complain about Go’s enum types accepting hard coded values, it’s a conceptual problem, sure, but is it a real problem in practice? It’s not something I would ever do by accident.
I don’t find my self passing random hard coded values to methods. I would have to look up the actual expected enum value, at which point I would think “I should make this a constant” at which point it fails to compile and I find the enum. Even then, in reality I would have noticed the type and the enum long before that. Hard coded values in and of themselves are a code smell. Like sure, in theory a typo could compile, but it seems like a hard mistake for a non-malicious developer to make.
It's turned out to be incredibly useful.
https://github.com/titzer/virgil/blob/master/doc/tutorial/En...
All it takes is one idiot to mutate one of those vars and all sorts of hell can break loose.
It doesn't. The closest it has is TFA's second example:
type FlagID int
const (
FooBar FlagID = iota
FizzBuzz
)
which is the underlying behaviour of C enums, but not actually that. Crucially it does not at any point hint or imply the set could be in any way closed.That it doesn't have C-style enums is a point in its favour, I would say. Not much of a point, mind, but still...
It does. By definition, an enum is simply a set of named values, which you code example provides, and is behaviourally the same as C.
> it does not at any point hint or imply the set could be in any way closed.
While true, that is a feature of sum types, which I already indicated Go does not have. This is slightly different to enums.
No.
> By definition, an enum is simply a set of named values
That is not the definition of "an enum", no. "an enum" does not imply a complete absence of any sort of type safety.
"a C-style enum" might, but that is not the distinguishing characteristic of C-style enums, the lying is, otherwise literally every language which has constants has C-style enums, including every language with sum types.
> and is behaviourally the same as C.
Except for all the ways in which C enums mislead users into assuming any sort of non-existent type-safety guarantees.
There is a critical distinction between "C-style enum", aka a misleading pile of garbage, and "just a bunch of constants". The latter is what Go provides.
> While true, that is a feature of sum types
That is not correct. For instance Java enums are not sum types, but are a type-safe, closed, set of values.
Sum types are where you expect to find type safety. Some languages call sum types enums, which I expect is where the confusion lies. Neither Go nor C have sum types.
> literally every language which has constants has C-style enums
I think that ultimately that's a fair assertion, but one might argue that the definition does imply some kind of defined set. Both C and Go define syntax for characterizing enums in an established set which the machine can determine where the set boundaries lie. A language with only constants relies on human interpretation of what defines the set.
> For instance Java enums are not sum types, but are a type-safe, closed, set of values.
That's a sum type, also known as a tagged union. Neither Go nor C has those, as has been established multiple times now.
In Go the values are not in a single declaration, and not even in a single TU. The strongest hint that this is an enum and not some other kind of integral newtype is the use of `iota` on the first declaration, but `iota` is also used for other purposes.
You're technically correct that they're "behaviorally the same", but declaration structures matter a lot.
According to the Go spec[1] iota exists specifically for enum generation. The closure of the enum is also defined. There is no other intended purpose. If a developer has found a new way to overload it in some new way they can equally do the same in C, so that is moot. Enums are not sum types in either language, with no expectation of behaving like sum types, there is no debate about that. They are simply enums.
This assertion is nowhere in the Go spec, not even implicitly.
`iota` is a convenience sequence generator, it is no more "specifically for enum generation" than sqlite's AUTOINCREMENT qualifier is. Or excel's cell-sequencing system.
Further demonstrating that the goal was not to replicate C enums, iota can not be advanced manually save by adding intermediate discarded case.
> If a developer has found a new way to overload it in some new way they can equally do the same in C
No, they can not, literally the second example of your link has to be written and maintained entirely by hand in C, to say nothing of the second example block.
> Enums are not sum types in either language, with no expectation of behaving like sum types, there is no debate about that. They are simply enums.
Go still does not have enums, and your generalised statements about enumerated types remain incorrect regardless of sum types.
The first line explicitly defines their use for defining enums. The only way it could be more clear would be to define enum, but that should be unnecessary given that the definition is usually well established, although certainly a couple of languages have tried to muddy those waters in recent times.
> `iota` is a convenience sequence generator
More importantly, it defines a set of values. The set is what differentiates an enumeration from a general bag of constants. C reaches for a enum keyword instead, but they achieve the same result of establishing a set.
> Go still does not have enums
It has enums. It doesn't have sum types. Yes, some languages call sum types enums, which is no doubt where your confusion lies.
No it's not, you can write totally heterogeneous types in a single const block, as well as intermix iotas and literals. iota may count the same sequence spread across multiple types. There's not even a guarantee that the type definition and the const block exist in the same TU!
> There is no other intended purpose.
It is often used to generate private context keys, for example.
type A = string | number;