Go’s popularity has exploded in recent years. The 2020 HackerEarth Developer survey found that Go was the most sought-after programming language among experienced developers and students. The 2021 Stack Overflow Developer survey reported similar results, with Go being one of the top four languages that developers want to work with.
Given its popularity, it’s important for web developers to master Go, and perhaps one of the most critical components of Go is its pointers. This article will explain the different ways pointers can be created and the types of problems pointers fix.
Go is a statically typed, compiled language made by Google. There are many reasons why Go is such a popular choice for building robust, reliable, and efficient software. One of the biggest draws is Go’s simple and terse approach to writing software, which is apparent in the implementation of pointers in the language.
When writing software in any language, devs must consider what code could mutate in their codebase.
When you begin to compose functions and methods and pass around all different types of data structures in your code, you need to be careful of what should be passed by value and what should be passed by reference.
Passing an argument by value is like passing a printed copy of something. If the holder of the copy scribbles on it or destroys it, the original copy you have is unchanged.
Passing by reference is like sharing an original copy with someone. If they change something, you can see — and have to deal with — the changes they’ve made.
Let’s start with a really basic piece of code and see if you can spot why it might not be doing what we expect it to.
package main import ( "fmt" ) func main() { number := 0 add10(number) fmt.Println(number) // Logs 0 } func add10(number int) { number = number + 10 }
In the above example, I was trying to make the add10()
function increment number
10
, but it doesn’t seem to be working. It just returns 0
. This is exactly the issue pointers solve.
If we want to make the first code snippet work, we can make use of pointers.
In Go, every function argument is passed by value, meaning that the value is copied and passed, and by changing the argument value in the function body, nothing changes with the underlying variable.
The only exceptions to this rule are slices and maps. They can be passed by value and because they are reference types, any changes made to where they’re passed will change the underlying variable.
The way to pass arguments into functions that other languages consider “by reference” is by utilizing pointers.
Let’s fix our first example and explain what’s happening.
package main import ( "fmt" ) func main() { number := 0 add10(&number) fmt.Println(number) // 10! Aha! It worked! } func add10(number *int) { *number = *number + 10 }
The only major difference between the first code snippet and the second was the usage of *
and &
. These two operators perform operations known as dereferencing/indirection (*
) and referencing/memory address retrieval (&
).
&
If you follow the code snippet from the main
function onward, the first operator we changed was to use an ampersand &
in front of the number
argument we passed into the add10
function.
This gets the memory address of where we stored the variable in the CPU. If you add a log to the first code snippet, you will see a memory address represented with hexadecimal. It will look something like this: 0xc000018030
(it will change each time you log).
This slightly cryptic string essentially points to an address on the CPU where your variable is stored. This is how Go shares the variable reference, so changes can be seen by all the other places that have access to the pointer or memory address.
*
If the only thing we have now is a memory address, adding 10
to 0xc000018030
might not be exactly what we need. This is where dereferencing memory is useful.
We can, using the pointer, deference the memory address into the variable it points to, then do the math. We can see this in the above code snippet on line 14:
*number = *number + 10
Here, we are dereferencing our memory address to 0
, then adding 10
to it.
Now the code example should work as initially expected. We share a single variable that changes are reflected against, and not by copying the value.
There are some extensions on the mental model we have created that will be helpful to understand pointers further.
nil
pointers in GoEverything in Go is given a 0
value when first initialized.
For example, when you create a string, it defaults to an empty string (""
) unless you assign something to it.
Here are all the zero values:
0
for all int types0.0
for float32, float64, complex64, and complex128false
for bool""
for stringnil
for interfaces, slices, channels, maps, pointers, and functionsThis is the same for pointers. If you create a pointer but don’t point it to any memory address, it will be nil
.
package main import ( "fmt" ) func main() { var pointer *string fmt.Println(pointer) // <nil> }
package main import ( "fmt" ) func main() { var ageOfSon = 10 var levelInGame = &ageOfSon var decade = &levelInGame ageOfSon = 11 fmt.Println(ageOfSon) fmt.Println(*levelInGame) fmt.Println(**decade) }
You can see here we were trying to re-use the ageOfSon
variable in many places in our code, so we can just keep pointing things to other pointers.
But on line 15, we have to dereference one pointer, then dereference the next pointer it was pointing to.
This is utilizing the operator we already know, *
, but it is also chaining the next pointer to be dereferenced, too.
This may seem confusing, but it will help that you have seen this **
syntax before when you look at other pointer implementations.
The most common way to create pointers is to use the syntax that we discussed earlier. But there is also alternate syntax you can use to create pointers using the new()
function.
Let’s look at an example code snippet.
package main import ( "fmt" ) func main() { pointer := new(int) // This will initialize the int to its zero value of 0 fmt.Println(pointer) // Aha! It's a pointer to: 0xc000018030 fmt.Println(*pointer) // Or, if we dereference: 0 }
The syntax is only slightly different, but all of the principles we have already discussed are the same.
To review everything we’ve learned, there are some often-repeated misconceptions when using pointers that are useful to discuss.
One commonly repeated phrase whenever pointers are discussed is that they’re more performant, which, intuitively, makes sense.
If you passed a large struct, for example, into multiple different function calls, you can see how copying that struct multiple times into the different functions might slow down the performance of your program.
But passing pointers in Go is often slower than passing copied values.
This is because when pointers are passed into functions, Go needs to perform an escape analysis to work out whether the value needs to be stored on the stack or in the heap.
Passing by value allows all the variables to be stored on the stack, which means garbage collection can be skipped for that variable.
Check out this example program here:
func main() { a := make([]*int, 1e9) for i := 0; i < 10; i++ { start := time.Now() runtime.GC() fmt.Printf("GC took %s\n", time.Since(start)) } runtime.KeepAlive(a) }
When allocating one billion pointers, the garbage collector can take over half a second. This is less than a nanosecond per pointer. But it can add up, especially when pointers are used this heavily in a huge codebase with intense memory requirements.
If you use the same code above without using pointers, the garbage collector can run more than 1,000 times faster.
Please test the performance of your use cases, as there are no hard and fast rules. Just remember the mantra, “Pointers are always faster,” is not true in every scenario.
I hope this has been a useful summary. In it, we covered what Go pointers are, different ways they can be created, what problems they solve, as well as some issues to be aware of in their use cases.
When I first learned about pointers, I read a multitude of well-written, large codebases on GitHub (like Docker for example) to try and understand when and when not to use pointers, and I encourage you to do the same.
It was very helpful to consolidate my knowledge and understand in a hands-on way the different approaches teams take to use pointers to their fullest potential.
There are many questions to consider, such as:
Deciding when and how to use pointers is on a case-by-case basis, and I hope you now have a thorough understanding of when to best utilize pointers in your projects.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.