Frankly, having used Go for years, I started using generics right after they were released and I'm very happy with them so far.
Sure, the performance characteristics could be improved, but other than that they solve all my main pain points I wanted them to solve while being constrained enough to not result in ivory towers on every corner.
My main pain points having been duplicated functions for each type as well as data structures.
With Go, I use a decent amount of generated code to create repetitive bits of code that only have small variations (e.g., the type!). Most of this code seems to not be possible for me to replace with generics yet, so I'm still finding myself using generated code. For example, a bunch of structs may share a field, but Go generics don't yet support accessing a field that is shared by types accepted by the interface.
I was recently thinking that I would really like "field references" in go, e.g. via type.Field, like you can do for methods to get unbound methods. Partly because it might enable generics over "contains field(s)" instead of just "contains method(s)".
In the meantime I guess there's always anonymous functions (to use as accessors). They're not hard to be generic over.
I haven't tried benchmarking a cached reflection-driven field reference, now that I think about it... I'll give that a try, very good point.
If that performs the same as a normal field access (or the equivalent behind a non-inlined function or something), yeah, that'd be completely fine. My main thought was that you can very easily reference and use `type.Method` (you just have to pass an instance as an additional first argument), so a straightforward `type.Field` equivalent would be convenient in a number of places. E.g. you could avoid the need to add accessor methods, which isn't possible on types you don't control, and no need to create anonymous functions everywhere to work around that.
From poking at this with benchmarks, results are basically:
Hiding an anonymous `return it.field` behind an interface{} in an attempt to stop inlining or other optimizations: 2x worse than directly accessing a field (same as direct access when not doing the interface dance, so it's preventing something at least). So "excellent performance", though there may be optimizations happening in a benchmark that aren't realistic in a larger system.
Using `reflect.ValueOf(it).Field(0).String()` each time: 60x worse than direct field reference. Go reflection remains pretty fast, but that's rather noticeable.
There does not appear to be any way to cache ^ that reflection operation. I.e. I don't see any way to go from `reflect.TypeOf(it).Field(0)` to "read that field from this instance".
---
And somewhat surprisingly: `m := instance.Method; benchmark{ m() }` performs ~10x worse than `benchmark{ instance.Method() }` and `m := type.Method; benchmark{ m(instance) }`. There's no intentional optimization-defeating here, so it is entirely possible this difference disappears in practice, but I'm surprised that they're not identical.
And I couldn't figure out a way to get a reference to a pointer-implemented method, e.g. `m := (&type).Method` or something. It only works on `m := type.Method` when the method is a value receiver. Possibly I'm missing something obvious.
---
I kinda want to redo ^ these and check compiler output closely, and try adding more packages / make more realistic optimizer-information-loss like you'd see in more normal go code. I suspect there are still some unrealistic ones occurring. But I've gotta drop this for now, so that's going on my ever-growing todo list.
I don't really know what you mean by "optimization-defeating" here or in your other comment; unless the language offers some heavy duty compile-time partial-evaluation, there are fundamental costs to certain indirections. It's not just the result of the optimizer deciding not to inline or whatever, a dynamic jump costs more than a static jump.
I’m also not sure about the `benchmark{ ... }` syntax, but:
m := instance.Method; benchmark{ m() }
In the general case, this will require allocating a closure and making a dynamic call.
m := type.Method; benchmark{ m(instance) }
This will require making a dynamic call.
benchmark{ instance.Method() }
This is a normal static call. It will be fast, even if not inlined. (Inlining today is often more critical as a supporting optimization for devirtualization than avoiding the minimal cost of a static call.)
---
reflect.ValueOf(it).Field(0).String()
`String()` is a bad case for a comparison here because it works and incurs a higher cost even if the field isn't a string.
60x sounds bad, but I really cannot stress enough: As soon as you're not doing a simple fixed-offset memory fetch from a base pointer, you might be paying 10x anyway no matter how good your optimizer is. That's just the cost of unpredictable memory access, even before we start talking about downstream impact on the code generator.
If you only want to support direct fields and not really the equivalent set of named fields you could type in a Go program, you can do some tricks with unsafe.Offsetof which will be faster, and probably cachable. But it's also called unsafe for a reason.
`benchmark {...}` is just some shorthand because the actual code is boilerplate-filled. It's not valid Go.
Optimization-defeating: go infers when to move things the heap vs keep it on the stack, and does somewhat aggressive in-function optimizations and inlining that it does not do cross-function. One common, very simple, and reasonably effective way to prevent some of that is to erase type information by passing a value through an interface{}. Even if you immediately unpack it and reuse the reference, Go has no jit, so it gets the job done alright. There are some other things that don't survive cross-package analysis, last I looked, so using multiple packages can also help make your benchmarks more realistic. It takes a lot more effort to be truly realistic in even one benchmark, much less the 9 various flavors that I tried.
And the String() piece is because I had it return a string because why not. Variable-sized data is extremely common so it's realistic enough, and using the correctly-typed reflection funcs usually avoids some reflection and boxing and allocation costs that I didn't feel like trying to address in more detail.
None of which was written out explicitly because there are tons of caveats regardless of the care I tried to take, so I just mentioned that they existed and moved on because I couldn't spend more time at that time.
It definitely won't match the performance of normal field access, just as passing an unbound method reference won't be as fast as calling it directly, or calling through an interface won't be as fast as calling on a concrete type.
The best performance you'll get is probably from putting the fields together into a substructure and passing around pointers to that. Which doesn't need any reflecting or additional language scaffolding.
Why would an unbound method be any different than a bound one? They both involve exactly the same function table lookup, and execute against data that's somewhere else.
> They both involve exactly the same function table lookup, and execute against data that's somewhere else.
It's not that it's bound vs. unbound, it's that you passed it somewhere else so the call is necessarily dynamic. It would happen with non-method functions also. (In neither case is there a "function table lookup" - that's rather if you call through an interface / generic dictionary.) A bound one would be even more expensive because you also have to allocate a closure for the receiver.
The point is: You want some non-compile-time behavior, whether that's a function or field access, you're going to pay more. That time, not regular field access, is what should be compared to reflect performance.
> For example, a bunch of structs may share a field, but Go generics don't yet support accessing a field that is shared by types accepted by the interface.
You can easily and cleanly work around this by exposing getter and setter method in your interface.
Yeah, you can, but then you need to expose those fields for a whole bunch of similar types. If I took that approach, I'd use generated code to reduce the repetition, and still in the same boat -- I'm either generating the functions for each type, or generating the getters/setters for each type.
Rust is the same I think, where you'd need to have methods that explicitly implement the trait (interface in Go). However, Rust has a built-in code generation tool (macros), and you can decorate your structs to have it automatically implement some traits for you. That's the equivalent of adding one more entry to your Go generated code loop, except that it's right next to the struct.
If Go did support field methods (which it looks like they may one day), that might be the tipping point for me for disabling a bunch of generated code. Not sure if there's other things getting in my way, because I gave up once I noticed I couldn't do that.
BTW, accessing common fields is temporarily disabled before releasing Go 1.18, because there is another fundamental problem which needs to be resolved firstly.
Which other language has interfaces specifying field access, that does not go through indirect method call anyway? I guess if you consider C++ templates to be interfaces, that would apply, but C++ templates are even less clean (given that there isn’t even any clean way to specify interface expected by C++ template, all you have is type traits, which are opposite of clean).
Of course, this doesn't exist in Go, so we must do:
func f[T interface{GetFoo(); SetFoo()}](T) {}
And then define setters and getters on every type we want to use, including defining new types (with the commensurate setters/getters) for types that we don't own (i.e., if you import a struct from another package and it doesn't have setters and getters defined, you have to create a new type that implements those setters/getters).
I don’t know Go, but I think less in that concepts are syntax guarantees, not semantic guarantees. I suspect interfaces (assuming they are opt-in) are semantic guarantees. That said, I’ve never run into this distinction being a problem, and you could provide opt-in mechanisms in C++. Of course, the C++ interface has zero runtime cost.
Where you see "modern", I see complicated type systems that solve the least interesting pains when programming. What they do provide is a feeling of "solving a puzzle" when all your type signatures are matching your usage patterns, but then again it's easy to mistake the puzzle solving with actual productivity.
Rich Hickey's talks can be very insightful in this regard. He surely convinced me :)
I think type systems have diminishing returns. I’ve used Python professionally for 15 years and a whole lot of time is wasted trying to figure out exactly what properties a given parameter must have. In the best case, you have stuff like “file like object” which doesn’t tell you if it just needs a “read()” method or also write, seek, close, etc. In the common cases you have annotations that are incorrect or outdated.
On the other extreme, you have Rust and Haskell type systems where you spend gratuitous time on pacifying the type checker for negligible quality gains (arguably negative gains).
Violently agreeing with this. Both extremes hinder productivity, just at different times during the development cycles.
It's easy to confuse type systems with correctness or clarity of thought. I've fallen victim to this myself – "oh this didn't map well to Rust's type system, I must be thinking wrong", kinda. It tickles this ocd nerve and I lose focus on the problem I'm trying to solve, often without realizing that I'm just wasting time.
I also like Go for these reasons. It's dumb and predictable, which let's me focus on the problem I'm solving. My only annoyance is really verbosity and small amounts of boilerplate.
Yeah, and there is a time and place for rigorous static analysis. If you’re writing embedded code for a car which is mission critical and prohibitively expensive to update frequently (firmware updates require people to bring their cars into dealerships for technicians to update), then you probably want to verify everything you can. On the other extreme you have SaaS software where you can get a bug report, diagnose it, and deploy a patch in an afternoon and no one’s life is at jeopardy (but the rewards for rapid iteration are huge).
Unfortunately, this nuance is missing from the discussion with people mostly talking about software as though it’s all mission critical / infrequently updated / rapid iteration doesn’t matter which is exactly incorrect for the general case.
When programming in the small, I can see where a robust type system might feel like it's adding complexity and "getting in the way".
When programming in the large, type systems are flat out an unmitigated win. The more you can confidently assert about your program before run time, the more time and money your team will save. If you are programming a large system and not using strong static typing, optimally with higher-level features like parametric types, then you are doing it wrong.
Not necessarily. This smells like a badly architected system with strong coupling and mixing of concerns. The overall complexity of the complete solution doesn't have to make each small module complex as well. And if the modules are not complex, type systems are not strictly necessary.
In the early stages of developing something complex you often want to play fast and loose with the type system - get some code running, and some tests set up, and go from there. But later on you want to refine it and start catching some more errors statically.
The pendulum has swung really far to static typing - and I get why. But if it swings back I hope we don't forget about gradual typing.
Gradual typing sounds like the worst of all worlds. You end up having to type things so you lose the supposed implementation speed of dynamic typing. But then you also can't rely on all the types being checked.
I'd argue it's the exact opposite - the best of both worlds. But it is what you make of it.
The poster child of gradual typing has to be TypeScript, and it's been a wild success. TypeScript allows gradual implementation to any JavaScript project and there's almost a million small different gradients you can use. From simply using a js file, to noImplicitAny, to simple types, to using complicated built-in types (eg Partials).
With TypeScript, you never lose the speed of dynamic typing. The whole point of gradual typing is you can jump between more and less types, so at no point are you restricted from reverting to full dynamic typing. And it is true, you can't rely on all types being checked, but the point is you add it to the important/stable bits. And having your largely stable important bits being typed while your more experimental unstable code can be dynamic (while still getting the benefit of consuming the stable types) is a nice place to be.
As I said, it is what you make of it. If you spend all your time typing the unstable bits and leave the stable core bits untyped, well, you've just wasted your time and would get the worst of both worlds.
My only experience with gradual typing is in Dart and I was surprised that I got runtime errors around types that should have been correct. I don't follow Typescript but guessing its a wild success because it checks your types and gradual typing is a way to incrementally adopt the language. Regarding wasting time, type inference takes a lot of boilerplate out of the strictly typed code.
I'm not kidding. Go is at a very nice verbosity level as far as reading code goes, but writing it is indeed tedious occasionally. However, in practice, Copilot is able to handle those bits extremely well, giving us the best of both worlds.
How does it do if I need to change one small thing in every copy of the code I generated?
(Edit: This is a serious question; I recently semi-seriously commented that maybe solutions like Copilot are particularly well suited to solve this problem with Go, so I'm curious how well it works in practice.)
I think we're talking about different kinds of code generation.
I'm mostly talking about boilerplate like writing `if err != nil` with a nice wrapping message every other line. Copilot will just write 2-3 lines at a time. That's imo the kind of boilerplate that might often be annoying to write.
I'm not talking about generating multiple copies of the same function nor anything like that. That would be better suited to deterministic generation or copy+paste. Copilot doesn't help you here.
Ok that makes sense. You don't need something as powerful as Copilot for that kind of boilerplate though, there have been "snippets" / macro things for that for forever.
My bigger annoyance is when I want to write non-trivial functionality that works for different data types.
Interesting, it's the error messages themselves that it's good for, rather than the boilerplate; I assumed you just meant the boilerplate around the error message. That kind of surprises me, but I guess I shouldn't be shocked that people are handling the same errors with the same messages over and over again without any way to encapsulate the pattern. Not shocked I guess, but that is disappointing.
Each error wrap has a situation-dependent wrapping message, different for each wrapped error in the function.
Copilot is great at writing the whole `if err != nil`, including context-specific wrapping messages. It's also great at writing your code in many other situations where the intent is clear and simple, but it just takes a few lines to write in Go.
I'm not sure if you've ever used Copilot yourself, but it's basically autocomplete on steroids.
Yes, the point I was trying to make is that it works because the error messages are formulaic. In which case I think it's too bad the whole pattern can't be abstracted.
What should? Are we talking about the same thing? My understanding of the Copilot workflow is that you use it as a programmer to "write" code by accepting suggestions and then checking the suggested code in as if you had written it the normal way.
I mean when a language requires as much repetitive boilerplate as this one, replacing the suggestion with some kind of markup to generate it will pay off almost immediately.
to work has nothing to do with generics; methods are always "removed" from types defined this way. If you want you can cast `t` to a `*container[fish]` and call it, but this is also where I would ask why "extend" rather than compose - are you gaining anything by ensuring identical layouts?
Re. "temporary restriction": Although there's agreement something should improve around `comparable`, there's no concrete design approved to solve it. It may persist for many versions, or indefinitely.
(Vs. e.g re-enabling better type inference where the goal is known and it's "just" implementation work.)
There will be some permanent restrictions in Go custom design, but I think the `comparalbe` one is not one of them. My personal prediction is it will be solved before Go 1.21.
Go doesn’t have inheritance. Defining one type in terms of another is not a subclass. It has embedding, which is a close substitute based on composition, but it isn’t the same.
It’s a little confusing because for primitive operations, it kind of looks like Go has inheritance, but this isn’t true for user-defined methods. (And automatic casting might in some cases compound the confusion.)
> automatic casting might in some cases compound the confusion.
This is why you should always be careful to distinguish conversions (which aren't automatic) from assignment of untyped constants/literals (which does automatically attach the type, but isn't casting).
data T1 = T1 Int
m :: T1 -> ()
m t = ()
newtype T2 = T2 T1
main :: IO ()
main =
let t2 = T2 (T1 23)
v = m t2
in return ()
Gets:
main.hs:11:15: error:
\* Couldn't match expected type `T1' with actual type `T2'
\* In the first argument of `m', namely `t2'
In the expression: m t2
In an equation for `v': v = m t2
|
11 | v = m t2
| ^^
> But why (1)? If a `fishTank` is a `container[fish]`
But a `fishTank` is not a `container[fish]`.
In common parlance
type T Thing
is newtyping. T has the same implementation as the underlying type, and Go allows conversions back and forth, but they are otherwise unrelated, and the new type does not share the interface (method set) of the original.
Oh I misunderstood `type T Thing` to be an alias and not a haskell-style newtype.
$ ghci
GHCi, version 8.8.4: https://www.haskell.org/ghc/ :? for help
Prelude> class HasField a where { field1 :: a -> Int }
Prelude> data Foo = Foo
Prelude> instance HasField Foo where { field1 f = 2 }
Prelude> field1 Foo
2
Prelude> newtype Bar = Bar Foo
Prelude> :t Bar
Bar :: Foo -> Bar
Prelude> field1 (Bar Foo)
<interactive>:7:1: error:
• No instance for (HasField Bar) arising from a use of
‘field1’
• In the expression: field1 (Bar Foo)
In an equation for ‘it’: it = field1 (Bar Foo)
Prelude> type Baz = Foo
Prelude> field1 (Foo :: Baz)
2
When people say Go is not an OO language, this is what they mean. Concrete type declarations define storage, not is-a hierarchies. A fishTank is not a container[fish], any more or less than it is any other `{ string, []fish }`.
And yet OO-isms are replete throughout implementations in go. `interface{}` all the things is another of the worst things about go. It practically begs for engineers to engage in all the worst practices of OO-ism and unit test zealotry.
interface{} isn’t pervasive in Go, it doesn’t tend to get abused often in practice, it has nothing to do with OO at all, and it doesn’t lead to any particular ideology about unit testing. It seems like you are mistaken on all counts.
OK, that `(*container[fish])(t).size())` idiom is um non-obvious. Somebody needs to write a nice simple bloggy step-by-step walk through all these gyrations.
When I started using Go generics, they turned out to be completely useless for the use case I wanted to solve the most: functions to use in Go templates. Something is basic as adding two numbers is not solved by the core library and becomes a challenge with about a dozen numeric types. You can use a type switch and cast interface{}/any to float64 (which is a hack, but one that works in practice). What you can't do is use generics, because they require instantiation at compile time, which cannot happen in templates, because those are dynamic. Extremely disappointing.
Another disappointing thing is the fact that Go type inference is not good enough to have syntactically compact lambda functions. So now, even though it's possible to write code that does filter-map-reduce kind of stuff, it's verbose, slow to write and hard to read.
Adding a type system to an existing language that was never intended to have that type system is not simple. And in Go, the language seems designed to largely eject its type system at the earliest opportunity that doesn't negatively impact the compiler. This will naturally constrain the capabilities of the generic programming constructs, much as it did in Josh Bloch's Java generics implementation.
Go is going to be torn between the minimalism of its creators, and the desires for modern capabilities expressed by its vocal user base. If this were a movie, it would be "A Beautiful Mind".
There are vocal critics and sometimes they’re not wrong about some of the flaws, though often they make too big a deal of them.
But I think there are also people who appreciate stability? It’s a language people complain about in part because it’s popular.
In this case, Tim Bray tripped over “Go doesn’t have inheritance, but it does have embedding” and ran into a couple limitations in the first version of generics, but he’s not making any wider claims.
Not a Go developer but i've been playing with Go for a toy project, really just to learn it. I had a tiny play with generics in Go when i needed a "min(int, ...int) int" func
// --- There's no int min() in stdlib -----------------------------------------
// Option 1. use math.Min() which is defined for float64, benchmarks comparing this with Option 2 & 3:
// BenchmarkMinImplementations/math.Min-8 261596238 4.282 ns/op
// BenchmarkMinImplementations/minGeneric-8 588252955 2.037 ns/op
// BenchmarkMinImplementations/minVariadic-8 413756245 2.827 ns/op
// Option 2. Generics in Go 1.18, this is generic over int and float but can't support a variadic "b"
func minGeneric[T constraints.Ordered](a, b T) T {
if a < b {
return a
}
return b
}
// Option 3. I was aiming for a lisp-style (min 1 2.3 -4) and this is variadic but it can't also be generic in 1.18
func minIntVariadic(a int, bs ...int) int {
for _, b := range bs {
if b < a {
a = b
}
}
return a
}
Coming from Java there's less to get your head around, e.g. super vs. extends isn't a thing here but otherwise similar.
You can do variadic args, you just have to explicitly label the type in both arguments for some reason, like you did for the non-generic version: https://go.dev/play/p/L6psz_WdETM
(I'm aware it's not actually using the varargs reasonably, typing code on a phone is a pain)
Go is my main language but it keeps disappointing me in it's lack of implementation tuning detail. Generics are just a code generator that, given it's poor performance for things like functional programming, makes it surprisingly of little use.
Go is a kitchen full of dull knives. It is good for layers of communication where you call other microservices to perform the heavy lifting, but not much else.
> given it's poor performance for things like functional programming, makes it surprisingly of little use.
In most cases it doesn't have "poor" performance and if you're going for optimal performance you are almost never going to be using generic data structures anyways because there's almost always type specific optimizations that can be done.
The intended use case is, and always has been, decently performing type safe data structures and Go generics are sufficient in most cases for that. Go is never going to be a functional programming language.
I think the parent believes that code which allocates a lot and puts function calls in tight loops is an end unto itself, and he’s quite correct that Go isn’t going to perform well in those cases. Go’s memory management chooses expensive allocations and low latency over cheap allocations and high latency, which is appropriate because idiomatic Go doesn’t allocate often but as with anything, people who want to deviate from Go’s opinions experience a lot of friction and express frustration by characterizing Go as a bad language or in this case “a drawer full of dull knives”. One wonders if the parent gets frustrated that his blender is no good for driving nails or that his sink is too small to bathe in.
Monomorphization is one of the ways generics are implemented in other languages and it definitely allows for type-specific optimizations.
I imagine there's a rationale for not implementing generics this way, like keeping binaries small, but it seems like a weird tradeoff considering it increases overall memory consumption and decreases performance quite a lot...
There are two possible extremes for the implementation of generics: full monomorphization on one side, and dictionaries on the other side. Both have serious drawbacks: monomorphization is faster, but produces bloat, and dictionaries are slower but don't produce bloat. Go chose to combine the two for a middle ground. It's a bit more complicated to implement than just monomorphization or just dictionaries, but it provides a nice balance.
I agree with everything you wrote but would add that it's even more complicated because there are cases, albeit rare in my experience, where full monomorphization produces slower code due to cache misses. I believe std::format in C++ is generally considered an example of this https://youtu.be/zssTF1uhxtM?t=2021
If it was up to me I would have gone with full monomorphization because I don't care about binary size or compilation time quite as much as the current Go developers. However, the decrease in performance of the current implementation is being vastly overstated because people read a single article. It's not going to be even close to a bottleneck in all but the most extreme performance demanding applications and those applications probably shouldn't be using a garbage collected language to begin with.
If I understood the implementation correctly, as long as you use value types instead of pointer/interface types for your generic type parameters, you'll basically get monomorphization.
I don’t think this is true. If your value types use methods in the generic code, I think you get dictionaries regardless (maybe Go doesn’t emit dictionaries if there is exactly one implementation for a gcshape?). Value types just make it more likely that you’ll have distinct gcshapes, but I’m pretty sure you’ll still get dictionaries. The only way you don’t is if you have a pure container (no calling methods on type parameters). That said, I believe there’s an undocumented flag to force full monomorphization, but it’s probably not stable.
I think it’s about keeping binaries small and compile times fast. I suspect they’ll monomorphize more in the future, but they want to proceed incrementally, which seems reasonable to me.
Again, just one engineer's opinion, but that's because most use cases for engineers are CRUD. If you don't have a CRUD problem, which is probably less than 10% of modern software engineering, Go falls on it's face.
You have it exactly backwards. Go is a particularly poor language for CRUD use cases (CRUD doesn’t care much about performance but it is very generic). Go shines for most non-CRUD applications.
You can use generics, you just can't introduce new type parameters in the method. You're free to use the type parameters defined on the type you're writing the method for.
The restriction of not introducing new generic types on methods has nothing to do with why that code doesn't compile. Remove the '>' in the method signature and it works fine.
Sure, the performance characteristics could be improved, but other than that they solve all my main pain points I wanted them to solve while being constrained enough to not result in ivory towers on every corner.
My main pain points having been duplicated functions for each type as well as data structures.