← Back to context

Comment by YesThatTom2

8 hours ago

"The best way to teach something new is to compare it to something the audience already understands."

Could someone take the example, reduce it to a non-generic version for two types I DO understand, then show that with the new feature I can collapse them into the Box/Map example in the doc?

I have 10+ years of Go experience and I can't make heads or tails of "(b Box[T]) Map[U any](f func(T) U) Box[U]"

    type IntBox struct { v int }
    type StrBox struct { v string }

    func (b IntBox) MapToStr(f func(int) string) StrBox {
        return StrBox{v: f(b.v)}
    }

(Please forgive any typos I made on mobile.)

It wasn't a great example because "Box" isn't really a useful type. But the point is that you no longer need to define a separate "MapToXXX" method for every type you might want to map to; now you can have just one type-generic "Map" method.

I think part of the issue is that they didn’t explain what is gained by adding generics on methods. You’re right that a few examples here are useful.

Without generics:

‘’’

type Stream[T any] struct{ ... }

func (s Stream[T]) MapInt(f func(T) int) Stream[int] { ... }

func (s Stream[T]) MapString(f func(T) string) Stream[string] { ... }

‘’’

Then later you need to map floats:

‘’’

func (s Stream[T]) MapFloat64(f func(T) float64) Stream[float64] { ... }

‘’’

Then later someone outside the package wants to map a custom type. Too bad! They’ll need to make some custom wrapper that doesn’t follow the pattern.

With the genetics approach the semantics are defined once and usable in all scenarios. You don’t need to keep adding methods; instead the caller can provide the mapping function. Common mapping functions could be pre defined for convenience.

It's not a great example.

I think it's trying to show a mapping operation for a generic container where the container values are of one type and the mapping function is allowed to return a container with values of a different type.

Without generics, something along the lines of the following (with runnable example at https://go.dev/play/p/KHBI1uAhbO0):

  type MySlice []int

  // Map maps from a slice of ints to a slice of float64s.
  func (s MySlice) Map(f func(int) float64) []float64 {
     var out []float64
     for i := range s {
         out = append(out, f(s[i]))
     }
     return out
  }

From a quick search, this seems to be better explanation of this new 1.27 feature:

https://www.gopherguides.com/articles/golang-generic-methods

(That uses an example that seems similar in spirit to the Interactive Tour's example, but with a more useful type of a Stack[T] and corresponding explanation seem clearer.)

  • And to answer the second half of your request, here is that exact same code as above, but now using the 1.27 generic methods feature (with a runnable example using tip at https://go.dev/play/p/1YK62tGetsm?v=gotip):

      // Map maps from a slice containing type In to a slice containing type Out.
      func (s MySlice[In]) Map[Out any](f func(In) Out) []Out {
          var out []Out
          for i := range s {
              out = append(out, f(s[i]))
          }
          return out
      }
    

    In short, you could always have methods on a generic type since Go first introduced generics in Go 1.18, but with 1.27, the methods on the generic type can also introduce their own additional type parameters.

    (Previously, you could achieve the same net effect with a top-level generic function, but then the code would not be grouped as nicely as hanging it off of the type, and arguably it now can have slightly better ergonomics in some cases. You can see more of the rationale from Robert Griesemer at https://github.com/golang/go/issues/77273.)

This is the kind of shit why they probably didn't want generics in the language.

This is like building a very crude general-ish DSL inside the language. Because the tools are intentionally limited (as to limit the scope of the feature), the result looks ugly. Also, like with C++ templates, people find exploits to do what the designers didn't want them to, with even more elaborate workarounds.

I liked Go before generics. It had a clear identity. If you wanted to get cute, you could use go generate and generate code. They should've made that much more convenient and ergonomic, if they wanted to make the language more powerful (and the nice thing is that it still sits outside of the language).

I think the point Go was making is that these complex things generally have little use in application code, and 99% of the time they're there for people who want to show how smart they are, at the expense of code readability, and accessibility.

  • As a bit of a counterpoint, I help maintain SDKs for Hatchet in multiple languages (largely Python and TS, but also contribute to Go a bit too) that benefit heavily from generics. It's especially useful for things where users of the SDK provide e.g. return types from functions they register, and we want those to be strongly-typed elsewhere in the codebase. A simple Python example is:

      @hatchet.task()
      async def my_task(...) -> SomeOutputType:
        return SomeOutputType(...)
    
      ## imagine this is an API handler:
      @api_handler("/some/path")
      async def handle() -> ...:
        result = await my_task.run(...)
    
        # we now know `result` is of type `SomeOutputType` without any sort of type assertion, etc.
    

    Admittedly, I'm not a Go expert, nor am I a programming languages expert. But I do feel that this type of behavior is really only possible (with nice ergonomics) with generics, and it's always been upsetting to me that somehow Python's type system feels more complete than Go's in this arena, or at least it has until more recently.

    Maybe this falls into the 1% of cases, but I'd suspect this sort of thing is more common than that.

    Edit: I should have mentioned - in the Python example above, `@hatchet.task` is generic with the output type of the task it wraps.

  • I think you're right and it is sad to see. I think had they stuck to their guns, the language might not feel like it has lost the point.

If you instantiate it with concrete types, does "(b IntBox) Map(f func(int) string) StringBox" make more sense? You have a collection (in this case Box) containing values of type T, a function that maps values of type T to type U, and if you apply that function to all elements in that collection you get a collection of type U.

  • Only they are not using a collection and therefore "Map" was a confusing choice for the method name (maybe "To" would have been a better name?).

    • A one item collection is admittedly a lame one, but it is a collection. Pointers are collections of zero or one items in a sense.

      Now, the verb "map" is the traditional name for this operator, but I agree it sounds confusing when the noun "map" is also a collection type. C# and SQL call this Select, if that helps.

    • Is that really the confusing part? If you had a general collection interface, say Mapper, a Box (i.e. a singleton set) could implement it just as well as a List or a Tree.

      2 replies →