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

I know there are tons of resources but coming from C# background, reading about all these just makes me more confused. For general purpose, which one is preferable and what are the pros/cons of each approach.

Slice of struct pointers being passed as parameter or returned from a function:

func getAllAnimals() []*Animal  {
}

func printAllAnimals(animals []*Animal) {
}

V/S

Slice of struct being passed as parameter or returned from a function:

func getAllAnimals() []Animal  {
}

func printAllAnimals(animals []Animal) {
}

you should write like this in golang
func getAllAnimals() (Animal,error) {
var(
x Animal
err error
)

return x,nil
}

data,err := getAllAnimals()
if err != nil {
}

type or paste code here

What is your main consideration here? Are you trying to add an extra layer of indirection for performance reasons? Are you trying to figure out which one is “more correct”? Let’s back up and talk about what a slice is. Take a look at the slice struct:

type slice struct {
	array unsafe.Pointer
	len   int
	cap   int
}

As you can see, it is very simple and the key point here is: it contains a reference to the underlying array (not a copy of it). That’s why you can slice and re-slice to your hearts’ content and it doesn’t create memory pressure. And here is some text from the tour of go:

A slice does not store any data, it just describes a section of an underlying array.

Changing the elements of a slice modifies the corresponding elements of its underlying array.

Other slices that share the same underlying array will see those changes.

In practice, most of the time I prefer to just use a slice of structs, not a slice of pointers to structs but there are some considerations (I’ll get to them in a bit). In terms of performance: passing around copies of structs in Go for whatever reason is lightning fast. I had the same knee-jerk reaction as you coming from C# that I should pass pointers because it felt like passing items “by reference” in C#. In practice, with many years of shipping production Go apps under my belt, it matters on more rare occasions than you would think.

One potential “gotcha” is: range loops. Again from the tour of go (emphasis mine):

The range form of the for loop iterates over a slice or map.

When ranging over a slice, two values are returned for each iteration. The first is the index, and the second is a copy of the element at that index.

With that in mind let’s create an example. Let’s assume we have the following Animal struct:

// Struct for storing info about various animals
type Animal struct {
	Name       string
	LikesWater bool
}

// Define an interface for printing info to the console
type PrintInfo interface {
	PrintInfo()
}

// Implement our interface
func (a Animal) PrintInfo() {
	fmt.Printf("Name: %v. Likes water? %v.\n", a.Name, a.LikesWater)
}

// Call PrintInfo on all items in our slice
func printAll[T PrintInfo](animals []T) {
	for _, animal := range animals {
		animal.PrintInfo()
	}
}

… and the following functions to return either a slice of structs or a slice of pointers to structs:

func getPointers() []*Animal {
	c := Animal{"Cat", false}
	d := Animal{"Dog", true}
	return []*Animal{
		&c,
		&d,
	}
}

func getStructs() []Animal {
	return []Animal{
		{"Cat", false},
		{"Dog", true},
	}
}

Then let’s use them:

func main() {
	// Get pointers and range over them.
	ptrs := getPointers()
	for _, v := range ptrs {
		// Automatic dereference here so safe to use v.Name as shorthand for (*v).Name
		// See also: https://stackoverflow.com/a/13533822/3718246
		v.Name = "MODIFIED!"
	}
	fmt.Println("Results after ranging over pointers:")
	printAll(ptrs)

	// Get structs and range over them
	structs := getStructs()
	for _, v := range structs {
		v.Name = "MODIFIED!"
	}
	fmt.Println("\nResults after ranging over structs:")
	printAll(structs)

	// Get structs and index into them to modify them instead
	for i, _ := range structs {
		structs[i].Name = "MODIFIED!"
	}
	fmt.Println("\nResults after indexing into struct array:")
	printAll(structs)
}

This produces the following:

Results after ranging over pointers:
Name: MODIFIED!. Likes water? false.
Name: MODIFIED!. Likes water? true.

Results after ranging over structs:
Name: Cat. Likes water? false.
Name: Dog. Likes water? true.

Results after indexing into struct array:
Name: MODIFIED!. Likes water? false.
Name: MODIFIED!. Likes water? true.

You can run it yourself on the playground:

In summary: I usually prefer passing a slice with an underlaying array of structs. You can use a slice with an underlying array of pointers to structs, but the benefits are (usually) minimal. There are ramifications to either though in terms of ergonomics down the line which I hope the example above (mostly) illustrates. Be careful when rangeing over structs if you’re trying to modify the original struct values (but that’s something every gopher must know eventually anyway!).

And the golden rule if you’re trying to determine which is better for performance is: benchmark both. Dave Cheney has a good blog post about benchmarking in go. Also go by example might be useful.

5 Likes

Thanks a lot for the detailed answer. I was also approaching with C# mindset. Just for 100% clarification, can you kindly verify if I got this right?

type Animal struct {
	Name   string
	Breed  string
	Weight float64
}

func Pointers(animals []*Animal) {
	for _, animal := range animals {
		fmt.Printf("%s %s %.2f", animal.Name, animal.Breed, animal.Weight)
	}
}

func Structs(animals []Animal) {
	for _, animal := range animals {
		fmt.Printf("%s %s %.2f", animal.Name, animal.Breed, animal.Weight)
	}
}

For Structs(), during the loop, in every iteration, animal instance is copied, but for Pointers() it is not so, right? So, for minute optimizations, the Pointers() is more optimal, is that correct?

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

This cleared up all my confusions. It is true, I was fretting over micro optimizations whereas it is rarely the bottleneck in most cases, however, it was great to learn the inner workings of slice and pointers. This was very educational, so thanks a lot for taking the time to write this detailed answer!

1 Like
  • This is similar to a slice of pointers. The slice holds a list of addresses (pointers) that point to the actual structs (boxes) stored somewhere else in memory.

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.