Can someone kindly ELI5 the difference between using slice of pointers and slice of structs?

I think the answer is “sometimes, maybe”. In the pointer case you are creating a copy of the pointer (which adds minimal overhead, but some). So, iterating over your structs without capturing the value and indexing into them would probably be the play:

func Structs(animals []Animal) {
	// No pointer/struct allocation here; just index
	for i := range animals {
		// You could also capture a variable here like a := animals[i]
		fmt.Printf("%s %s %.2f", animals[i].Name, animals[i].Breed, animals[i].Weight)
	}
}

But don’t trust me. Let’s benchmark it. First let’s create functions to do some contrived “work” on our slices:

// For brevity I'm omitting the other 3 funcs here, but they each do the 
// same workload on arrays of type []T and []*T. Using either capturing a 
// copy of the value like this in a range loop or indexing into our array
// with animals[i] instead.
func RangePointers(animals []*Animal) (int, int) {
	domesticShorthairs, domesticLonghairs := 0, 0
	for _, animal := range animals {
		switch animal.Breed {
		case BreedDomesticShorthair:
			domesticShorthairs++
		case BreedDomesticLonghair:
			domesticLonghairs++
		}
	}
	return domesticShorthairs, domesticLonghairs
}

… and in our test file let’s create a function to create test data as a slice of []T or []*T:

// GetAnimals returns contrived animal data for benchmarking
func GetAnimals() []Animal {
	count := 1000
	animals := make([]Animal, count)
	for i := 0; i < count; i++ {
		animals[i] = Animal{
			Name:   fmt.Sprintf("Animal #%v", i),
			Breed:  GetBreed(i),
			Weight: float64(i),
		}
	}
	return animals
}

// GetBreed is just a deterministic way to get a breed based on
// whether the current iterator value is even.
func GetBreed(ctr int) string {
	if ctr%2 == 0 {
		return BreedDomesticLonghair
	}
	return BreedDomesticShorthair
}

// GetAnimalPointers calls GetAnimals then returns a slice with
// pointers to the animal structs.
func GetAnimalPointers() []*Animal {
	animals := GetAnimals()
	animalPtrs := make([]*Animal, len(animals))
	for i := range animals {
		animalPtrs[i] = &animals[i]
	}
	return animalPtrs
}

Finally let’s create our actual benchmark functions:

var (
	finalResultShort int
	finalResultLong  int
)

// Omitting other 3 benchmarks for brevity
func BenchmarkRangePointers(b *testing.B) {
	animals := GetAnimalPointers()
	// Don't incude our setup time in this benchmark.
	b.ResetTimer()
	domesticShort, domesticLong := 0, 0
	for i := 0; i < b.N; i++ {
		domesticShort, domesticLong = RangePointers(animals)
	}
	// Store final result just to make sure compiler doesn't optimize.
	finalResultShort = domesticShort
	finalResultLong = domesticLong
}

The result?

goos: windows
goarch: amd64
pkg: github.com/DeanPDX/struct-benchmarks
cpu: Intel(R) Core(TM) i7-9700K CPU @ 3.60GHz
BenchmarkRangePointers-8          654621              1839 ns/op
BenchmarkRangeStructs-8           499588              2332 ns/op
BenchmarkIndexPointers-8          704833              1732 ns/op
BenchmarkIndexStructs-8           666658              1844 ns/op
PASS
ok      github.com/DeanPDX/struct-benchmarks    5.002s

Each of those runs is the number * iterating over 1000 structs. So in the slowest case (benchmarking with range creating copies of each struct) we are looping 499,588,000 times total. 2332 ns/op means it takes 0.002332 milliseconds to iterate over 1000 structs and create a copy of them. It depends on your app, but this is really unlikely to be a bottleneck in most applications. If you, for example, did a single query to pull these back from a database it’s far more likely to add latency to your app.

Complicating things further, the width of your data/structs probably has some bearing on this benchmark. And re-ordering your struct fields can affect memory usage. Hence why I said “sometimes, maybe”. :slight_smile:

The upshot of this is: focus first on correctness and ease of code readability / maintenance. Writing “efficient” code comes more naturally over time (like we all just get better the more experience we have) and it’s never a bad thing to think about it on your first pass, but get your app working first. When the time comes to optimize, you will probably have refactored your code several times and it won’t look anything like what it currently looks like anyway. And when the time comes to optimize something, trust me: you’ll know.

Want to run these benchmarks yourself? I created a repository for you:

Run the following:

# Clone the repo
git clone https://github.com/DeanPDX/struct-benchmarks.git
# Run the benchmarks
cd struct-benchmarks
go test -bench .

Fork it. Tweak it. Send me a pull request. Etc. And good luck!

3 Likes