Golang Diaries: Generics
tbray.org
tbray.org
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.
Rich Hickey's talks can be very insightful in this regard. He surely convinced me :)
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).
I always felt Go hit the sweet spot.
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.
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.
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.
(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'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.
My bigger annoyance is when I want to write non-trivial functionality that works for different data types.
I'm not aware of anything else that writes good wrapping error messages automatically though.
Your use-case is precisely what you'd be using generics / standard codegen for.
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.
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.
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.
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.
I believe Haskell has them, is that where you've used them?
I mainly use Swift these days but I haven't had a need for them yet so was asking out of curiosity.
You can easily and cleanly work around this by exposing getter and setter method in your interface.
BTW, accessing common fields is temporarily disabled before releasing Go 1.18, because there is another fundamental problem which needs to be resolved firstly.
Concepts?
So, how do I specify, using concepts, that my template expects a type which has a field “.foo” of type int?
template<typename T>
concept has_foo = requires(T x){
{ x.foo } -> std::same_as<int>;
};
template<has_foo T>
int get_foo(T x){...}
// or
template<typename T>
requires has_foo<T>
void f(T x){...}
// or even
void f(has_foo auto x) {...}type hasFoo interface {
getfoo() int
setfoo(int)
}func f(hasFoo) {}
?
func f[T interface{.Foo}](T) {}
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). foo.baz = 1; // baz could be a int or could be a setter.
int baz = foo.baz; // sameRust 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.
In the meantime I guess there's always anonymous functions (to use as accessors). They're not hard to be generic over.
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.
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.
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.
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’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.
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.
1) The failure of
func (t *fishTank) fishCount() string {
return fmt.Sprintf("How many fishies? %d!", t.size())
}
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?2) The bigger issue, that interfaces cannot require their implementations to be comparable, is https://github.com/golang/go/issues/52614 and linked tickets.
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.It is, in a way, a shorthand for
type T struct { _0 Thing } $ 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 type T = Thing1) is totally generics unrelated.
package main
type T1 int
func (T1) M() {}
type T2 T1
func main() {
var t2 T2
t2.M() // t2.M undefined
}
The fishTank should be defined as type fishTank struct {
container[fish]
}
instead.2) is a temporary restriction, among many in the Go 1.18. Some of the restrictions will be removed from future Go versions.
ref:
* https://go101.org/generics/101.html
* https://github.com/golang/go/issues/50646#issuecomment-10237...
* https://github.com/golang/go/issues/51257
* https://github.com/golang/go/issues/51338
* https://github.com/golang/go/issues/52474
* https://github.com/golang/go/issues/52509
(Vs. e.g re-enabling better type inference where the goal is known and it's "just" implementation work.)
There may be some discussions about this, hidden in the go-nuts forum. But I don't know how to filter them out.
See: https://go.dev/doc/faq#inheritance and https://go.dev/doc/effective_go#embedding
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.)
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
| ^^https://go.dev/play/p/GrLYBj3N0jr
The trick is to define the fish tank like so:
type fishTank struct {
container[fish]
} func (t *fishTank) fishCount() string {
return fmt.Sprintf("How many fishies? %d!", (*container[fish])(t).size())
}
that's because type fishTank container[fish]
creates a completely new and independent type with container[fish] as the underlying implementation.An alternative is to just alias:
type fishTank = container[fish]
however in that case you can't define methods on fishTank, because it's literally just a shorthand for container[fish]. T(v)
but that doesn't work if T is a pointer type, because it's parsed as *(T(v))
so the types don't match. So you need (*T)(v)
to ensure the type part includes the pointer specifier.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.
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".
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.
> Among the 26% of respondents who said Go lacks language features they need, 88% selected generics as a critical missing feature.
From https://go.dev/blog/survey2020-results
Most folks I work with are among the 74% who are content with the language and are mostly cautious of the complexity associated with generics.
// --- 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.(I'm aware it's not actually using the varargs reasonably, typing code on a phone is a pain)
Are you learning Go with a familiar project? or is there another motivation for Quamina?
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.
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 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...
This is not valid code.