forked from cadey/xesite
parent
f590fc71d1
commit
67de839da8
|
@ -0,0 +1,550 @@
|
|||
---
|
||||
title: Crimes with Go Generics
|
||||
date: 2022-04-24
|
||||
tags:
|
||||
- cursed
|
||||
- golang
|
||||
- generics
|
||||
vod:
|
||||
twitch: https://www.twitch.tv/videos/1465727432
|
||||
youtube: https://youtu.be/UiJtaKYQnzg
|
||||
---
|
||||
|
||||
Go 1.18 added [generics](https://go.dev/doc/tutorial/generics) to the
|
||||
language. This allows you to have your types take types as parameters
|
||||
so that you can create composite types (types out of types). This lets
|
||||
you get a lot of expressivity and clarity about how you use Go.
|
||||
|
||||
However, if you are looking for good ideas on how to use Go generics,
|
||||
this is not the post for you. This is full of bad ideas. This post is
|
||||
full of ways that you should not use Go generics in production. Do not
|
||||
copy the examples in this post into production. By reading this post
|
||||
you agree to not copy the examples in this post into production.
|
||||
|
||||
I have put my code for this article [on my git
|
||||
server](https://tulpa.dev/internal/gonads). This repo has been
|
||||
intentionally designed to be difficult to use in production by me
|
||||
taking the following steps:
|
||||
|
||||
1. I have created it under a Gitea organization named `internal`. This
|
||||
will make it impossible for you to import the package unless you
|
||||
are using it from a repo on my Gitea server. Signups are disabled
|
||||
on that Gitea server. See
|
||||
[here](https://go.dev/doc/go1.4#internalpackages) for more
|
||||
information about the internal package rule.
|
||||
1. The package documentation contains a magic comment that will make
|
||||
Staticcheck and other linters complain that you are using this
|
||||
package even though it is deprecated.
|
||||
|
||||
<xeblog-conv name="Mara" mood="hmm">What is that package
|
||||
name?</xeblog-conv>
|
||||
|
||||
<xeblog-conv name="Cadey" mood="enby">It's a reference to
|
||||
Haskell's monads, but adapted to Go as a pun.</xeblog-conv>
|
||||
|
||||
<xeblog-conv name="Numa" mood="delet">A gonad is just a gonoid in the
|
||||
category of endgofunctors. What's there to be confused
|
||||
about?</xeblog-conv>
|
||||
|
||||
<xeblog-conv name="Cadey" mood="facepalm">\*sigh\*</xeblog-conv>
|
||||
|
||||
## `Queue[T]`
|
||||
|
||||
To start things out, let's show off a problem in computer science that
|
||||
is normally difficult. Let's make a MPMS (multiple producer, multiple
|
||||
subscriber) queue.
|
||||
|
||||
First we are going to need a struct to wrap everything around. It will
|
||||
look like this:
|
||||
|
||||
```go
|
||||
type Queue[T any] struct {
|
||||
data chan T
|
||||
}
|
||||
```
|
||||
|
||||
This creates a type named `Queue` that takes a type argument `T`. This
|
||||
`T` can be absolutely anything, but the only requirement is that the
|
||||
data is a Go type.
|
||||
|
||||
You can create a little constructor for `Queue` instances with a
|
||||
function like this:
|
||||
|
||||
```go
|
||||
func NewQueue[T any](size int) Queue[T] {
|
||||
return Queue[T]{
|
||||
data: make(chan T, size),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Now let's make some methods on the `Queue` struct that will let us
|
||||
push to the queue and pop from the queue. They could look like this:
|
||||
|
||||
```go
|
||||
func (q Queue[T]) Push(val T) {
|
||||
q.data <- val
|
||||
}
|
||||
|
||||
func (q Queue[T]) Pop() T {
|
||||
return <-q.data
|
||||
}
|
||||
```
|
||||
|
||||
These methods will let you put data at the end of the queue and then
|
||||
pull it out from the beginning. You can use them like this:
|
||||
|
||||
```go
|
||||
q := NewQueue[string](5)
|
||||
q.Push("hi there")
|
||||
str := q.Pop()
|
||||
if str != "hi there" {
|
||||
panic("string is wrong")
|
||||
}
|
||||
```
|
||||
|
||||
This is good, but the main problem comes from trying to pop from an
|
||||
empty queue. It'll stay there forever doing nothing. We can use the
|
||||
`select` statement to allow us to write a nonblocking version of the
|
||||
`Pop` function:
|
||||
|
||||
```go
|
||||
func (q Queue[T]) TryPop() (T, bool) {
|
||||
select {
|
||||
case val := <-q.data:
|
||||
return val, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
However when we try to compile this, we get an error:
|
||||
|
||||
```
|
||||
cannot use nil as T value in return statement
|
||||
```
|
||||
|
||||
In that code, `T` can be _anything_, including values that may not be
|
||||
able to be `nil`. We can work around this by taking advantage of the
|
||||
`var` statement, which makes a new variable and initializes it to the
|
||||
zero value of that type:
|
||||
|
||||
```go
|
||||
func Zero[T any]() T {
|
||||
var zero T
|
||||
return zero
|
||||
}
|
||||
```
|
||||
|
||||
When we run the `Zero` function like
|
||||
[this](https://go.dev/play/p/Z5tRs1-aKBU):
|
||||
|
||||
```go
|
||||
log.Printf("%q", Zero[string]())
|
||||
log.Printf("%v", Zero[int]())
|
||||
```
|
||||
|
||||
We get output that looks like this:
|
||||
|
||||
```
|
||||
2009/11/10 23:00:00 ""
|
||||
2009/11/10 23:00:00 0
|
||||
```
|
||||
|
||||
So we can adapt the `default` branch of `TryPop` to this:
|
||||
|
||||
```go
|
||||
func (q Queue[T]) TryPop() (T, bool) {
|
||||
select {
|
||||
case val := <-q.data:
|
||||
return val, true
|
||||
default:
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
And finally write a test for good measure:
|
||||
|
||||
```go
|
||||
func TestQueue(t *testing.T) {
|
||||
q := NewQueue[int](5)
|
||||
for i := range make([]struct{}, 5) {
|
||||
q.Push(i)
|
||||
}
|
||||
|
||||
for range make([]struct{}, 5) {
|
||||
t.Log(q.Pop())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## `Option[T]`
|
||||
|
||||
In Go, people use pointer values for a number of reasons:
|
||||
|
||||
1. A pointer value may be `nil`, so this can signal that the value may
|
||||
not exist.
|
||||
1. A pointer value only stores the offset in memory, so passing around
|
||||
the value causes Go to only copy the pointer instead of copying the
|
||||
value being passed around.
|
||||
1. A pointer value being passed to a function lets you mutate values
|
||||
in the value being passed. Otherwise Go will copy the value and you
|
||||
can mutate it all you want, but the changes you made will not
|
||||
persist past that function call. You can sort of consider this to
|
||||
be "immutable", but it's not as strict as something like passing
|
||||
`&mut T` to functions in Rust.
|
||||
|
||||
This `Option[T]` type will help us model the first kind of constraint:
|
||||
a value that may not exist. We can define it like this:
|
||||
|
||||
```go
|
||||
type Option[T any] struct {
|
||||
val *T
|
||||
}
|
||||
```
|
||||
|
||||
Then you can define a couple methods to use this container:
|
||||
|
||||
```go
|
||||
var ErrOptionIsNone = errors.New("gonads: Option[T] has no value")
|
||||
|
||||
func (o Option[T]) Take() (T, error) {
|
||||
if o.IsNone() {
|
||||
var zero T
|
||||
return zero, ErrOptionIsNone
|
||||
}
|
||||
|
||||
return *o.val, nil
|
||||
}
|
||||
|
||||
func (o *Option[T]) Set(val T) {
|
||||
o.val = &val
|
||||
}
|
||||
|
||||
func (o *Option[T]) Clear() {
|
||||
o.val = nil
|
||||
}
|
||||
```
|
||||
|
||||
Some other functions that will be useful will be an `IsSome` function
|
||||
to tell if the `Option` contains a value. We can use this to also
|
||||
implement an `IsNone` function that will let you tell if that `Option`
|
||||
_does not_ contain a value. They will look like this:
|
||||
|
||||
```go
|
||||
func (o Option[T]) IsSome() bool {
|
||||
return o.val != nil
|
||||
}
|
||||
|
||||
func (o Option[T]) IsNone() bool {
|
||||
return !o.IsSome()
|
||||
}
|
||||
```
|
||||
|
||||
We can say that if an Option does not have something in it, it has
|
||||
nothing in it. This will let us use `IsSome` to implement `IsNone`.
|
||||
|
||||
Finally we can add all this up to a `Yank` function, which is similar
|
||||
to
|
||||
[`Option::unwrap()`](https://doc.rust-lang.org/rust-by-example/error/option_unwrap.html)
|
||||
in Rust:
|
||||
|
||||
```go
|
||||
func (o Option[T]) Yank() T {
|
||||
if o.IsNone() {
|
||||
panic("gonads: Yank on None Option")
|
||||
}
|
||||
|
||||
return *o.val
|
||||
}
|
||||
```
|
||||
|
||||
This will all be verified in a Go test:
|
||||
|
||||
```go
|
||||
func TestOption(t *testing.T) {
|
||||
o := NewOption[string]()
|
||||
val, err := o.Take()
|
||||
if err == nil {
|
||||
t.Fatalf("[unexpected] wanted no value out of Option[T], got: %v", val)
|
||||
}
|
||||
|
||||
o.Set("hello friendos")
|
||||
_, err = o.Take()
|
||||
if err != nil {
|
||||
t.Fatalf("[unexpected] wanted no value out of Option[T], got: %v", err)
|
||||
}
|
||||
|
||||
o.Clear()
|
||||
if o.IsSome() {
|
||||
t.Fatal("Option should have none, but has some")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<xeblog-conv name="Mara" mood="hmm">I think that
|
||||
<code>Option[T]</code> will be the most useful outside of this post.
|
||||
It will need some work and generalization, but this may be something
|
||||
that the Go team will have to make instead of some random
|
||||
person.</xeblog-conv>
|
||||
|
||||
## `Thunk[T]`
|
||||
|
||||
In computer science we usually deal with values and computations.
|
||||
Usually we deal with one or the other. Sometimes computations can be
|
||||
treated as values, but this is very rare. It's even more rare to take
|
||||
a partially completed computation and use it as a value.
|
||||
|
||||
A thunk is a partially evaluated computation that is stored as a
|
||||
value. For an idea of what I'm talking about, let's consider this
|
||||
JavaScript function:
|
||||
|
||||
```javascript
|
||||
const add = (x, y) => x + y;
|
||||
console.log(add(2, 2)); // 4
|
||||
```
|
||||
|
||||
This creates a function called `add` that takes two arguments and
|
||||
returns one argument. This is great in many cases, but it makes it
|
||||
difficult for us to bind only one argument to the function and leave
|
||||
the other as a variable input. What if computing the left hand side of
|
||||
`add` is expensive and only needed once?
|
||||
|
||||
Instead we can write `add` like this:
|
||||
|
||||
```javascript
|
||||
const add = (x) => (y) => x + y;
|
||||
console.log(add(2)(2)); // 4
|
||||
```
|
||||
|
||||
This also allows us to make partially evaluated forms of `add` like
|
||||
`addTwo`:
|
||||
|
||||
```javascript
|
||||
const addTwo = add(2);
|
||||
console.log(addTwo(3)); // 5
|
||||
```
|
||||
|
||||
This can also be used with functions that do not take arguments, so
|
||||
you can pass around a value that isn't computed yet and then only
|
||||
actually compute it when needed:
|
||||
|
||||
```javascript
|
||||
const hypotenuse = (x, y) => Math.sqrt(x * x + y * y);
|
||||
const thunk = () => hypot(3, 4);
|
||||
```
|
||||
|
||||
You can then pass this thunk to functions _without having to evaluate
|
||||
it_ until it is needed:
|
||||
|
||||
```javascript
|
||||
dominateWorld(thunk); // thunk is passed as an unevaluated function
|
||||
```
|
||||
|
||||
We can implement this in Go by using a type like the following:
|
||||
|
||||
```go
|
||||
type Thunk[T any] struct {
|
||||
doer func() T
|
||||
}
|
||||
```
|
||||
|
||||
And then force the thunk to evaluate with a function such as `Force`:
|
||||
|
||||
```go
|
||||
func (t Thunk[T]) Force() T {
|
||||
return t.doer()
|
||||
}
|
||||
```
|
||||
|
||||
This works, however we can also go one step further than we did with
|
||||
the JavaScript example. We can take advantage of the `Thunk[T]`
|
||||
container to cache the result of the `doer` function so that calling
|
||||
it multiple times will only actually it once and return the same
|
||||
result.
|
||||
|
||||
<xeblog-conv name="Mara" mood="hacker">Keep in mind that this will
|
||||
only work for _pure functions_, or functions that don't modify the
|
||||
outside world. This isn't just global variables either, but any
|
||||
function that modifies any state anywhere, including network and
|
||||
filesystem IO.</xeblog-conv>
|
||||
|
||||
This would make `Thunk[T]` be implemented like this:
|
||||
|
||||
```go
|
||||
type Thunk[T any] struct {
|
||||
doer func() T // action being thunked
|
||||
o *Option[T] // cache for complete thunk data
|
||||
}
|
||||
|
||||
func (t *Thunk[T]) Force() T {
|
||||
if t.o.IsSome() {
|
||||
return t.o.Yank()
|
||||
}
|
||||
|
||||
t.o.Set(t.doer())
|
||||
return t.o.Yank()
|
||||
}
|
||||
|
||||
func NewThunk[T any](doer func() T) *Thunk[T] {
|
||||
return &Thunk[T]{
|
||||
doer: doer,
|
||||
o: NewOption[T](),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Fibonacci example
|
||||
- [ ] Why is this slow? I have no idea
|
||||
- [ ] Numa\ this is the power of gonads!
|
||||
|
||||
Now, for an overcomplicated example you can use this to implement the
|
||||
Fibonacci function. We can start out by writing a naiive Fibonacci
|
||||
function like this:
|
||||
|
||||
```go
|
||||
func Fib(n int) int {
|
||||
if n <= 1 {
|
||||
return n
|
||||
}
|
||||
|
||||
return Fib(n-1) + Fib(n-2)
|
||||
}
|
||||
```
|
||||
|
||||
We can turn this into a Go test in order to see how long it takes for
|
||||
it to work:
|
||||
|
||||
```go
|
||||
func TestRecurFib(t *testing.T) {
|
||||
t.Log(Fib(40))
|
||||
}
|
||||
```
|
||||
|
||||
Then when we run `go test`:
|
||||
|
||||
```console
|
||||
$ go test -run RecurFib
|
||||
=== RUN TestRecurFib
|
||||
thunk_test.go:15: 102334155
|
||||
--- PASS: TestRecurFib (0.36s)
|
||||
```
|
||||
|
||||
However, we can make this a lot more complicated with the power of the
|
||||
`Thunk[T]` type:
|
||||
|
||||
```go
|
||||
func TestThunkFib(t *testing.T) {
|
||||
cache := make([]*Thunk[int], 41)
|
||||
|
||||
var fib func(int) int
|
||||
fib = func(n int) int {
|
||||
if cache[n].o.IsSome() {
|
||||
return *cache[n].o.val
|
||||
}
|
||||
return fib(n-1) + fib(n-2)
|
||||
}
|
||||
|
||||
for i := range cache {
|
||||
i := i
|
||||
cache[i] = NewThunk(func() int { return fib(i) })
|
||||
}
|
||||
cache[0].o.Set(0)
|
||||
cache[1].o.Set(1)
|
||||
|
||||
t.Log(cache[40].Force())
|
||||
}
|
||||
```
|
||||
|
||||
And then run the test:
|
||||
|
||||
```
|
||||
=== RUN TestThunkFib
|
||||
thunk_test.go:36: 102334155
|
||||
--- PASS: TestThunkFib (0.60s)
|
||||
```
|
||||
|
||||
<xeblog-conv name="Mara" mood="hmm">Why is this so much slower? This
|
||||
should be caching the intermediate values. Maybe something like this
|
||||
would be faster? This should complete near instantly,
|
||||
right?</xeblog-conv>
|
||||
|
||||
```go
|
||||
func TestMemoizedFib(t *testing.T) {
|
||||
mem := map[int]int{
|
||||
0: 0,
|
||||
1: 1,
|
||||
}
|
||||
|
||||
var fib func(int) int
|
||||
fib = func(n int) int {
|
||||
if result, ok := mem[n]; ok {
|
||||
return result
|
||||
}
|
||||
|
||||
result := fib(n-1) + fib(n-2)
|
||||
mem[n] = result
|
||||
return result
|
||||
}
|
||||
|
||||
t.Log(fib(40))
|
||||
}
|
||||
```
|
||||
|
||||
```console
|
||||
$ go test -run Memoized
|
||||
=== RUN TestMemoizedFib
|
||||
thunk_test.go:35: 102334155
|
||||
--- PASS: TestMemoizedFib (0.00s)
|
||||
```
|
||||
|
||||
<xeblog-conv name="Cadey" mood="enby">I'm not sure
|
||||
either.</xeblog-conv>
|
||||
|
||||
If you change the `fib` function to this, it works, but it also steps
|
||||
around the `Thunk[T]` type:
|
||||
|
||||
```go
|
||||
fib = func(n int) int {
|
||||
if cache[n].o.IsSome() {
|
||||
return *cache[n].o.val
|
||||
}
|
||||
|
||||
result := fib(n-1) + fib(n-2)
|
||||
cache[n].o.Set(result)
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
This completes instantly:
|
||||
|
||||
```
|
||||
=== RUN TestThunkFib
|
||||
thunk_test.go:59: 102334155
|
||||
--- PASS: TestThunkFib (0.00s)
|
||||
```
|
||||
|
||||
To be clear, this isn't the fault of Go generics. I'm almost certain
|
||||
that my terrible code is causing this to be much slower.
|
||||
|
||||
<xeblog-conv name="Numa" mood="delet">This is the power of gonads:
|
||||
making easy code complicated, harder to reason about and slower than
|
||||
the naiive approach! Why see this as terrible code when it creates an
|
||||
amazing opportunity for cloud providers to suggest that people use
|
||||
gonads' `Thunk[T]` so that they use more CPU and then have to pay cloud
|
||||
providers more money for CPU! Think about the children!</xeblog-conv>
|
||||
|
||||
---
|
||||
|
||||
I'm glad that Go has added generics to the language. It's certainly
|
||||
going to make a lot of things a lot easier and more expressive. I'm
|
||||
worried that the process of learning how to use generics in Go is
|
||||
going to create a lot of churn and toil as people get up to speed on
|
||||
when and where they should be used. These should be used in specific
|
||||
cases, not as a bread and butter tool.
|
||||
|
||||
I hope this was an interesting look into how you can use generics in
|
||||
Go, but again please don't use these examples in production.
|
Loading…
Reference in New Issue