9 ms·
Good union types in Go would probably need types without a zero value
- akira2501 2y ago> 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.
- TheDong 2y ago> 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.
- XorNot 2y agoI 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?
- rto_victim 2y agoAgreed. 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]. [1]: https://gvolpe.com/blog/error-handling-scala3/ https://gvolpe.com/blog/error-handling-scala3/
- andyferris 2y agoRoc'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).
- tubthumper8 2y agoUntagged unions lack the ability to model "the success value is a string and the error is a string", right?
- shepherdjerred 2y agoGo is great for certain kinds of programmers. 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.
- cmrdporcupine 2y agoExactly 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"
- masklinn 2y ago> I might even pick it over Java, which I think is kinda... barely statically typed. Go’s type system is really not much better than Java, if it’s not worse.
- elcritch 2y agoI 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".
- aw1621107 2y ago> 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.
- chrismorgan 2y agoHow 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.)
- jim-jim-jim 2y ago> 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`.
- lmm 2y ago> 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.
- treyd 2y agoThis 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.
- pqn 2y agoThis reminds me of `tiny::tombstone_traits` in C++: https://github.com/foonathan/tiny#tombstones https://github.com/foonathan/tiny#tombstones
- cglan 2y agoI 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.
- bobbylarrybobby 2y agoI don't understand, why did Go ignore the past ~30 years of PL insights?
- School-Cotton 2y agoBecause the designers of Go believed that most programmers at google are too stupid to understand and use them.
- klodolph 2y agoI 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?
- kleton 2y agoThe exact quote > 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
- lostdog 2y agoHere's a very go-like way to solve it. 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.
- jchw 2y agoHonestly, 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.
- deleted 2y ago[deleted]
- klodolph 2y agoThe 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.
- noelwelsh 2y agoWhy 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.
- adhamsalama 2y agoWhat do people think of this https://github.com/samber/mo https://github.com/samber/mo?
- kgeist 2y agoGo 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.
- yencabulator 2y agoBut then you lose all of the benefits of sum types, so what's the point? You can do roughly what you're asking today with interfaces.
- kgeist 2y agoWhat are the benefits? You mean exhaustive match or...?
- yencabulator 2y agoSo 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 pub struct PrimeNumber(u64) impl PrimeNumber { pub fn new(n: u64) -> Result<PrimeNumber, PrimeError> { ... } } 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.
- 2y ago
- relistan 2y agoThere 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.
- nilptr 2y ago> 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.
- relistan 2y agoSo shoehorning something into the language is a shorter path to success. Yeah that checks out.
- the_gipsy 2y ago"just use something else" hasn't worked out for generics either.
- nine_k 2y ago> just use something wise. Sometimes autocorrect is serendipitous.
- deleted 2y ago[deleted]