Bad Go: slices of pointers

Please don't

This is the first of what may be a series of blog posts on uses of Go that I’ve found frustrating. They’re mostly minor things that could just be better without being more complicated. I’m going to try to not only explain why they are bad but also demonstrate it.

First up is slices of pointers. Things like []*MyStruct. Unless you need to express that certain indices in the slice are nil, then this is just wasteful and []MyStruct is better in almost all circumstances.

Let’s start with some benchmarks. Here’s the struct we’re going to use.

type MyStruct struct {
	A int
	B int
}

First we’ll benchmark building a slice of 100 entries, each of which is a pointer to a MyStruct. We’ll fill in some fields in the struct just for laughs.

func BenchmarkSlicePointers(b *testing.B) {
	b.ReportAllocs()

	for i := 0; i < b.N; i++ {
		slice := make([]*MyStruct, 0, 100)
		for j := 0; j < 100; j++ {
			slice = append(slice, &MyStruct{A: j, B: j + 1})
		}
	}
}

Next we’ll do the same, but use a []MyStruct.

func BenchmarkSliceNoPointers(b *testing.B) {
	b.ReportAllocs()

	for i := 0; i < b.N; i++ {
		slice := make([]MyStruct, 0, 100)
		for j := 0; j < 100; j++ {
			slice = append(slice, MyStruct{A: j, B: j + 1})
		}
	}
}

We run the benchmarks with go test -bench . -count 10 > run1.txt, then analyse the results with benchstat run1.txt. Here are the results.

name               time/op
SlicePointers-8    2.50µs ± 2%
SliceNoPointers-8   117ns ± 1%

name               alloc/op
SlicePointers-8    1.60kB ± 0%
SliceNoPointers-8   0.00B     

name               allocs/op
SlicePointers-8       100 ± 0%
SliceNoPointers-8    0.00     

The no-pointer version allocates less memory, performs fewer allocations and is over an order of magnitude faster.

Why is it faster? Well, the pointer version allocates a new piece of memory for each entry in the slice, whereas the non-pointer version simply fills in the A & B ints in the slice entry itself. Allocating memory takes time - somewhere around 25ns per allocation - and hence the pointer version must take ~2500ns to allocate 100 entries.

Are there any downsides? Not really.

But I want to change entries in the slice!

You can still do this with a non-pointer slice. You can obtain a pointer to an entry in the slice and change it.

e := &slice[37]
e.A = 42

But I really need a slice of pointers to pass to this library

OK, sometimes you’re forced to use a slice of pointers because that’s what a library needs. If you can’t change the library then perhaps you just do need to build a slice of pointers. You might still be better off using a slice of non-pointers to do this! First build up the slice of non-pointers, then build a slice of pointers from that. You’ll likely end up with fewer allocations overall.

We can benchmark this too.

func BenchmarkSliceHybrid(b *testing.B) {
	b.ReportAllocs()

	for i := 0; i < b.N; i++ {
		slice := make([]MyStruct, 0, 100)
		for j := 0; j < 100; j++ {
			slice = append(slice, MyStruct{A: j, B: j + 1})
		}

		slicep := make([]*MyStruct, len(slice))
		for j := range slice {
			slicep[j] = &slice[j]
		}
	}
}
name           time/op
SliceHybrid-8   349ns ± 0%

name           alloc/op
SliceHybrid-8  1.79kB ± 0%

name           allocs/op
SliceHybrid-8    1.00 ± 0%

This time we don’t have to make a fresh allocation for each slice entry in the []*MyStruct - we just use pointers to the entries in []MyStruct. The amount of memory allocated it about the same, but it’s done in many fewer allocations.

But my function returns a *MyStruct

Does it have to? Could you change it to return a MyStruct instead?

But … it’s complicated!?

Remember this is just a suggestion. If your case is complicated perhaps you can write a benchmark to see whether changing things will make a worthwhile improvement. I’m just saying that perhaps []MyStruct should be the normal case, and you should use slices of pointers only where they are necessary.

I’m kind of hoping and kind of dreading that no-one out in the world uses slices of pointers, and therefore this post has fallen completely flat. I hestitate to say let me know on twitter…