Pointers In Go - Part 1

Pointers In Go - Part 1

Preface

This is the first article in a series where we dive deep into pointers in Go. We are going to start with the very basics and work our way toward understanding everything from the fundamental operators used to define pointers to how to think about heap vs. stack allocations when using pointers. Along the way, I will provide general guidelines to help you get started. My goal for this series is to give you a solid foundation—not just in pointers in Go but also in memory allocation concepts and how to determine when and how to use pointers while understanding the pitfalls that come with them.

Introduction

Pointers are often viewed as an intimidating or even scary concept in programming. This likely stems from languages like C and C++, which require programmers to manage their own memory. As projects grow in complexity, the surface area for potential memory-related bugs increases, making it nearly impossible for a single programmer to track all allocations and deallocations.

Over the years, I have learned some clever techniques for managing memory in C—Arena Allocators being my personal favorite. However, pointers can still feel like black magic to newcomers and can be daunting to grasp.

Pointer vs. Value Semantics

Part 1 will focus on ensuring we have a solid understanding of how Go moves data around a program.

Value Semantics in Action

Let's look at some code and see what is happening:

func increment(num int) {
    fmt.Printf("num: %d\n", num)
    num++
    fmt.Printf("num++: %d\n", num)
}

func main() {
    increment(8)
}        

The above code is straightforward. We have a function called increment that accepts an integer value. Inside the function, we print out the value of num, increment it by 1, and print the new value.

If we run this code, we get the expected output:

$ go run main.go
num: 8
num++: 9        

If you've been programming for any amount of time, this isn't surprising. Let's take it a step further:

func increment(num int) {
    fmt.Printf("num: %d\n", num)
    num++
    fmt.Printf("num++: %d\n", num)
}

func main() {
    myNum := 8
    increment(myNum)
    fmt.Printf("myNum: %d\n", myNum)
}        

Output:

$ go run main.go
num: 8
num++: 9
myNum: 8        

If you come from languages like Python or Java, this output likely doesn't surprise you. Go isn't trying to be unique here; we're simply making a copy of myNum and passing that into the increment function.

Inside the function, Go creates a new stack frame on the call stack, copies the value of myNum into the parameter num (8 in this case), and isolates any work done on this data within that frame. This technique is called pass-by-value semantics, and all Go functions use pass-by-value. This ensures that function arguments are copies of the original data, reducing unexpected side effects. Because we work on a copy, any modifications inside the function do not affect the original variable.

We can confirm this by printing the memory addresses of our variable and function parameter:

func increment(num int) {
    fmt.Printf("num: %d, %p\n", num, &num) 
    num++
    fmt.Printf("num++: %d, %p\n", num, &num)
}

func main() {
    myNum := 8
    increment(myNum)
    fmt.Printf("myNum: %d, %p\n", myNum, &myNum)
}        

Output:

$ go run main.go
num: 8, 0xc00011a048
num++: 9, 0xc00011a048
myNum: 8, 0xc00011a040        

As you can see, num and myNum have different memory addresses, explicitly showing that a copy of the value was made in a new memory location.

Note: The Go playground runs on a 64-bit architecture, so int values take up 8 bytes. On a 32-bit system, an int would take up 4 bytes.

Pointer Semantics

A note before we begin: In these examples, I am using a pointer to a primitive int type. In general, this is not a good practice in Go, for reasons beyond the scope of this introductory article. The focus here is to demonstrate the semantics of pointers rather than illustrate best practices for handling primitive values. In the next article, I will introduce more real-world use cases.

Now that we have covered value semantics (passing copies of data), let's explore pointer semantics.

We'll start from the beginning and build our intuition using pointer semantics. I've added comments to clarify the new symbols:

// *int means "a pointer to an int value"
func incrementPointer(num *int) {
    // *num means "dereference the value at the address of num"
    fmt.Printf("num: %d\n", *num)

    // Increment the value stored at the address
    *num++ 

    fmt.Printf("num++: %d\n", *num)
}

func main() {
    myNum := 8

    // &myNum means "get the address of myNum"
    incrementPointer(&myNum)
}        

Output:

$ go run main.go
num: 8
num++: 9        

Success! We were able to increment a value using pointer semantics.

You may have noticed that in our first program, we passed 8 directly into the function. However, with pointers, we must first store the value in memory before passing it to a function expecting a pointer. The & operator means "get the address of," and the integer 8 doesn’t "live" anywhere in RAM memory until it's assigned to a variable.

Exercise: Try passing the value 8 directly into incrementPointer using the & operator. What happens?

Now, let's convert our second program to use pointer semantics:

func incrementPointer(num *int) {
    fmt.Printf("num: %d\n", *num)
    *num++
    fmt.Printf("num++: %d\n", *num)
}

func main() {
    myNum := 8
    incrementPointer(&myNum)
    fmt.Printf("myNum: %d\n", myNum)
}        
Before running this, try to predict the output.

Output:

$ go run main.go
num: 8
num++: 9
myNum: 9        

Unlike our value semantics example, where myNum remained unchanged, here we see that myNum is affected. This is a powerful effect.

"With great power comes great responsibility." — Uncle Ben

Before we finish let's take a look at these output with our addresses now and see if we can uncover anything.

Let's add memory addresses to our output:

func incrementPointer(num *int) {
    fmt.Printf("num: %d, %p\n", *num, num)
    *num++
    fmt.Printf("num++: %d, %p\n", *num, num)
}

func main() {
    myNum := 8
    incrementPointer(&myNum)
    fmt.Printf("myNum: %d, %p\n", myNum, &myNum)
}        

Output:

$ go run main.go
num: 8, 0xc000010070
num++: 9, 0xc000010070
myNum: 9, 0xc000010070        

We see that all addresses are identical—indicating we are modifying the same memory location.

Go’s Pass-by-Value Applies to Pointers Too

Even though we pass a pointer, Go still uses pass-by-value. Consider this:

func incrementPointer(num *int) {
	fmt.Printf("num: %d, %p\n", *num, &num)
	*num++
	fmt.Printf("num++: %d, %p\n", *num, &num)
}

func main() {
	myNum := 8
	incrementPointer(&myNum)
	fmt.Printf("myNum: %d, %p\n", myNum, &myNum)
}        

Output:

$ go run main.go
num: 8, 0xc000106038
num++: 9, 0xc000106038
myNum: 9, 0xc000010070        

The function receives a copy of the pointer, not the original, meaning the address of num inside incrementPointer is different from myNum in main().

Here is a diagram to help explain how this works.

What you can see is that we have a stack of memory for our main() Go routine. In the first frame, we store our variable myNum with a value of 8 at the address of 0xc000010070.

Next, we call our incrementPointer function, passing the address of our myNum variable as the function parameter. In blue, you can see that our new function gets a new stack frame to store memory. The parameter num has a value of 0xc000010070 and its own address of 0xc000106038 because it is copied into the function’s stack frame. The blue arrow indicates that the value stored in the num parameter is an address pointing to the value 8, which exists one level up in our stack memory.


Conclusion

This article introduced pointers in Go, covering value and pointer semantics. Understanding these concepts is crucial for writing efficient, idiomatic Go code. In the next part, we’ll explore real-world applications, including how pointers interact with slices, maps, and structs!

Jake Mayo

Tech Lead - Agora Data

1 个月

Thanks for pointing us in the right direction!

要查看或添加评论,请登录

Justin Dodson的更多文章

社区洞察