Table of contents
Until today, pointers were the most confusing part of Go for me. That is until I actually sat down and spent some quality time with them. Here are the resources that made it click for me if you want to dig in:
The best visual explanation:
[YouTube] Junmin Lee - Golang Tutorial 3 - Golang pointers explained, once and for all
Worthy Mentions:
[YouTube] Tech With Tim - Golang Tutorial #19 - Pointers & Derefrence Operator (& and *)
[YouTube] Bryan English - When You Should Actually Use Pointers In Go
tl;dr
pointer lets you change something outside of your current scope
&
is used to get the address of
*
is used to find out what's stored at an address
when *
is in front of a variable will return what the variable is pointing to
func main() {
i := 4
iPtr := &i
// get the value of what the iPtr is pointing to
fmt.Println(*iPtr)
}
when *
is in front of a type expects a pointer to a value of that type
func doTypePointer(i *int) {
// need to dereference
// because i consumed by the function is a pointer to a value
fmt.Println(*i)
}
func main() {
i := 4
iPtr := &i
doTypePointer(iPtr)
}
Let's dive in
Since go is a pass-by-value ( read as go creates a copy of variables passed into functions ) garbage collected language. By default, you’re unable to change a value that was created in another function, aka the variable is out of the scope, aka is located in a different frame of the memory stack. That’s a confusing group of words, isn’t it?
Let us start with the memory. Each time we run go code, a go routine is spawned and a stack of memory is allocated to it. The memory stack in its essence is a line of work that the code we run needs to execute, it operates on LIFO (last in, first out) idea, meaning that the last item added to the stack will be executed first. There's also heap, that works as a cache for values that have to be stored for use in other functions, garbage collector handles the values pushed to heap.
Cool fact, you can use
go build -gcflags="-m"
to see whether variables will escape to heap or not.
As you know, code is executed line by line. Every time a function is invoked inside go code, that function is put on top of the stack with its separate scope.
func doAddOne(i int) {
i += 1
fmt.Printf("value in doAddOne: %v\n",i)
}
func main() {
n := 3
doAddOne(n)
fmt.Printf("value in main: %v\n",i)
fmt.Println("we continue")
}
in Go, the main()
function is the entry point of any program that you run, so it will be put on the stack first, then, we encounter doAddOne()
that’ll be thrown on top of the main()
in the memory stack, once doAddOne()
finish everything it needs to do, it will be removed from the stack automatically and the execution of main()
will continue.
the output that we get after running the above example is as follows:
$ go run pointers.go
doAddOne value: 4
main value: 3
we continue
we can see that the value was changed inside the doAddOne()
function, and the value in the main()
function stayed the same.
What if we want/need to change the value inside the main()
function? Pointers, my friend!
When we create a variable, it gets the things we give to it - the name and the value. The computer also gives it an address that provides a reference to where this variable will live in the computer's memory. This address is what gives us a way to change things outside the scope of a specific function.
func doAddOne(i *int) {
*i += 1
fmt.Printf("doAddOne address: %v\n", i)
fmt.Printf("doAddOne value: %v\n", *i)
}
func main() {
n := 3
doAddOne(&n)
fmt.Printf("main value: %v\n", n)
fmt.Println("we continue")
}
the original example didn’t change much, some *
and &
appeared though.
doAddOne()
now accepts a pointer to an integer. Meaning that instead of a value, like before, the function now expects an address to the value it needs to work with.
Inside the main()
we provide the &n
that, instead of a flat value, gives us the address of the variable that stores our value.
the output from the example above is as follows:
$ go run pointers.go
doAddOne address: 0xc0000ba000
doAddOne value: 4
main value: 4
we continue
in the previous example, i
returned the value, now - i
return a memory address ( the 0xc0000ba000
thing ). *i
returns the actual value we are working with. Why the asterisk? Because we need to access the value stored at the memory address of the pointer ( aka dereference the pointer )
We can also see that the n
value inside the main()
function is now also updated with the result of the doAddOne()
function. That’s exactly because we changed the value at the memory address of i
.
That was a long one, I hope that made it a bit clearer for someone.
When you can use pointers?
should you? that's up to you!
- If you have a huge chunk of data that you need to pass around - don’t. Store it in memory, pass and change the memory address of that blob of data.
- when you need a descriptive state, default-zero value of a pointer is
nil
example : there's an app that your gf wrote asking whether you love her. If she used value instead of a pointer, the bool will default to
false
. God bless your soul. Instead - she should use a pointer, then she would see that you haven't opened the app. You're still in trouble, but at least alive to live another day.
p.s. there are other, probably better and easier ways to implement this logic, but having this as an option is cool never the less.
- when you need to introduce mutability
type person struct {
name string
}
func rename(c *person) {
c.name = "new name"
}
func main() {
p := person{}
rename(&p)
fmt.Println(p)
}
- When you need to create receiver functions ( aka add functionality to a struct )
func (s *calculator) doAddOne(n *int) {
*n += 1
fmt.Printf("doAddOne address: %v\n", n)
fmt.Printf("doAddOne value: %v\n", *n)
}
type calculator struct {
price int
}
func main() {
n := 3
c := calculator{}
c.doAddOne(&n)
fmt.Printf("main value: %v\n", n)
}