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:
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 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!).
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
}
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.
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!
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!
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.