1. 15
  1. 5

    Generics in Go are probably gonna be a big deal, and the implementation looks nice and everything.

    But I’m a bit concerned that the rest of the language doesn’t really “support” generics as well as most other languages which feature them. Take the Max example from the article:

    func Max[T constraints.Ordered](x, y T) T {
        if x > y {
            return x
        }
        return y
    }
    

    This is a Max function which can take “any ordered type”. But “ordered” here just means the types which have comparison operators – which is a fixed set of built-in types. The Max function works with the signed integer types, the unsigned integer types, the floating point types, and strings. And that’s much better than the situation before generics where each built-in type would need a separate Max function (or, more likely, as is the case with Go’s standard library, just support float64 and require lossy casting between your type and float64). But it’s extremely limiting compared to any other generics I’ve heard of, because no user-defined type can be an “ordered” type.

    Generics (well, templates) work in C++ because a user-defined type can implement essentially all the operators a built-in type can, and can therefore act indistinguishably from a built-in type. A max function template in C++ can use the < operator to compare, and then any type with a < operator will work with that function.

    Generics “work” in Java because the generic type must be an object, not a primitive, and so a generic function can just require that the type implements the Comparable interface, which is implemented by all the relevant boxed types (Integer, Float, etc), and can be implemented by all user-defined types. So again, a max function in Java can use the compare method to compare, and then any type which implements Comparable will work with that function.

    Go is somewhere in the middle, where you can’t overload operators, so you can’t make a custom type act like a built-in primitive type, but it also doesn’t require boxing so people will be reluctant to make generic functions which require boxing (after all, in Go, that would be kind of ridiculous; you might just as well use runtime polymorphism using interfaces if you’re gonna box your types anyways).

    Maybe this turns out to not really be a problem in practice. Or maybe it turns out to be one of those things which Go users live with and Go haters bring up in every discussion as (valid, if overblown) criticism. I’d be curious to read other people’s thoughts on this. Has it been discussed in the Go community or core team? Does anyone here have experience with generics in other languages and in Go?

    Maybe the solution will be to pass in the operators/functions you need? A proper generic Max in Go could be written like this:

    func Max[T any](x, y T, compare func(T, T) int) T {
        if compare(x, y) > 0 {
            return x
        } else {
            return y
        }
    }
    

    Though this looks like it would be rather cumbersome to call all the time. I’m also slightly worried about how smart the Go compiler will be when it comes to inlining that comparison function, which will be important to reach performance comparable to C++ templates.

    All in all, generics look like a positive development for the Go language, but from what I can tell, I think it demands that we re-tread old discussions about operator overloading, function overloading, or if there are better ways to achieve what we need.

    1. 3

      But “ordered” here just means the types which have comparison operators – which is a fixed set of built-in types.

      No, constraints.Ordered matches all types who underlying types are one of those builtin types. See source code and note the ~ notation in interfaces. For example, time.Duration is defined as type Duration int64 for example and satisfies this constraint.

      This mechanism doesn’t inherit any methods, but it does inherit all the operators. (The builtin primitive types don’t have methods, but if you do type A int64 and then type B A, B doesn’t inherit methods from A.) I don’t know whether this was an intentional design decision or not, but it now certainly poses a practical challenge for treating operators like methods (which is probably never going to happen anyway).

      Operator overloading has also been explicitly rejected in the Go FAQ (see https://go.dev/doc/faq#overloading). In comparison, the FAQ never explicitly rejected generics - it used to say something like “it’s an open question, we may or may not add it”. Some people took that as a corp-speak way of rejecting generics, but it has now turned out to be an earnest statement.

      1. 3

        Fair, I suppose “fixed set of built-in types or type aliases of those built-in types” would be more technically correct. I don’t think that materially changes much of what I wrote, but it’s an important detail.

        1. 2

          These aren’t type aliases. Type aliases look like type T = int64 (note the =) and T becomes equivalent to int64 everywhere. Defining type T int64 creates a new type. The new type has the same machine representation as int64 and “inherits” the operators (which work between values of type T, not between T and int64) - but nothing else. The new type is convertible to int64 (and vice versa) that an explicit type cast is needed.

          This is probably the more quirky aspect of Go’s type system. It is internally consistent though. If you have two type definitions type T struct{} and type U struct{}, these are not the same types and have distinct method sets, but they can be converted to each other with an explicit type cast.

      2. 2

        But it’s extremely limiting compared to any other generics I’ve heard of, because no user-defined type can be an “ordered” type.

        Just to clarify, are you saying it’s limited because there’s no way to implement constraints.Ordered yourself, or because there’s no way to implement a generic “ordered”? If it’s the former, then yes, right now there’s still a difference between primitives and types with methods. If it’s the latter, then the self-referential trick documented in the type parameters proposal can implement what you want:

        package main
        
        import "fmt"
        
        type Lesser[T any] interface {
        	Less(T) bool
        }
        
        func Max[T Lesser[T]](a T, more ...T) T {
        	max := a
        	for _, candidate := range more {
        		if max.Less(candidate) {
        			max = candidate
        		}
        	}
        	return max
        }
        
        type FlippedInt int
        
        func (n FlippedInt) Less(other FlippedInt) bool{
        	return n > other
        }
        
        func main() {
        	a := FlippedInt(1)
        	b := FlippedInt(2)
        	c := FlippedInt(3)
        
        	fmt.Println(Max(b,a,c)) // Prints 1
        }
        

        I wouldn’t say this is “extremely limiting” though. It just means there has to be two methods instead of just one, and you need to pick the right one based on what kind of type you’re dealing with. I mean, OCaml has two different operator sets for integers and floats, and it’s not considered “extremely limiting” from what I gather.

        For containers, they’ll likely be implemented by using a comparison function underneath. If the community/standard library decides on a particular pattern, you’ll likely have one convenience function for the ones that are constraints.Ordered, another for Lesser (if that ever becomes a thing), then a raw one which lets you specify whatever comparison function you want.

        1. 2

          You can write func Max[T constraints.Ordered | interface{ Less(T) bool }](a, b T) T, but with the current implementation, it’s hard to do a type switch inside of Max to figure out if you got something with a Less method or not. It can be done, but it requires reflection or some other trickery. There’s a proposal to allow type switching on T, and I think it will happen in 1.19 or 1.20. Everyone wants type switching, and it’s just a matter of ironing out all the weird corner cases with it.

          1. 2

            One alternative to switching would be to have methods which duplicate the effect of the binary operators. So instead of having to have FlippedInt , we’d have an action .Less method on all types which are ordered, so generic code which wanted to support - say bigint and int64 - could just call the methods and eschew the operators.

            One nice side effect is that the method sets define natural interfaces which can be used as constratins, and I think you no longer need typesets.

            Do you happen to know if this has been discussed (or if it has obvious negatives I haven’t seen)

            1. 2

              Yes, it was discussed. It turned out be hard to get a set of methods that captured things like, uint can’t be negative, int8 can’t be more than 128, etc. so they need type classes, and then at that point natural methods are redundant.

            2. 1

              You can try casting as the interface just like any other type cast, where the second return value indicates if it was successful.

            3. 1

              Author here. I agree, the current implementation is limited!

              Coming from C++, not being to implement comparison operators on user-defined types is a big gap. The core team actually acknowledge that in their generics presentation at GopherCon this past moth. I’m not sure if they plan to address this gap, but it does feel like generics (at this point) are really designed for primitives.

              When I was first exploring the constraint system, I was excited to see that, in theory, any interface could be used in a generic type set. But then I had to pause and question when I would use type-set contained generics and would I would use method-set constrained interfaces. In the absence of user-defined operators, I don’t think there’s a compelling reason to switch from the latter to the former.

              For example, consider two user defined interfaces Foo and Bar. Now we have the option to define a type-set

              type Buzz interface {
                  Foo | Bar
              }
              
              func Do[T Buzz](x T) {
                  x.F()
              }
              

              But we already had a variant on this pattern with the Reader, Writer, and ReaderWriter interfaces, which (in my opinion, at least) is one of the more powerful interfaces in the standard library.

              type ReadWriter interface {
              	Reader
              	Writer
              }
              
              func Do(rw ReaderWriter) {
                  rw.Read(...)
              }
              

              So that leaves the question of “what’s the advantage of generics over ‘compound’ interfaces”. I argue that being able to generalize over operators is the biggest selling point. We’re never going to implement OOM patterns like Java, so to your point, yeah, we’re sitting in limbo until we can do operational comparisons on user defined types.

              All that said, I think the constraint system is a compelling and clean implementation. If anything, I want to see how they expand on this concept to include variables etc. I’m confident this constraint system can be leveraged elsewhere with the right support.

              1. 1

                Coming from C++, not being to implement comparison operators on user-defined types is a big gap.

                I don’t want comparison operators to be overloaded, but I do wish we could define methods on any type so we could define a “Less() bool” method on builtin types to make them implement a more general interface. This comes up a lot, and creating a subtype doesn’t work because there’s no cheap way to cast a slice of ints to a slice of MyInt, for example. And on top of that it’s not very ergonomic to have to subtype (remember the days before sort.Slice(), anyone?).

            4. 2

              okay, I gotta know how the author did Day18

              1. 1

                Author here. I used ASTs for day 18, though I spent far too much time attempting a regex/sting parsing solution. Ultimately if you can parse the input down to an abstract syntax tree, it’s a matter of a tree traversal. Happy to give more hints if you’d like!

                1. 1

                  I wrote my original solution in a functional fashion (via Julia). I guess Go’s generics wouldn’t be much assistance since they don’t work on runtime types?

                  1. 1

                    Yeah, Unfortunately I didn’t see generics being of much use. Maybe the standard AST lib could benefit, but there wasn’t much room in my implementation of it.