Go: pointers and memory

Photo by Ahmad Odeh on Unsplash

Go: pointers and memory

·

6 min read

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 & Dereference Operator (& and *)

[YouTube] Bryan English - When You Should Actually Use Pointers In Go

tl;dr

A pointer allows you to modify 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, it will return what the variable is pointing to.

func main() {
    i := 4
    iPtr := &i
    fmt.Println(&i)
    // get the value of what the iPtr is pointing to
    fmt.Println(*iPtr) // output - 4
    fmt.Println(iPtr)  // output is a memory address of i - 0x...
    fmt.Println(&iPtr) // output is a memory address of iPtr - 0x...
}

When * is in front of a type, it 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 language (read as Go creates a copy of variables passed into functions), by default, you’re unable to change a value that was created in another function.

The combination of the memory stack and heap form the building blocks of a Go program's memory management system.

When running Go code, a new Go routine is created and assigned a stack of memory. This memory stack contains a set of instructions that the code needs to execute and operates on the lifo ( last in first out ) principle, where the most recently added instruction is executed first.

In addition to the memory stack, there is also a heap that serves as a cache for values that are needed across multiple functions.

The garbage collector is responsible for managing the values stored in the heap, ensuring that they are cleaned up when they are no longer needed.

💡 Cool fact, you can use go build -gcflags="-m" to see whether variables will escape to the heap or not.

As you may be aware, 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",n)
    fmt.Println("we continue")
}

In Go, the main() function is the entry point of any program that you run. It will be put on the stack first, then, we encounter doAddOne() that’ll be pushed 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 or need to change the value inside the main() function? That's where pointers come in handy!

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. This means that, instead of a value (as before), the function now expects a memory address that points to the value it needs to manipulate.

  • 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 (dereference the pointer).

As you can see, the n value inside the main() function has been updated with the result of the doAddOne() function. That’s 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 use pointers?

That decision is up to you!

  • When dealing with large data structures, passing them as values to functions can be inefficient, as it requires copying the entire data structure. In such cases, using pointers can help improve performance.

  • When you need a descriptive state, the default-zero value of a pointer is nil.

For example, imagine there's an app that your girlfriend wrote, asking whether you love her. If she used a 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.

  • When you need to create receiver functions (add functionality to a struct)
type Calculator struct {
    price int
}

func (s *Calculator) AddOne() {
    s.price += 1
}

func main() {
    c := Calculator{}
    c.price = 10
    c.AddOne()
    fmt.Println(c.price)
}
  • By default, when a variable is passed to a function, a copy of its value is created. If you need to modify the original value of a variable inside a function, you can pass its memory address as a pointer and modify the value at that address.

  • When multiple functions or goroutines need to access and modify the same variable, passing a pointer to that variable can ensure that all modifications are made to the same instance of the variable.