Demystifying function parameters in Go
In this post we're going to talk about how (and why!) different types of function parameters behave differently in Go.
If you're new (or even not-so-new) to Go, this can be a common source of confusion and questions. Why do functions generally mutate maps and slices, but not other data types? Why isn't my slice being mutated when I append to it in a function? Why doesn't assigning a new value to a pointer parameter have any effect outside the function?
Once you understand how functions and the different Go types work, the answers to these kind of questions becomes clearer. You'll discover that Go's behavior consistently follows a few fairly straightforward rules, which I'll aim to highlight in this post.
(If you just want the actionable takeaways, you can skip to the summary.)
Parameters and arguments
Before we dive into this post, I'd like to quickly explain the difference between parameters and arguments. People sometimes use these terms interchangeably – but for this tutorial it's important that we're precise on the terminology.
- Parameters are the variables that you define in a function declaration.
- Arguments are the values that get passed to the function for execution.
(A neat way to remember this is arguments = actual values.)
Functions operate on copies of the arguments
It's important to understand that when you call a function in Go, the function always operates on a copy of the arguments. That is, the parameters contain a copy of the argument values.
We can illustrate this with the following short example:
When you run this program it will print "The score is 20"
, not "The score is 30"
. That's because the parameter s
in incrementScore()
contains a copy of the score
argument, and when we increment the value with s += 10
we are updating this copy, not the original score
variable in the main()
function.
We can confirm this behavior by using the reference operator &
to get the memory addresses of the score
argument and s
parameter, like so:
If you run this, you'll see that the printed memory addresses are different – in my case 0xc000012028
for the score
argument and 0xc000012040
for the parameter s
. That confirms that they are truly different variables, with their values stored at different locations in memory. With that in mind, it's not surprising that changing one doesn't change the other.
Just to hammer home the point one more time: in Go, functions always operate on a copy of the arguments. There are no exceptions to this.
Pointer parameters
So, what can we do if we want incrementScore()
to actually change the score
variable?
The answer is to change the signature of incrementScore()
so that the parameter s
is a pointer, like func incrementScore(s *int)
. Let's take a look at a working example and then talk it through.
In this code:
- We declare the
score
variable normally inmain()
with the linescore := 20
. - Then in the line
incrementScore(&score)
we use the reference operator&
to get a pointer to thescore
variable, and pass this pointer as the argument toincrementScore()
. Remember, a pointer just contains a memory address – in this case it's the memory address of thescore
variable. - When
incrementScore()
is executed, the parameters
contains a copy of this pointer. But this copy still holds the same memory address – the memory address of thescore
variable. - In the line
newScore := *s + 10
we use the dereference operator*s
to 'read through' and get the underlying value at that memory address, and add ten to it. - Then in the next line
*s = newScore
we use the dereference operator again to 'write through' and setnewScore
as the value at that memory address. - The end result is that we've mutated the value at the memory address of the
score
variable. So when the program executes the final line of code, we get the output"The score is 30"
.
I should point out that I made the code here a bit more verbose than it needs to be. You can simplify incrementScore()
to use the +=
operator like so:
Write-though vs reassignment
In the example above, we used the deference operator *s
to read-through and then write-through to the underlying memory address. But what would happen if we didn't write-through, and assigned a completely new pointer value to s
instead? Let's take a look.
If you run this, you'll see we're back to the situation where the score
value isn't being mutated, and the program is printing "The score is 20"
again.
The only thing that's changed here is the body of the incrementScore()
function. In this code:
- The line
newScore := *s + 10
is exactly the same as before. It reads through to get the underlyingscore
value from thes
parameter, adds ten to it, and assigns the result to thenewScore
variable. - But the line
s = &newScore
is different. Here we use the reference operator&newScore
to get a pointer to thenewScore
variable, and assign this tos
. This means that the variables
no longer contains the memory address of thescore
variable frommain()
– it now contains the memory address of thenewScore
variable.
So, in this example, incrementScore()
doesn't ever 'write-through' and change anything at the memory address of the score
variable. All it does is replace s
with a completely different pointer, which is then discarded when the function returns.
This is just one example of a more general rule. Assigning a new value to a parameter with the =
operator won't affect the argument in any way (unless the parameter is a pointer and you are dereferencing it and 'writing-through' a new value). Remember, the parameter is just a copy of the argument.
Automatic dereferencing
Let's continue with the same example, but update the incrementScore()
function so that it accepts a pointer to a custom player
struct, containing the player's name and score.
So as you might expect, because the parameter p
in incrementScore()
is a pointer, the changes that we make to p
affect the data at the underlying memory address of p1
and the program prints "The score for Alice is 30"
.
But the most interesting part here is the line of code p.score += 10
in the incrementScore()
function. p
is a pointer, but we appears that we don't have to dereference it using the *
operator in order to write-through the new value.
You could – if you wanted to – change this line to be (*p).score += 10
. That's perfectly valid and will compile fine. But it's not necessary. If you have a pointer to a struct (which is what the p
parameter is here), then Go will automatically dereference the pointer for you when you use the dot operator .
on it to access a field or call a method.
You can also use index expressions on a pointer to an array without dereferencing it. (Note that this will only work on arrays, not slices). For example:
"Reference types"
Everything we've illustrated in this tutorial so far is true when the parameter type is a basic type, a struct, an array, a function, or a pointer to any of those things.
However, the behavior that you get when a parameter is a map, slice or channel type needs some further discussion. Once you realize how these types are implemented at runtime behind the scenes, you'll see that their behavior actually follows the same rules as the other Go types – but nonetheless it can be a bit confusing at first.
If you've been programming for a while, you might be familiar with the terms pass-by-value and pass-by-reference from other languages. You might have also heard or read people in the Go world saying things like "maps, slices and channels are reference types", or "maps, slices and channels are passed by reference".
Well... the sentiment there is sort of right, but the wording isn't correct and needs tightening up.
Firstly, Go does not support pass-by-reference behavior. I've probably banged this drum enough already now, but parameters are always a copy of the arguments. That is, they are always passed by value. Even pointers are passed by value; a pointer parameter will contain a copy of the pointer.
Strictly speaking, there's also no such group of things in Go known as "reference types". To be fair, the Go spec did use "reference types" as an umbrella term for maps, slices and channels in one sentence, but this was removed over a decade ago (with the commit message Go has no 'reference types'
). Basically, I recommend forgetting hearing the term "reference types" in relation to Go, and replacing it with an understanding of how maps, channels and slices are actually implemented.
Maps and Channels
The important thing to understand is that behind-the-scenes when your code is running, the Go runtime implements a map as a pointer to a runtime.hmap
struct, and a channel as a pointer to a runtime.hchan
struct.
This means that map and channel parameters behave in a similar way to regular pointer parameters. The parameter will contain a copy of the map or channel, but this copy will still point to the same underlying memory location that holds the runtime.hmap
or runtime.hchan
struct. In turn, that means that any changes you make to a map or channel parameter will also mutate the argument.
Let's look at an example, where we create a scores
map containing the names and scores for multiple players like map[string]int{"Alice": 20, "Bob": 160}
, and then pass it to a function that increments the score by ten for all players.
When you run this, you'll see that incrementAllScores()
mutates the scores
argument and the program prints map[Alice:30 Bob:170]
as the output.
Because of this behavior, you normally won't need to use a pointer to a map or channel as a function parameter.
On the other hand, if you don't want a function to mutate a map, you can use the maps.Clone()
function to create a clone that points to a different memory location, and work on the clone instead.
Slices
How slices work behind the scenes in Go can be pretty difficult to grok, and if you'd like a detailed explanation I recommend reading the Go Slices: usage and internals post on the official blog.
But as a high-level summary, the Go runtime implements slices as a runtime.slice
struct. This struct wraps a pointer to a (fixed-size) array that actually stores the slice data. You can think of a slice as being a bit like a 'window through' to a segment of this underlying array.
So when you have a function with a slice parameter, the parameter will contain a copy of the slice argument you pass it. Effectively, it will have a copy of the runtime.slice
struct. But the pointer in this copy of runtime.slice
will still point to the same underlying array, meaning that any changes you make to a slice parameter will also mutate the argument.
To demonstrate this, let's say that we have a slice containing some player scores, and pass it to a addBonus()
function that adds fifty to each score in the slice.
When you run this code it will print [60 70 80]
, demonstrating that the changes made in addBonus()
mutated the elements in the scores
slice.
If you don't want a function to mutate a slice, you can make a clone of it using slices.Clone()
and work on that instead.
So far, so good. Slices generally behave pretty much like maps and channels, in the sense that changing a slice parameter will mutate the argument. If you don't want that, you can make a clone at the start of the function and use the clone instead.
But using append()
on a slice parameter can sometimes be a source of confusion. Consider the following code, where we create a variadic addScores()
function that appends some new values to a scores
slice.
(Yes, this is a bit of a silly example, but it illustrates the point in a simple way.)
When you run this program, it will print out [10 20 30]
– demonstrating that the append()
operation in addScores()
has not affected the scores
argument.
This actually makes sense and is consistent with the other behavior we've seen in this post. Earlier on I said:
Assigning a new value to a parameter with the
=
operator won't affect the argument in any way (unless the parameter is a pointer and you are dereferencing it and 'writing-through' a new value). Remember, the parameter is just a copy of the argument.
The code s = append(s, values...)
is no different. We're replacing the s
parameter with a new value, and this operation doesn't touch the argument in any way.
So what about when you want an append()
operation in a function to mutate the argument? The answer here is to make the parameter a pointer to a slice, like so:
Now with the line *s = append(*s, values...)
, whatever is returned by the append()
function will be 'written-through' to the memory address of the scores
argument.
Exactly the same logic applies for operations to 'reslice' a slice and assign the result back to the parameter, like s = s[0:1]
. If you want this operation to mutate the argument, you should make the parameter a pointer and dereference it like *s = (*s)[0:1]
.
Summary
We've covered a lot of ground in this post, so I'll try to summarize everything into a handful of take-away points.
Parameters always contain a copy of the argument. Go doesn't have "reference types" or support pass-by-reference semantics.
For the basic Go types, as well as structs, arrays and functions, changing the value of a parameter in the function body won’t change the value of the argument. But if you do want to mutate the argument, you can use a pointer parameter instead and dereference it inside the function to ‘write-through’ a new value to the argument's memory address. For common operations on structs and arrays, Go will automatically dereference the pointer for you.
Because of the way that they're implemented by the Go runtime, changes you make to map, slice, channel parameters in a function will mutate the argument. If you don't want this, make a clone at the start of the function and use that instead.
Using the
=
operator to assign a new value to a parameter does not affect the argument (unless you are manually-or-automatically dereferencing a pointer and 'writing-through' a new value). So for slices, if you want a function to perform an append or reslice operation that mutates the argument, you should use a pointer to a slice as the function parameter and dereference it as necessary.