Welcome back to our weekly Go insights,
Last week, we delved into the strategic placement of interfaces within the consumer package rather than the producer to streamline our Go applications:
Now, we shift our focus to the subtleties of handling data in Go and this week’s takeaways are:
Pass Values, Not Pointers
Pointer Receivers and Value Receivers Nuances
Prefer Using a Pointer Receiver When Defining Methods
Take 1: Pass Values, Not Pointers
This is a topic that has tripped up many folks (including myself) when we first started with Go.
Often, we’re tempted to pass pointers in our functions for a couple of reasons:
We’re looking to avoid the overhead of copying a struct.
Or maybe, we already have a pointer, and it feels unnecessary to dereference it just to pass the value (*T).
0. Common Thoughts About Pointers
It’s pretty common to think of pointers as a clever way to save on memory.
Why copy all that data to pass to a function when you can just send over a tiny address pointing to where the data is stored, right?
But the advice is to prefer passing values directly to functions instead of passing pointers. Why?
Here are 5 key points on when to pass values.
1. Fixed-sized Types
We’re talking about integers, floats, small structs, and (small) arrays here.
These types have a fixed memory footprint that’s often on par with or even less than a pointer’s size on many systems.
2. Immutability and clarity.
Passing by value means the function gets its own copy of the data, no strings attached.
This way, you don’t have to worry about unintended side effects, so any changes stay local to the function. Also, passing a value means you’re signaling to your team: ‘I’m not messing with your data, I just need to work with it”.
This solution is clear and safe:
Both examples are considered good and if you want to change the value inside the called function, just do it with a pointer, of course.
3. Small or unlikely to grow types
For data types that are inherently small or unlikely to expand significantly, direct passing avoids the extra step of dereferencing pointers.
4. Passing values is FAST and rarely slower than passing pointers.
It might seem counterintuitive because of copying, but the reasons are:
Copying a small amount of data is very efficient and can often be quicker than the indirection required when using pointers.
It reduces the workload of the garbage collector, when values are passed directly, the GC has fewer pointer references to track.
Data that is passed by value tends to be stored closer together in memory, allowing the CPU to access the data more quickly.
Very rarely do you have a struct large enough that benefits from passing by pointer.
5. Make passing values your default.
Only consider pointers if you’ve got benchmarks showing a clear advantage, atiny bit of performance gain often isn’t worth sacrificing clarity.
Sure, pointers can speed things up for large or growing (unbounded) structs, but you’ve got to prove it’s worth it.
Take 2: Pointer Receivers and Value Receivers Nuances
1. Both (addressable) Values T and Pointers *T can Invoke Methods with Value and Pointer Receivers.
This is the most basic principle.
If a type has a value receiver method, it can be called on both the value itself and a pointer to that value.
Similarly, for a pointer receiver, it can be called on both its pointer and the value itself.
The example below runs without any compile errors:
Even when m is not a pointer, calling m.Transform()
still changes its .Model
value. It’s just another way of doing (&m).Transform()
.
So, there are two notes here (we will discuss the hidden secrets of both):
Go automatically takes the value’s address to call a pointer receiver.
It automatically dereferences a pointer to call a value receiver.
Many of us have misunderstandings about pointer receivers, they can actually be called with just a value, no need for a pointer.
2. Nuance With Nil.
Let’s tweak the example a bit, guess which example will panic because of nil:
Which one?
Neither of them panics, we can actually call a function with a nil pointer, provided the method does not access any fields of the struct.
But… we just discussed that ‘Go automatically dereferences the pointer when calling a method with a value receiver’. So, what happens if we call a value receiver method with a nil pointer? Could this cause a panic?
Yes.
Attempting to call a method with a value receiver on a nil pointer (var car *Car; car.Move()
) leads to a panic, because Go can’t dereference nil to pass it as a value (at runtime).
3. The Unaddressable Problem
We’ve just solved the second note, now onto the first one: “Go automatically takes a value’s address to call a pointer receiver”.
What happens if the value is “unaddressable”? Like values returned from a function or temporary values (a struct literal or value from a map) are typically not addressable directly.
So, what’s the outcome when we try calling a method that expects a pointer receiver on such values?
Do we get a panic, or does it result in a compile error?
Neither m[0] nor ACar() can call the Transform method, we face a compile error.
This is because Go needs to extract an address to call the method with a pointer receiver, but it can’t do this with temporary, unaddressable values.
Of course, calling a method with a value receiver on these instances works just fine, as expected.
4. Interface and Method Set Nuances
“What are method sets?”
For a type T, its method set includes all methods with T as the receiver.
But for a pointer to a type (*T), the method set expands, it includes methods with both T and *T as the receivers.
Let’s look at an example for method with value receiver and interface:
Both *Car and Car can have the Move() method in their method sets, allowing them to satisfy the Mover interface.
Let’s look at an example of a method with a value receiver and interface:
We hit a compile error when we try something like using Car{…} as a Mover.
The error goes like: “cannot use Car{…} (value of type Car) as Mover value in variable declaration: Car does not implement Mover (method Move has pointer receiver)”
What’s happening here is that *Car has Move() in its method set, while Car doesn’t.
Even though T and *T might seem to interchangeably use each other’s methods, their method sets diverge when interfacing with… well, interfaces.
Take 3: Prefer Using a Pointer Receiver When Defining Methods.
The rule isn’t black and white: “Use pointer receivers to modify, and value receivers otherwise”.
When to opt for a pointer receiver?
To modify the receiver’s state.
For structs that are considered “large”, which can be a bit subjective as I’ve mentioned in previous section.
When a struct includes synchronizing fields like
sync.Mutex
, opting for a pointer helps avoid copying the lock.If unsure, leaning towards a pointer receiver might be wise.
When is a value receiver suitable?
For types that are small and won’t change (immutable).
If your type is a map, func, channel, or involves slices that aren’t altered in size or capacity (though elements may change).
“Why slices that aren’t altered in size or capacity?”
Copying the slice and modifying its size will break the connection.
While you can modify the elements of the slice or the content of the underlying array through a value receiver (affecting the original slice), resizing the slice (e.g., using append that increases the capacity) will not affect the original slice outside the method.
Lastly, consistency is key.
Avoid mixing receiver types for a given struct to maintain consistency.
If any method requires a pointer receiver due to mutations, it’s generally better to use pointer receivers for all methods of that struct, even those that don’t cause mutations.
The main reasons (I’ve come up with) are:
Using both can lead to confusion and inconsistency in how instances of that struct are manipulated, especially when changing the receiver type.
To keep object interactions with interfaces consistent and straightforward (Remember the ‘method set’ method we discussed in Take 3?)