←back to thread

Constraints in Go

(bitfieldconsulting.com)
210 points gus_leonel | 10 comments | | HN request time: 0.626s | source | bottom
Show context
pansa2 ◴[] No.42163816[source]
I'm surprised by the complexity of Go's generic constraints, given the language's focus on simplicity. Things like the difference between "implementing" and "satisfying" a constraint [0], and exceptions around what a constraint can contain [1]:

> A union (with more than one term) cannot contain the predeclared identifier comparable or interfaces that specify methods, or embed comparable or interfaces that specify methods.

Is this level of complexity unavoidable when implementing generics (in any language)? If not, could it have been avoided if Go's design had included generics from the start?

[0] https://stackoverflow.com/questions/77445861/whats-the-diffe...

[1] https://blog.merovius.de/posts/2024-01-05_constraining_compl...

replies(5): >>42164004 #>>42164048 #>>42164801 #>>42164982 #>>42176376 #
1. jerf ◴[] No.42164801[source]
In practice, none of this impacts your program. The standard advice I give to people messing around with this stuff is, never use the pipe operator. The standard library already implements all the sensible uses of it.

In particular, people tend to read it as the "sum type" operator, which it is not. I kind of wish the syntax has used & instead of |, what it is doing is closer to an "and" then an "or".

By the time you know enough to know you can ignore that advice, you will. But you'll also likely find it never comes up, because, again, the standard library has already implemented all the sensible variants of this, not because the standard library is magic but because there's really only a limited number of useful cases anyhow. I haven't gone too crazy with generics, but I have used them nontrivially, even done s could tricks [1], and the pipe operator is not that generally useful.

When the generic constraint is an interface with methods is the case that can actually come up, but that makes sense, if generics make sense to you at all.

It probably is a good demonstration of the sort of things that come up on generic implementations, though. Despite the rhetoric people often deployed prior to Go having them, no, they are never easy, never without corner cases, never without a lot of complications and tradeoffs under the hood. Even languages designed with them from the beginning have them, just better stuffed under the rug and with less obvious conflict with other features. They're obviously not impossible, and can be worthwhile when deployed, certainly, but it's always because of a lot of work done by the language designers and implementations, it's never just "hey let's use generics, ok, that one sentence finishes the design I guess let's go implement them in a could of hours".

[1]: Just about the edge of the "tricky" I'd advise: https://github.com/thejerf/mtmap

replies(2): >>42164999 #>>42171108 #
2. tapirl ◴[] No.42164999[source]
> In particular, people tend to read it as the "sum type" operator, which it is not. I kind of wish the syntax has used & instead of |, what it is doing is closer to an "and" then an "or".

I don't understand here. In my understanding, the pipe operator is indeed closer to "or" and "sum type" operator. Interpreting it as "and" is weird to me.

replies(1): >>42165029 #
3. Groxx ◴[] No.42165029[source]
I think they're reading it as "a bitwise-and of the functionality of the types passed", which is accurate (since you're getting the lowest common denominator of all |'d types).

I'm... not sure which way I lean tbh, now that I've seen that idea. Both have merit, it's more of a problem for educational material than anything. If you present it as "these types", | makes sense. If you instead use "these behaviors", & makes sense. | is slightly easier to type for me though, and & has more meanings already (address-of), so maybe I'd still favor |.

replies(1): >>42165169 #
4. tapirl ◴[] No.42165169{3}[source]
Okay, it is some reasonable if the operator is viewed as a behavior operator. But it is not, it is a type set operator.
replies(1): >>42165887 #
5. jerf ◴[] No.42165887{4}[source]
And the real point I'm making here is that "the type set operator" is not "a sum type". A sum type with, say, three branches is either the first, or the second, or the third, and to do anything with any of them, you have to deconstruct it, at which point you have full access to the deconstructed branch you are in. The | operator in a Go generic is more a declaration of "I want to operate on all of these at once", so, you can put multiple numeric types into it because you can do a + or a - on any of them, but while the syntax permits you to put three struct types into it, and it'll compile, it does not produce a "sum type". Instead you get "I can operate on this value with the intersection of all the operations they can do", which is more or less "nothing". ("Methods" aren't "operations"; methods you can already declare in interfaces.) Some people particularly fool themselves because you can still take that type, cast it into an "any", and then type switch on it, but it turns out you can always do that, the | operator isn't helping you in any particular way, and if you want to have a closed set of types, a closed interface is a much better way to do it, on many levels.

It also doesn't currently do anything else people may want it to do, like, accept three structs that each have a field "A" of type "int" and allow the generic to operate on at least that field because they all share it. There's a proposal I've seen to enable that, as the current syntax would at least support that, but I don't know what its status is.

replies(1): >>42166057 #
6. tapirl ◴[] No.42166057{5}[source]
There is actually a proposal to make type constraints act as sum types: https://github.com/golang/go/issues/57644

But I doubt sum types will be supported perfectly in Go. The current poor-men's sum type mechanism (type-switch syntax) might be still useful in future Go custom generic age.

replies(1): >>42172907 #
7. BlackFly ◴[] No.42171108[source]
It is precisely a sum type, no? https://go.dev/play/p/xlRegSDYytg

As defined, the set of the type Ordered is exactly the sum of all elements of int, uint and string. The intersection of int and string would be empty. The or symbol makes sense because an element of Ordered is either a uint or an int or a string. An element of Ordered is not a uint and an int and a string.

It feels to me that static typed languages tend to give you intersection bounds and not union bounds. Rust has intersections, java has intersections. Meanwhile, if you have duck typing then you end up with a bunch of union types (see python -> mypy, javascript -> typescript). There are of course the general union types (not generic bounds) in C/C++/rust which kind of behaves in a similar fashion.

replies(1): >>42172891 #
8. jerf ◴[] No.42172891[source]
No, you have created "a type that can be one of int, uint, or string, or anything that backs directly to them". They all can >, and since that's the only thing you used in Max, everything works fine. You don't have a sum type; you have "a type that is either an int, a uint, a string, or something that backs to them", specifically. It doesn't come in as any sort of sum type, it is specifically one of those types directly.

For one thing, as you've specified it, you don't even have a closed set of types. Off in another package I can declare a "type MyInt int" and use your Max on it, so if you tried to type switch in your Max function, you would not know about my type, and it is arguably the defining characteristic of a sum type that you can know all the branches it has.

You can fix that by knocking off the tilde, but then you still have the problem that it is not legal to use "switch val := a.(type)", which is basically the level of deconstruction of a type that Go permits, because when the Max function is running, it is not running on a value of type "Ordered"; it literally has a value of the type you passed in. That's the whole point of generics, to have values of the concrete type that was passed in, and not a sort of "sum type".

https://go.dev/play/p/MGhRjwvpdTh

Note you don't get "Ordered". You get the specific types. That's not any sort of "generic weirdness", that's the real situation. That's why you can only use the intersection of operations they all support.

If you want a sum type in Go, use closed interfaces: https://github.com/BurntSushi/go-sumtype If you're willing to accept what you've written as a sum type, you should be even more willing to accept this method, which actually produces a reasonable approximation of one.

replies(1): >>42178708 #
9. jerf ◴[] No.42172907{6}[source]
I've pondered the utility of just proposing some syntax sugar around the current methodology, mostly for the reason of getting it rejected for being an unnecessary redundancy to what we already have, so we can point at the rejection.
10. Groxx ◴[] No.42178708{3}[source]
I broadly agree with you, so this isn't to disprove you or anything, but in case you hadn't seen it before: you can do type checks inside generic functions. You just have to trick the compiler / do pointless boxing because the compiler is overly simple.

This fails: https://go.dev/play/p/3J4urjOU6lc

    v, ok := thing.(target)
But this works: https://go.dev/play/p/Zb_fnAMaqZb

    v, ok := ((any)(thing)).(target)
It's basically because generics are generated code for specific types with little more than text replacement, and type assertion only works on interfaces, and it can't rule out non-interfaces. But if you box it in an `any`, it's fine, just like it's fine to `((any)(5)).(int)` anywhere else (or any other equivalent construct).