Make the union type default instantiate as the first possible type of the union. You might think this is a pretty dumb design decision that can lead to programmer error, but it meshes perfectly with the other mistakes in the language.
The problem is that you can’t really make this work within the runtime in the way you think it would work. The runtime relies on being able to figure out where pointers and non-pointers are. When you assign to a union, you’re not just changing the value, you’re (potentially) changing which parts of it are pointers. If you just let this be, you have to redesign the runtime (significant changes). Alternatively, you could make the union store everything like it were a struct containing all variants… but that would be inefficient. These are not good options.
Why do you think this is a problem? It's solved in other languages that have much more expressive type systems than Go and comparable or better performance. Rust is an obvious example.
The standard implementation is pretty simple: the different variants of a sum have a tag that tells the runtime which variant they are, and hence where to find pointers.
Go already has a "type switch" for interfaces, so it must already store tags with values in some cases, which is exactly what is needed to make this work.
> The standard implementation is pretty simple: the different variants of a sum have a tag that tells the runtime which variant they are, and hence where to find pointers.
That won’t work in Go, because it would not be safe. You would need to coordinate the change with the concurrent garbage collector. It’s not designed to do this. Redesigning it to do this would be non-trivial.
Go already has a lesser-known safety problem similar to this involving interface types, but that safety problem is at least avoidable if you write code without data races in it. With the union safety problem, the data race is harder to avoid.
As parent mentions, Go already does exactly this for interface values, so the problem has already been solved. This is at most a problem if you want to avoid boxing individual union variants. Otherwise, the runtime representation of a union can just be an interface value (with some extra type system stuff to enforce exhaustive matching, etc. etc.)
Interface type implementation was explicitly changed to not trigger this problem. Now small values cannot be stored inline in the interface, where they can be confused for pointers. All things stored in interfaces have to be allocated, and the GC associates "what parts are pointers" with the memory location, not with the interface type tag.
Yes, that's true. But that means the problem is solved, no? I think my previous comment was badly worded. I just meant to say that Go has a concurrent GC and a runtime representation of a value that can instantiate one of a number of different types (i.e. interface values). So as long as one is willing to accept that as an implementation for an enum/union type, all of the major runtime problems are already solved.
Sure, I appreciate that concurrent GCs are hard. For interfaces, the Go team chose one of a number of possible solutions: https://github.com/golang/go/issues/8405 If the language were to add support for union types, these tradeoffs could potentially be revisited.
Other commentators have pointed out that nil is a perfectly good default value for a union (if it's underlyingly an interface).
I must admit I don't understand what you are trying to say here.
Perhaps there is some confusion around terminology. What OP calls union types are known in type theory as sum types, and are actually different from what type theory calls union types. Type theory union types are like set unions.
Commutativity doesn't apply to sum types, though it does apply to union types. Here I'm using the type theory terms, not the terms used by OP. So this isn't really an issue.
Zero values don't make any sense anyway. As the original comment said "You might think this is a pretty dumb design decision that can lead to programmer error, but it meshes perfectly with the other mistakes in the language."
Honestly, doesn't seem like it would be that bad. Presumably you could then have a static go vet check for any time you implicitly initialize a union type to the zero value, then it's roughly the same compromise as handling copies of non-copyable values.
This. It is either amusing or depressing to read hand-wringing from Go developers about design issues that have been solved for some 50-ish years (see [1]). It was the same with generic types.
You don’t just drop a union type into a language with some syntax and semantics. There’s an impact on how the runtime works. Every language’s design is somewhat impacted by runtime considerations. For Go, the runtime consideration is the concurrent garbage collector, and the data race that you would have between the garbage collector and the union tag. You have to solve that problem in order to have unions in Go in a way that would make sense in Go.
Go unions would not look like unions in ML.
You could trivially implement unions in Go by having union values be pointers to structures, and it could be a different structure for each variant. This is basically just sugar around "any" and typecasting. The problem? You’re now allocating for every assignment.
I agree that one cannot simply hand wave a language feature into existence. What I don't understand is why you consider a requirement to change the runtime to disqualify a new feature.
In this case, if I understand your complaint correctly, what you are saying is there is a race condition between setting the tag of a variant / union / sum type and the GC reading that tag to interpret the memory layout. I agree this is an issue, but it's an issue with any memory write. This is why garbage collectors have a write barrier, and the Go GC is no different (e.g. [1]). So ultimately I don't see the problem as unsurmountable. Yes, it will require some thought and some runtime changes, but the foundation (i.e. type tags, the write barrier) is already in place.
And yet, with Go, I've never been more productive.
edit: to be complete, design decisions, even 50 year old ones, are not absolutes, but trade-offs and subjective. Eg, is a GC more important than a highly-expressive type system? Depends who you are. But a simple language, even one with a minimal type system, can be highly productive.
There's still PL research because PL researchers are researching novel techniques like dependent types, linear types, formal verification, algebraic effects, advanced reference counting etc. They aren't researching features that ML had literally 50 years ago. Well I hope they aren't anyway.
In fairness to the Go developers I don't think they ever said they were against generics; they just put off adding them for ages because they didn't have a design they liked. I imagine the same could happen with type unions - in 20 years when everyone has stopped using Go they'll come up with a reasonable design. :-D
Features such as type unions. The topic of this conversation.
> Why are we not using OCaml or standardML or even F# (which I tend to look favorably on) more widely then?
In my opinion there are a number of factors. I have most experience with OCaml, so on that:
1. Terrible Windows support.
2. Poor documentation.
3. An obsession with linked lists and recursion, which are great from a theoretical point of view, but abysmal from a performance point of view (and also simplicity IMO).
4. Poor syntax. The dearth of brackets and semicolons makes it very hard to visually parse. Something as simple as mismatched brackets can be very frustrating to resolve. The insistence on (a -> b -> c -> d) style function types is unnecessarily confusing. Generally when there has been a choice between academic cleverness and accessibility they've always chosen cleverness.
5. Global type inference is pretty clearly a mistake at this point.
IMO part of the reason Rust is so successful is that it has taken a lot of the very good ideas from ML and basically fixed all of the above issues.
Standard accessible C style syntax, but expression based and with proper ML style types. Fantastic documentation and Windows support. No linked lists to be seen.
> Rust has its fair share of corners and weird syntax.
True, but IMO they're in smaller bits of the language, like trait bounds. Not something as basic as blocks, function calls or variable declaration.
> Go evolves at a more cautious pace, and is actually more successful. To the extent that rust also borrowed from Go.
Go is more successful because it is older. Give it a few years. I actually don't know of a single feature Rust has borrowed from Go. It is not even listed as an influence:
> Minimalism, caution, and starting from first principles with a clean slate is obviously the right choice.
Throwing away learnings from other languages is pretty clearly a bad choice IMO. It would be like creating a new language where everything is nullable... oh.
> If it was truly the case, there wouldn't be any PL research anymore. :o)
What? There is plenty of interesting ongoing PL research (e.g. a "perfect" solution to combining subtyping with type inference was only found quite recently). But some languages are happy to ignore results from 50 years ago.
I am fine just watching you implement another Rust binding every weekend until you give up.
At this point there's probably more GL bindings for Rust out there than active developers.
If you haven't figured out by now that every language has tradeoffs and compromises along the way because it has to run on x86/x87/AVX512 CPUs, then I guess you will need a couple years until we can have a rational discussion about it.
Rust is good at type safety, we get it. But there is plenty of developers that are also fine without type safety. Therefore type safety is just a scale from duck to dependent types, and in Rust, type safety is usually in the way.
> This makes a zero value union very similar to a nil interface, which will also fail all type assertions.
Yet is still directly comparable with 'i == nil' which yields true. Two different interface pointers of the same type that are both 'nil' can also be compared this way. Plus interfaces can be manipulated under reflection.
> At this point my feeling is that Go might as well stick with interfaces and not attempt to provide union types.
The language, and most particularly the runtime, is not compatible with the idea. I'm not sure why it gets brought up other than language level FOMO.
> I'm not sure why it gets brought up other than language level FOMO.
I think it gets brought up because people see places it would be helpful quite often.
A lot of the Go code I've written has:
switch x {
case ....:
case ....:
default:
panic(fmt.Sprintf("inexhaustive match, please update this switch statement for %v", x))
}
Similarly, go supposedly has good error handling, but I constantly find myself writing:
switch {
case err == nil:
case errors.Is(err, SomeErr):
// handle SomeErr
case errors.Is(err, OtherErr):
// handle OtherErr
case strings.Contains(err.Error(), "tls: unknown certificate authority")):
// handle unexported go stdlib error, XXX fix after https://github.com/golang/go/issues/35234
default:
panic(fmt.Sprintf("unhandled error type: %v: %T", err, err))
}
Like, for being a statically typed language, Go's error handling is really bad, and sum types is the thing that would let me internally at least make error handling feel good.
I could have my own functions return up a non `error`-interface type, not implement the `error` interface intentionally to avoid the nil-typed-interface problem 'err' is otherwise prone to, and then I could actually know when I compiled my codebase that my error handling is good, and know what needs to be updated after adding new error paths.
I mean...I'll say this, for all the great things spoken about it I also found myself wildly disappointed with Rust's error handling when I tried to use it (and then a pile of blog posts going "oh yeah, it does kind of suck...").
What I am yet to find in any programming language is a reliable way to put my cursor somewhere in the code base, and know exactly all the possible errors which can happen, where they could come from, and whether I've handled them (or might be interested in handling them).
Fault-injection is also consistently a sore point - i.e. in Go if you're doing an os.Open(somefile) call - it'd be really nice if writing code which tests the failure path was just easier - i.e. most of the time I'm saying "this Open on this line, just return an error for this test case so I can make sure it does what I want" - without bogging myself down in dependency injection or call monitoring (i.e. realistically I just want to say "os.Open in file somefile.go, line 143 - for this test please just return err"). Yes something like this can be done, but can we really not make this easier? Write tools which make this easier?
Agreed. Sum types require too much boilerplate for error handling as they require wrapping and unwrapping which leads to people creating crate-level error enums that have every possible injection.
Ideally we'd have unions which gets rid of the bike shedding that comes with sums [1].
Roc's polymorphic tagged unions seem to be the sweet spot of not requiring specially-crafted enums or multiple levels of unwrapping, yet having lots of statically-checked guarantees.
(Zig is similar but doesn't support different types of payloads on the different error values).
> What I am yet to find in any programming language is a reliable way to put my cursor somewhere in the code base, and know exactly all the possible errors which can happen, where they could come from, and whether I've handled them
You can do this in Scala with an IO type e.g ZIO.
All functions have a return type of IO[Success, Error] and it will always return one or the other.
Cats Effect and ZIO eventually catch unhandled exceptions but you are right, there's no way to know for sure, we're merely assuming that 1) Scala libraries have a sane behavior (i.e. no exceptions at all) 2) the developer is familiar with the Java libraries they're wrapping and will use IO.attempt pervasively.
However, in the near future, the compiler itself should be able to handle all exceptions statically:
I've been enjoying the improvements in Nim's effect system lately. The Nim LSP recently added the ability to see the exceptions any function can result in. It's been pretty handy. Well when the LSP isn't crashing like a seive at least (it's getting better slowly).
The LSP doesn't show where the exceptions are raised from, but that'd be cool. Even just having a link to pull up a search for a given exception would be handy.
> What I am yet to find in any programming language is a reliable way to put my cursor somewhere in the code base, and know exactly all the possible errors which can happen, where they could come from, and whether I've handled them (or might be interested in handling them).
Zig's error types and error unions seem like a decent approach to achieve this.
I'm not bothered to `switch` errors, however I really wish I could also match against both static errors (ErrSomethings) and error _types_ in a single `switch`, because now whenever I want to properly handle most errors, I need at least two `switch` statements. There's also the issue of type-matching deeply-wrapped types which is non-trivial (requiring a for loop.)
For other programmers, it's missing critical features.
For me, it is very important to be able to model my domain in the type system. You can't do that very well (compared to other languages) with Go's type system.
I would choose Go over any dynamically typed language. I wouldn't pick it over most statically typed languages.
Exactly this. I'd pick it over Python. I might even pick it over Java, which I think is kinda... barely statically typed. But thinking in strong types and making the type system my friend has saved my ass and improved my productivity so much over the years... I find programming in Go is like working with one arm tied behind my back.
The emphasis on simplicity is a laudable goal. The problem is they picked the wrong definition of "simple"
There are so many of these stories about why some popular feature from other languages doesn’t fit into Go. Great. It’s already a nice and very productive language. If you don’t like it, just use something wise.
The reason this stuff comes up is because many folks are forced to use a language for commercial reasons due to job. It's not as simple as just fscking off to use something else you desire. Good luck selling that rewrite.
How much are zero values actually zero values (as in, bit patterns of all zeroes), and how much are they instead default values, which could conceivably be computed per type?
Suppose we talk about tagged unions specifically, would it be conceivable to just nominate one variant as the default variant?
Hence, Option[T]’s zero value could be None, and Result[T, E]’s zero value could be Ok(T’s zero value).
(I have no idea if this would work or not. I haven’t used Go for a dozen or so years, but once knew it pretty decently. I work extensively in Rust. But I am curious about this, because I want every language to have sum types, they’re just such a great thing. Always miss ’em when working in JavaScript or most other languages I ever touch.)
This works for those two types, but consider something slightly more general like an Either type with a Left and Right. You could just repeat the same think as Ok but for Left or Right, but then why pick one or the other? It becomes a footgun with more complex scenarios to always demand some default value. Also how it interacts with generics needs to be considered.
Look, Result defaulting to Ok is stupid too—it doesn’t have a sensible default value. But Go already has a problem with some of its zero values being stupid. I don’t think proper enums would make it any worse?
> Suppose we talk about tagged unions specifically, would it be conceivable to just nominate one variant as the default variant?
My experience with Go is just as recent as yours, and I'm happy to eat shit if I'm completely off base here, but my interpretation is that a tagged union with two instances like
Some a | None
would have to be implemented with underlying standalone `Some` and `None` types, and that `Some` itself would still require an empty representation, which is strange.
Compared to Haskell, where for
Maybe a = Just a | Nothing
the two instances are only (de)constructors, and there is no actual type `Just`.
> would have to be implemented with underlying standalone `Some` and `None` types, and that `Some` itself would still require an empty representation, which is strange.
It's not that strange. That's the normal way to model these things in OO languages, and while it's obviously not ideal, it's not completely awful either.
I think at some point Go is just going to have to either be a very flawed language, or make some very big breaking changes. Between union types being difficult to do properly, and sum types being subject to infinite arguments on GitHub. I get the feeling that it’s just going to stay a flawed language that I grow annoyed with.
Literally the only two features I’ve ever wanted in go is a way to express optional return values without pointers, and a way to be able to write a set of enumerable values in a sane way. The inability to express both in Go is quite frankly ridiculous.
I use go extensively. I’ve written numerous tools and deployed lots of things to production with it. Both of these problems are such a sore point for me. So many go libraries have either ridiculous workarounds with foot guns due to these two missing features that it hurts to use most of them.
Because it wasn't designed by PL researchers. It was designed by systems programmers who are used to C and just wanted a "better C". It was made popular because that happened within Google and they publicly gave it their backing so they wouldn't have to train new hirees on their new language.
Also its creator pulled a Molyneux and basically promissed journalists everything they asked about it. Not only would it be the perfect C++ replacement for all projects at Google, it would do systems and embedded programming and dozens of other things as well.
My first Go project (i think this was ~2014), i created a supervisorD clone as a school project (the coroutine/channel part of the languages were pretty much perfect for that).
After one week, i started calling Go: C+-. It felt like a superset of C with a lot of helpful tools, that kneecaped you each time you want to do something it's not meant to, like using memcpy. Why feel so much like C and not give you its most powerfull tool? (i was becoming pretty good with C memory management, pointer algorithms, and gcc at the time too, and not having those tools available to code/debug probably gave me a bad first impression).
The public backing by Google absolutely propelled Go into the spotlight, but Dart, also released by Google, hasn’t achieved anywhere near the same success. Considering how long ago Go was released, if the language didn't have its own merit, it would have fizzled out by now and failed to sustain its momentum or foster such a strong community.
Dart was never marketed (to my knowledge) as a general-purpose programming language. Go was marketed as the best thing since sliced bread, and especially as a "systems language", which it definitely isn't. It was also gaining popularity on HN at the same time Rust was gaining its initial wave of popularity (~2016-2017, around when I started reading HN), so the two were compared and written about a lot in a way that Dart never had the chance to since it never had a narrative foil.
In reality it turned out to be a worse C in many ways, because it has a GC and fat runtime (ruling it out for a huge chunk of what you might use C for) and lacks any kind of metaprogramming capability (yes, C macros are bad, but they're useful/necessary a lot of the time).
Regardless of their intention, it turned out to be a competitor to Java, not C.
I don't understand, why did you think Go "ignored" them?
I think a really important insight from ~50 years of PLs is that recent language features (say: lifetimes, or dependent types) do not always correlate with practical adoption, security guarantees, low cognitive overhead, teachability, readability, fast compilation, mechanical sympathy, and other such goals.
You can totally argue that Go should have been designed differently, but it's much harsher and untrue to say the designers ignored the ideas you have in mind.
Go the language is simple at the expense of code written in Go being complex. That’s why it sucks. Every problem it doesn’t solve or solves poorly to eschew complexity is a problem every Go codebase now has its own bespoke pattern or hack or library to solve. Its low cognitive overhead is false economy.
You can't have "low cognitive overhead" and Go's "let's pollute every single line of code with error handling". Same for "let's have multiple versions of the same type everywhere because we don't have generics" (thankfully, fixed). And so on.
Sum types and product types are fundamental to a type system, the same way that addition and multiplication are fundamental to arithmetic.
You wouldn't design a language with only multiplication and not addition. You wouldn't design boolean operations with only `&&` and not `||`. You wouldn't design bitwise operations with only `&` and not `^`. You wouldn't design set operations with only `∩` and not `∪`.
The alternative to "ignore" here is "ignorant", so it seems a nicer intention that they were aware of the fundamentals and chose not to use them.
Thanks for your input! All the design proposal RFCs have shown they cause an issue with the Go Compatibility Promise whereby adding any enum value can cause a semver-MAJOR breaking change. This causes a greater rate of ecosystem churn which, in every design proposal so far, has been deemed a net negative outweighing the modelling benefits.
Yep, I'm aware. The question you asked was about the Go language designers ignoring the fundamentals, which is the root cause of this situation today where fundamentals can't be bolted on to the language later
Yes, generics. Have all the people complaining about the lack of generics in Go adopted it now that it supports generics? No, they found other things to complain about. So this should probably be a lesson to stop looking for the "next big thing" to implement (non-nullability? Union types?).
Yes, people will complain about other things. Why is it surprising to you?
Generics was a big thing because it impacted how a lot of code was written. It doesn't mean people don't want other things in the language that is hell bent on ignoring any practical advances in languages of the past 50 years.
What you propose is to basically stop improving the language because people complain.
I see these kinds of hateful comments repeated on HN all the time, and these comments disgust me. Why phrase the criticism in such an incendiary way? What’s the goal here?
> The key point here is our programmers are Googlers, they’re not researchers. They’re typically, fairly young, fresh out of school, probably learned Java, maybe learned C or C++, probably learned Python. They’re not capable of understanding a brilliant language but we want to use them to build good software. So, the language that we give them has to be easy for them to understand and easy to adopt
> They’re not capable of understanding a brilliant language but we want to use them to build good software. So, the language that we give them has to be easy for them to understand and easy to adopt
How would you phrase this? Thinking programmers are too stupid to understand languages with modern features is just the plain literal meaning of the quote, no?
He spent decades at Bell Labs. He was also, IIRC, in his seventies.
So a more charitable interpretation would be that most of his colleagues are in their 20s and just out of school with a CS degree. He doesn't think they can (productively) use the languages the cutting-edge PL researchers generate.
Sure they could. Sum types aren't in go, not because fresh grads can't learn how to use sum types, but because they couldn't see a clean way to add it to go and have it fit with the other stuff they were putting in go, which they thought was more important.
But trying to get new grads to use Haskell on the scale of a 10 million line code base, and have them not make a mess of it? That's the kind of thing he didn't trust the new grads with.
> They’re typically, fairly young, fresh out of school
these same lowly colleagues were also incapable of wiping their asses at one point in their life but over the years and skidmarks, they all mastered that beautiful language and now make heated bidet money.
I think Rob Pike didn't mean brilliant as a complete compliment here. It was more an indictment on astronaut engineering of languages rather then engineers. He is literally the designer of Go and likes to program in it.
Go already has pseudo-functions such as "make", special syntax for channels/map, and switching on types, technically they could do something like this:
value := make(option[int, bool], true)
b := value.(bool) // panic if not a bool
switch value.(type) {
case int:
case bool:
}
If an option value isn't initialized, all attempts to do something with such an option will panic (there's already such a rule for nil interfaces). Not saying it's good design, but it's consistent with the syntax/semantics of the rest of the language.
So let's say I'm making a type, PrimeNumber, and I have a method that validates a given number is prime and returns an error if not. In Rust, that's something like
If you let `PrimeNumber` have an arbitrary zero value, you've lost the guarantee that all `PrimeNumber` values are prime.
Rust has tons of these, e.g. NonZeroU64 is guaranteed to be >=1. If you are somehow holding one, you don't have to check.
You can e.g. make a `struct Username(String)` and all paths to creating one enforce it to match a grammar; you will never encounter a `Username` that's the empty string, or contains non-printing characters, etc.
If you think of solving that by having a way for PrimeNumber to say "my zero value is actually 2", well, a) that's really weird and will just cause different bugs and b) that only solves the very simplest case.
Sorry, but I don't follow. Practically, in the Go design above, PrimeNumber is going to be uninitialized only if the current value of Result is PrimeError. Which can be checked with type matching: which value is active. Attempting to dereference an uninitialized option would panic, we just shifted it from compile-time to runtime (which is typical of Go). I don't understand how things like "NonZeroU64 is guaranteed to be >=1" are related to the notion of sum types per se. Sure Go has a weaker type system than Rust and you can't guarantee that a struct will never be zero-initialized, but you can enforce it with conventions, like wrapping values with structs, using constructor functions, etc.
Example (with proposed syntax):
type Username struct {
value string
}
func NewUsername(value string) option[Username, UsernameError] {
if value == "" {
return make(option[Username, UsernameError], NewUsernameError(..))
}
return make(option[Username, UsernameError], Username{value: value})
}
u := NewUsername("")
switch u.(type) {
case Username:
// ...
case UsernameError:
// ..
}
Verbose and non-idiomatic but seems to work. Or I don't understand something.
The idea of types that can only contain "approved" values is the point of sum types for error handling. You either get an error, or a valid value, and there is no way to manufacture a zero value of Username out of thin air.
If you don't have that, you're not really improving on `value, ok := foo()`.
>there is no way to manufacture a zero value of Username out of thin air
As I said, with the proposal above, you cannot manufacture a default zero value from a union if you specify in the language's spec that trying to retrieve a value from a non-initialized ("not active") subtype panics (i.e. the union was constructed with a different underlying type)
asUsername := myUnion.(UserName) // panics if myUnion doesn't hold UserName internally
// if you want to retrieve the value, you type-switch:
switch myUnion.(type) {
case UserName:
// if the underlying type is UserName, it always returns the value as it was passed into the union's constructor by the developer, it doesn't matter if it was "initialized" or the developer passed some default zero'd value; it's not the union's concern at all
}
It's similar to panicking on nil interfaces, nil maps etc. The article is called "Good union types in Go would probably need types without a zero value", so I responded to that -- you don't need types without a zero value for the use case of union types. It's also different from interfaces (as you noted earlier), because this allows disjoint types.
Whether Go must allow non-zero types in general is a totally different topic.
I don't quite follow what the author is saying. Perhaps I'm missing something about Go. Also, I'm not aware if Go has a way to make stack-only structs which aren't pointers. To me it seems more like they're just indexing off the Rust philosophy that zero-values are inherently unsafe and must not be allowed at a language level.
Really I've never been convinced that default values are the huge security or safety issues they're made out to be. Perhaps there's a bunch of CVEs due to it but all the CVE's I've ever read are due to memory bounds issues, use after free, or not zeroing out data properly and leaking info. Also I'm talking about languages where all values are properly initialized to a known zero state. Not C's worst case where a value could be full of random data.
Sure there's some scenarios, say encryption keys, where default values could be an issue. However a programmer can just as easily set an encryption key to `default()` in Rust and end up with the same issue. It may be "explicit" but programmers get in habits of using things like `default()` and so it's still as likely to happen as it would with non-explicit defaults IMHO.
Requiring non-zero values isn't worth the mental overhead in the other 99.9% of cases where zero-values can simplify a lot of coding to say declare a stack array of zero-valued structs or unions, etc. In those cases you can make the zero-value of a union type where the zero value is `UnInitializedBadEncryptionKey`.
> As noted in a comment by loreb on my entry on how union types would be complicated, these 'union' or 'sum' types in Go also run into issues with their zero value, and as Ian Lance Taylor's issue comment says, zero values are built quite deeply into Go.
What are the actual issues though with zero values in Go when used with union types?
> You can define semantics for union types that allow zero values, but I don't think they're really particularly useful for anything except cramming some data structures into a few more bytes in a somewhat opaque way, and I'm not sure that's something Go should be caring about.
Why not? Generally the zero-value for a union type is just the first option, which can be useful or not. You can wrap it in an option type to ensure it's been initialized too.
If Go's semantics mean it's always a nullable pointer, then that still doesn't make union types useless. There's still value in knowing that "if there's a value, it's one of these Y options".
Though it does make sense that in Go that type assertions in Go could be used at the compiler level to say that "this variable is one of Y interfaces".
> However a programmer can just as easily set an encryption key to `default()` in Rust and end up with the same issue. It may be "explicit" but programmers get in habits of using things like `default()` and so it's still as likely to happen as it would with non-explicit defaults IMHO.
The point is you don't offer any default if there is no safe default, and so it's not so easy. Yes, a determined programmer can use any API badly. But nudging them in the right direction helps move the needle.
> Why not? Generally the zero-value for a union type is just the first option, which can be useful or not. You can wrap it in an option type to ensure it's been initialized too.
Races with the GC, if you try to be smart and have unboxed options.
Interfaces are similarly boxed to avoid such races, and make the rest of the language faster.
> If Go's semantics mean it's always a nullable pointer, then that still doesn't make union types useless. There's still value in knowing that "if there's a value, it's one of these Y options".
At that point, what's the benefit over a (boxed) interface and existing type assertions/switch?
Besides, if the options are your own types, you can also add an unexported method to the interface to seal it. And then, if you go this route, you can also have exhaustiveness checks with existing tooling.
Zero-values are not like use-after-free or similar errors, as they are actually type safe. But they consistently create logic errors, as they get bubbled up and require runtime validation which is simply something that never gets donde once at the right place.
It reminds of PHP's spirit of moving forward despite errors as far as it can go. It looks like it's working, but you can never really trust such a program much.
> To me it seems more like they're just indexing off the Rust philosophy that zero-values are inherently unsafe and must not be allowed at a language level.
I don't think that "zero-values are inherently unsafe" is a Rust philosophy? I think it's more about some sense of control and/or design - Rust shouldn't force types to have a zero value if a zero value doesn't make sense (e.g., references, NonNull<T>), and Rust shouldn't force your type to have zero as its default value if another default would be more appropriate.
> However a programmer can just as easily set an encryption key to `default()` in Rust and end up with the same issue. It may be "explicit" but programmers get in habits of using things like `default()` and so it's still as likely to happen as it would with non-explicit defaults IMHO.
default() would only be usable if the encryption key type implements Default and I think I'd be a bit on the skeptical side with respect to how likely Default would be "accidentally" implemented on it. Putting aside questions about how Default on an encryption key made it into the codebase in the first place, IIRC array types for representing crypto keys are not that uncommon and I don't think Default is implemented for arrays (by default?), so #[derive(Default)] wouldn't even compile and I'm not sure impl Default would be common enough to be something that's done out of habit.
Specific example aside, I'm not Default is one of those traits that are habitually added to structs in the first place?
> Requiring non-zero values isn't worth the mental overhead in the other 99.9% of cases where zero-values can simplify a lot of coding to say declare a stack array of zero-valued structs or unions, etc.
Would it really be that much simpler? For example, in Rust for Copy types:
[DefaultStruct::default(); SIZE]
vs.
[NonDefaultStruct { value: defaultValue }; SIZE] // other options available as well
I'm also not sure whether instances where zero values simplify things outweigh the ability to more accurately model your domain in the type system. Sure, declaring a stack array of zero-valued structs/unions might be simpler, but I feel being able to more accurately model your intended domain by making invalid states unrepresentable would be more generally useful.
> In those cases you can make the zero-value of a union type where the zero value is `UnInitializedBadEncryptionKey`.
The disadvantage to that approach is that now everything that deals with that union type needs to know about/deal with uninitialized values even if such values are only ever transient during initial construction and are supposed to be impossible otherwise. It's arguably cleaner to make such a state unrepresentable at all and use a different mechanism to represent being uninitialized. For example, Rust again:
let uninit = [const { MaybeUninit<NonDefaultStruct>::uninit() }; SIZE];
// initialize as appropriate
let init = unsafe { std::mem::transmute<_, [NonDefaultStruct; SIZE]>(uninit); }
And now all code that deals with NonDefaultStruct knows for sure it has been properly initialized.
Make the union type default instantiate as the first possible type of the union. You might think this is a pretty dumb design decision that can lead to programmer error, but it meshes perfectly with the other mistakes in the language.