[HN Gopher] Compile-time safety for enumerations in Go
___________________________________________________________________
Compile-time safety for enumerations in Go
Author : varankinv
Score : 49 points
Date : 2023-09-29 12:43 UTC (10 hours ago)
(HTM) web link (vladimir.varank.in)
(TXT) w3m dump (vladimir.varank.in)
| kleton wrote:
| I use this in my projects https://github.com/abice/go-enum
| maxekman wrote:
| Here is an issue tracking a possible fix to this:
| https://github.com/golang/go/issues/19412
| hqudsi wrote:
| I sometimes do this but idk if I would consider it 'elegant'
|
| The other 'gotcha' is that in switch statements the compiler
| can't tell whether you enumerated on all your cases as there is
| no true enum type so it's not uncommon to have a catch all
| default case that either returns an error or panics and hope you
| can catch it during tests if you missed a case.
|
| I just wish go had proper sum types.
| orbz wrote:
| This is an analyzer that will catch this:
| https://github.com/nishanths/exhaustive
|
| I believe it's in golangci-lint.
| atombender wrote:
| The main downside to this approach is that you'll still have to
| deal with the zero value, which for interfaces is going to be
| nil.
| Groxx wrote:
| While I agree nils are a problem with this: as long as they
| have access to the type at all, they can create zero values.
| E.g. var c color.Color
|
| That's a valid color whether it's a nil interface value or an
| empty typed string.
|
| You can return a private type to prevent this, but that also
| means they can't refer to it as an argument or return value
| anywhere outside the implementing package, which is a rather
| severe limit in many cases.
|
| Sometimes* I really miss constructors.
|
| *: all the time
| masklinn wrote:
| No matter how Go solves the issue (assuming it ever does), that
| will be part of it: unless it goes through a revolution and
| strips out and forgets about ubiquitous default values (which I
| don't think it will, C# has barely just dipped its toes into
| that pool) any sort of sum-type-like construct will need a
| default value. And I'm not sure `nil` (a clearly corrupted /
| missing value) is any worse than picking an actual valid value
| the way non-pointer types do.
| jchw wrote:
| I dunno if I'd consider having a dummy method on an interface as
| "elegant", but it does work. A trade-off of keeping the language
| very simple, for sure.
|
| What I really wish Go had was sum types, Rust style. That'd cover
| enumerations and more.
| jjice wrote:
| I go back and forth on this. On one hand, I _love_ Rust/ML
| style sum types. They're such a fantastic feature. On the other
| hand, I wonder how much use they'd get in a language like Go.
| If it's introduced, would it radically change the way some
| problems get solved? Is it that different than an traditional
| enum (which Go also skirts around)?
|
| Pattern matching and exhaustive checks are massive benefits of
| them though. I guess I'm just oddly conflicted here.
|
| I do think that long term, sum types are going to become more
| prevalent. I'm excited to see it, I'm just interested in how
| that adoption occurs in existing languages without them.
| Zach_the_Lizard wrote:
| Sum types with pattern matching would likely take the place
| of (foo, error) in a lot of codebases.
|
| This is especially true in cases where a function returns a
| collection of results where each result is independent from
| other results and a result can succeed or fail.
|
| For instance, a batch report generator that is called every
| $INTERVAL might return a ([]Report metadata, error) today,
| but each report could have succeeded or failed without
| impacting the others.
|
| The output is emailed to $SOMEONE to let them know the
| reports ran and information about each.
|
| In today's world, the "error" could be a special error type
| that capture failures on a per report basis. The
| ReportMetadata type could also have an Error field, but one
| is not forced to check either.
|
| Sum types could force checking for error on a per report
| basis, increasing the odds something is done with the error.
| jchw wrote:
| If I had sum types in Go, I'd use them for state machines and
| enumerations mostly. I can't see a huge downside; I'm sure
| some software has little use for it, but it's useful enough
| for data modelling that protobuf has the sort-of similar
| oneof primitive. Hmm. On that note, maybe it would be better
| than nothing to do some kind of code generation here...
| jerf wrote:
| I think the answer to your "back and forth" is probably laid
| out in: https://jerf.org/iri/post/2960/
|
| Sum types are useful, even very useful in the right place,
| but I do think there's a lot of people who use them a couple
| of times, probably in one of those "right places" and then
| mistakenly label them in their mind as "better". Just,
| universally, Platonically "better". They aren't in fact
| "better"; they're a tool. Sometimes they're the right tool
| for the job, but much, _much_ more often, they 're just a
| tool that works, and so do several other tools. People who
| get too excited about sum types need to be sure they square
| their understanding of how useful they are with the fact that
| the vast majority of programs are written without them.
| jchw wrote:
| This article seems almost entirely concerned with code
| organization, but in my case I basically never do
| functional programming and am only concerned about data
| modelling and data structures. In an adjacent, comment I do
| acknowledge one point (probably not all software really
| needs sum types) but I still feel as though languages
| without sum types are missing an important data and state
| modelling tool. Trying to take something like a state
| machine and jam it into interfaces or polymorphism kind of
| sucks; you lose exhaustiveness checking obviously, but also
| it enforces a lot of constraints about control flow and
| data. In Go parlance, if each of my states has a method
| with its receiver set to the state type, then I can _only_
| access that data, so for example I 'd need to pass
| something in or out to handle state transitions. Not ideal
| IMO.
|
| I feel this pain basically any time I hand-write lexical
| scanners/parsers in Go or even C++.
| masklinn wrote:
| > I still feel as though languages without sum types are
| missing an important data and state modelling tool.
|
| That's because they are. jerf subtly shifts the point to
| one they can criticise, but it does not change the basic
| fact that sum types are a critical tool which is just...
| missing.
|
| There's probably no tool which can't be misused, even the
| humble boolean, that's not an issue with the tool, and
| pointing that out is at best irrelevant. It does not
| change the fact of the matter: you're missing a critical
| axis of composition. It's like saying screwdrivers are
| useful but you can misuse them to hammer nails as if that
| justified trying to screw with a hammer.
| jerf wrote:
| Where I'd disagree is that they are _a_ tool, not a
| _critical_ tool, and they aren 't _missing_ from Go, they
| are simply not the preferred choice. You can cover 75% of
| the use cases with this approach. It is not 100% of the
| use cases. But there isn 't a language where you can
| cover 100% of the use cases with 100% effectiveness,
| which is why we don't have and never will have The One
| True Language.
|
| Moreover, Go seems to attract this sort of criticism as
| if Go is Uniquely Broken and it's nirvana in all the
| other languages... but I've used enough of them to know
| better. Sum types are great, until you hit the branch of
| the expression problem where you really need the other
| side, and if you're in a language that favors them,
| you're going to get the same 75% experience, just mirror
| imaged.
| jerf wrote:
| "In Go parlance, if each of my states has a method with
| its receiver set to the state type, then I can _only_
| access that data, so for example I 'd need to pass
| something in or out to handle state transitions."
|
| I am not clear what you mean by that, exactly, but
| generally I model a state machine as a type that composes
| a state: type State interface {
| isState() } // a whole bunch of
| state types here type StateMachine struct {
| State // exported or not exported, depending on local
| needs // additional data }
| func (sm *StateMachine) Event1(args...) error {
| // can call current state and change it here }
|
| You can add an "Execute" method on to the State and have
| it return an entire state if that's how you want to do
| it. The state machine can pass in any cross-state data.
| There's a number of options. You aren't obligated to do
| exactly, only, and precisely what you'd do in another
| language, and program X in Y. For some reason, that's
| common wisdom in the programming world... _except_ for
| functional programming. I say "don't program X in Y" is
| simply true and there is no carveout for functional
| programming in non-FP languages any more than there is
| the other way around for OO in FP languages.
| jerf wrote:
| If you are satisfied with a dummy method on an interface, you
| can continue by adding more types to that interface. Color is
| not a great example, so: type Vehicle
| interface { isVehicle() }
| type Car struct {} func (c Car) isVehicle() {}
| type Van struct {} func (v Van) isVehicle() {}
| func VehicleType(vehicle Vehicle) { switch v :=
| vehicle.(type) { case Car:
| fmt.Println("car") case Van:
| fmt.Println("van") default:
| fmt.Println("unknown vehicle") }
|
| This covers most, but not all, of the bases, in that you don't
| get exhaustiveness checking at compile time, unless you adjoin
| a linter to your compile process:
| https://github.com/BurntSushi/go-sumtype
| jchw wrote:
| Yeah, I've done that before. It's not really elegant in my
| opinion, but OTOH, it's basically the best you can do. Oh
| well.
| the_gipsy wrote:
| You need the JSON de/encode implementation with checks. At that
| point you'll want code generation, and something that was never
| elegant is turning awful.
| SPBS wrote:
| This is not elegant. It also has overhead. Stick with the simple
| `const Red Color = "red"`, you're not gaining a lot by doing all
| these strange type contortions in Go. Seriously consider what you
| are protecting against, and whether it's an imagined bogeyman.
|
| "Oh but someone may try to cast arbitrary values to my package
| type" Okay but who is that going to hurt? You or them? Will they
| get hit with errors early on in the development lifecycle if they
| do something silly like this? Are you actually going through all
| these lengths for nothing?
| the_gipsy wrote:
| It's stuff that just creeps in. Enum values can come from
| outside (json, anything non-literal). Now you have to do
| validation, because the bad values parse and for sure exist at
| some stage in your program.
|
| It's not a bogeyman.
| ollien wrote:
| What overhead is there at runtime?
| kgeist wrote:
| Casting a value to an interface forces it to be allocated on
| the heap.
| Groxx wrote:
| Interfaces are both allocations and function-call
| indirection, and I _think_ they also always move their data
| to the heap.
|
| Which is almost always trivial except when it isn't.
| euroderf wrote:
| In a more dynamic environment where you could manage values in a
| controlled manner, how could you validate values ?
|
| One idea is to make a DB table dedicated to enumerations and then
| use foreign key constraints. Define a unique namespace for each
| set of enumerated values, and catch foreign key constraint
| failures on DB writes.
| earthboundkid wrote:
| I would be interested to read bug reports from people who thought
| they had a complete enumeration but learned in production that
| their enumeration was incomplete. I've never seen it myself.
| Someone wrote:
| An easy way to get that is when you use a third-party library,
| update that to get some new feature, and get a new enum value
| for free, say because the html parser library started
| supporting a new node type or added a new error type (the
| latter will be rare in go because of its lack of sum types).
|
| Ideally, of course, the release notes of the library would
| spell out the issue, and you'd read them, but even then, making
| sure you fix this everywhere is way easier if the compiler
| refuses to compile your code if it doesn't handle the new case.
| earthboundkid wrote:
| Did this actually happen to you or are you just theorizing
| that it could happen?
| Zach_the_Lizard wrote:
| This is relatively common with naive int / string to enum type
| conversions, especially if the int or string are given as input
| from an RPC mechanism of some sort.
|
| This type of pattern can be used to force other developers to
| check if the value is garbage before calling $BUSINESS_LOGIC
| that uses the enum
| kiitos wrote:
| Unfortunately not. func main() { c :=
| color.Red cptr := (*string)(&c) *cptr =
| "orange" PrintColor(c) // successfully compiles, and
| prints "orange" }
| revoly wrote:
| I don't think the solution is trying to cover this. If a user
| does type conversion then usually they know what they are
| doing. It's like going out of the way. The solution is to just
| hide a type behind an interface to be explicit about it and to
| avoid the error that would have gone unnoticed otherwise.
| kiitos wrote:
| The article is titled "Compile-time safety for enumerations
| in Go" but my example demonstrates that this is not the case.
| dgb23 wrote:
| You demonstrated that you can deliberately circumvent it.
| taeric wrote:
| The counter point is that this is like arguing that an
| article about safe knife skills are silly, because you can
| still just stab yourself?
| kiitos wrote:
| I'm not sure how that's comparable. Compile-time type
| safety is a boolean thing, you either have it or you
| don't. If you have it, then my example shouldn't compile.
| But it does compile, so you don't have it.
| taeric wrote:
| I think by insisting to view it only as a boolean thing
| is very limiting. By that measure, are the type safety
| hints of Python not really giving any type safety? Just
| give up and go home, as it were? :D
|
| I'm sympathetic to the points, I think. But I also don't
| have a problem with someone pushing for some type safety
| as long as you avoid actively trying to go against it.
| kiitos wrote:
| > By that measure, are the type safety hints of Python
| not really giving any type safety?
|
| If we're talking compile-time, then: yes, absolutely!
|
| Type hinting doesn't provide type safety, because type
| safety isn't a spectrum.
| paulddraper wrote:
| It is the case under certain conditions.
|
| Casting is by definition an escape hatch.
| throwaway894345 wrote:
| The type conversion is a red herring. He's just changing the
| value of a variable by referencing and dereferencing a
| pointer. In other words, he's not using a pointer conversion
| to change the value of `color.Red`, he's creating a new
| variable `c` and assigning `color.Red` to it, and then
| changing the value of the variable through the pointer and
| type conversion. But he doesn't even need to do the
| pointer/type conversion stuff, he could just do `c =
| Color("orange")` and call it a day.
| kiitos wrote:
| > But he doesn't even need to do the pointer/type
| conversion stuff, he could just do `c = Color("orange")`
| and call it a day.
|
| c = Color("orange") does not compile.
| throwaway894345 wrote:
| Ah, I misunderstood what type color.Red had. My mistake.
| avg_dev wrote:
| i appreciate this counter-example; thanks. i was hoping there
| was a way to get real enums in go.
| throwaway894345 wrote:
| You're not changing the underlying value of `color.Red`, you're
| changing the variable (in other words, `PrintColor(color.Red)`
| will still print `red`). If that's the point you're intending
| to make, then couldn't you make it more easily with this?:
| c := color.Red c = Color("orange")
| PrintColor(c)
| masklinn wrote:
| > You're not changing the underlying value of `color.Red`
|
| Doesn't matter, the point is they're getting in a value which
| is not part of the defined set, demonstrating that this
| emulation is not actually typed-safe.
|
| > If that's the point you're intending to make, then couldn't
| you make it more easily with this?: cannot
| convert "orange" (untyped string constant) to type Color
| tedunangst wrote:
| At some point I think it's worth asking who are we trying to
| stop and why.
| kiitos wrote:
| Indeed. Go types are like simple locks: they keep honest
| users honest, but they don't prevent dishonest users from
| breaking your assumptions. The point is just that "compile-
| time type safety for enumerations" isn't actually true, or
| even possible. You always have to do some amount of runtime
| validation of received values.
| chabad360 wrote:
| No, you don't. If your module relies on compile time checks
| for validity, there's no reason for you to also include
| runtime checks. Other developers breaking your defined
| contract is not your responsibility, it's not even a bug.
| kiitos wrote:
| > If your module relies on compile time checks for
| validity,
|
| ...then it's broken, because the Go compiler, factually,
| cannot (in general) check or enforce the validity of
| specific values.
|
| > there's no reason for you to also include runtime
| checks. Other developers breaking your defined contract
| is not your responsibility, it's not even a bug.
|
| It is absolutely the responsibility of the function
| func PrintColor(c Color)
|
| to ensure, at runtime, that `c` is a valid Color. My
| example code -- which allowed an invalid Color value of
| "orange" -- is absolutely a bug in PrintColor.
| gabereiser wrote:
| It's actually not and is one of those Java isms that have
| made its way into generalized software engineering.
|
| If I write a function that takes Foo as an argument. I
| have a Foo implementation exposed elsewhere in my
| program. It is absolutely expected that I mean MY Foo,
| not your Foo. If you pass me your Foo, you will get
| unexpected results that are not a bug, not a side effect,
| not my responsibility. It's yours, the caller, to adhere
| to the contract.
|
| There are valid reasons to not adhere. To pass your own
| implementation. At that point, you are responsible for
| its use. Not me. If it adheres to MY Foo's interface, you
| might get by, but it's is not my responsibility to
| validate all permutations of unknown types to ensure
| you're passing me mine. In go, it's perfectly valid to
| return structs but accept interfaces as arguments so that
| you, the program author calling my api, can craft the
| correct program flow.
|
| So please, leave that runtime type check reflection for
| Java and C# and the land of JS. We have no need for it
| here in machine code land.
| ori_b wrote:
| Exactly. Someone could always connect to your program
| with a debugger and modify the instructions are being
| run, and that's why you need to regular runtime checks of
| the validity of your program text. If you want to handle
| people actively trying to undermine the assumptions your
| program is making, you can't leave out easy to replicate
| cases like 'gdb attach', obvious, non-accidental use of
| the type system, and other similar categories of issues.
|
| What strategies do you use in your programs to ensure
| type safety in the event of debugging, mmap, and other
| mechanisms that can be used to subvert the memory layouts
| your compiler thinks it produced?
| athrun wrote:
| That doesn't make any sense to me.
|
| Most instructions are not atomic, so even if you had
| runtime checks something like a debugger could still
| inject invalid state in between your checks, making them
| ineffective.
|
| If real-time code injection is your threat model, I don't
| see how runtime checks would get you anywhere.
| wahern wrote:
| You seem to be suggesting that (to use a common phrase)
| if something can be done, you should do something. The
| previous poster's example regarding a debugger was to
| challenge the notion that there was any meaningful reason
| to constrain non-accidental type subversion. You seem to
| be saying it's absurd to care about someone using a
| debugger to inject invalid code at runtime _because_
| there 's nothing you can do about it, thus implying if
| you can do something, you should. But that only begs the
| question--why _should_ you? To some people, defending
| against debugger code injection is no more absurd than
| defending against other examples where the programmer
| deliberately alters code to subvert static type checking.
| If you disagree then articulating more meaningful
| distinctions among cases would be worthwhile. Mere
| ability is generally not considered sufficient
| justification alone to do something.
| davidkunz wrote:
| > Go's type system allows preventing both issues in a rather
| elegant way.
|
| Proper enums would be elegant, not this.
| masklinn wrote:
| Some way to define a closed set of values anyway. It doesn't
| even need to be classic-style sum types, for instance as
| support for generics Go introduced support for union types (I
| don't think they have a name?) e.g. type Foo
| interface { A | B | C }
|
| and the interface type is the union of those type-sets. Such
| interfaces can not currently be used outside of type
| constraints, but if that is relaxed, and type switches are
| updated to support and enforce exhaustive matching (and
| understand such sealed / nominative interfaces), you've got all
| the bits you need.
|
| You'd need to newtype variants to add payloads of similar
| underlying type e.g. `int | int`, but that's not a huge
| imposition, and the variants being types themselves is often
| convenient so it's a 50:50 tradeoff compared to classic sum
| type (where constructors disambiguate all variants but are not
| themselves types).
| foldr wrote:
| Agreed. A slight wart is that default initialization would
| require either boxing or a somewhat arbitrary decision about
| which variant should be the default. So if you have e.g.
| var foo interface{ A | B }
|
| then foo either has to be boxed (so the default value is a
| nil interface) or unboxed and arbitrarily initialized as an A
| or a B.
| masklinn wrote:
| To me it would make sense that this be implemented as a
| normal interface at least initially, it would reuse all the
| existing bits of the language as is. Note that whether an
| interface boxes depends on escaping.
|
| I don't see how unboxing would have to be "arbitrarily
| initialized as an A or a B" either. Even with a novel
| bespoke implementation you still need a discriminant
| between the two nested. You could keep a nil default by
| reserving the zero discriminant for that purpose.
| foldr wrote:
| I agree with your first paragraph.
|
| I think it would be weird if an unboxed union of A and B
| was initialized to a value that wasn't the default value
| of either A or B, although I take your point that it's a
| technically possible implementation.
| masklinn wrote:
| > I don't quite understand the second one (is 'boxing'
| supposed to be 'unboxing'?)
|
| Yep, fixed, sorry about that.
|
| > I think it would be weird if an unboxed union of A and
| B was initialized to a value that wasn't the default
| value of either A or B
|
| On the one hand yes, on the other hand it would be
| consistent with the langage semantics (the default value
| of an interface type is a nil), and I don't think
| defaulting to the default value of an arbitrary variant
| is better.
|
| Although I can see the similarity with iota / newtype
| constants, the first item being the default, and
| similarly people could set the first type as relevantly
| named (possibly unexported) empty struct if they want /
| need the signal.
| pjmlp wrote:
| Pascal like enumerations, yet another feature that Go missed from
| the 1970's.
| dgb23 wrote:
| Clever!
|
| But what problem does this actually solve?
|
| When I see a type alias of a string and enumerated variants then
| why would I pass in arbitrary strings?
|
| There are _always_ ways to break assumptions. Putting arbitrary,
| complex guard rails at every call site doesn't make a program
| magically better.
|
| An API is primarily about affordances. We provide utility to the
| caller.
___________________________________________________________________
(page generated 2023-09-29 23:01 UTC)