DEV Community

Cover image for An ode to Stacks and Pointers in Go!
Abhinav Singh
Abhinav Singh

Posted on

An ode to Stacks and Pointers in Go!

Introduction

Let's be honest: pointers can be a tough nut to crack. Misusing them can lead to frustrating bugs and slow performance, especially in concurrent or multi-threaded programs. That's why many languages shield developers from dealing with pointers altogether. However, if you're coding in Go, you can't escape them. Mastering pointer is essential for crafting elegant, efficient code in Go.

Function Frames

In Go programming, functions execute within a defined function frame, each maintaining their distinct memory space. These frames allow the functions to operate autonomously within their designated context, facilitating smooth flow control. While a function can readily access memory within its frame, venturing beyond its frame necessitates indirect access. To tap into the memory within a different function frame, the memory in question must be shared with the function via frame pointer. Grasping the intricacies and constraints imposed by these function frames is paramount.

When a function is invoked within another function, there is a transaction that occurs between the calling and the called function frame. If the function call requires data, then the data must be passed to the other frame in "pass by value" fashion.

Pass by value (also known as pass-by-copy) is the argument passing technique utilized in the Go programming language. This technique permits various forms of arguments in the call, such as constants, variables, and complex expressions, while also ensuring the immutability of arguments. It achieves this by guaranteeing that the function receives a copy of the data. Consequently, the function can modify the data without impacting the original data.


1. package main
2. 
3. import "fmt"
4. 
5. func increment(val int) {
6.  // Increment the value of val
7.  val++
8. 
9.  // Printing the value of the val variable
10.     fmt.Println("Val value:", val)
11. 
12.     // Printing the address of the val variable
13.     fmt.Println("Val Address:", &val)
14. 
15. }
16. 
17. func main() {
18.     // Declaring variable of type int
19.     var counter int
20. 
21.     // Setting the value of the counter variable to 1
22.     counter = 1
23. 
24.     // Printing the value of the counter variable
25.     fmt.Println("Counter value:", counter)
26. 
27.     // Printing the address of the counter variable
28.     fmt.Println("Counter Address:", &counter)
29. 
30.     // Pass counter as an argument to the increment function
31.     increment(counter)
32. 
33.     // Printing the value of the counter variable
34.     fmt.Println("Counter value after increment:", counter)
35. 
36.     // Printing the address of the counter variable
37.     fmt.Println("Counter Address after increment:", &counter)
38. 
39. }
Enter fullscreen mode Exit fullscreen mode

When executing a Go program, the runtime initiates the main goroutine to commence executing all code, including that within the main function. A goroutine represents a path of execution assigned to an operating system thread for execution on one of the cores. Each goroutine is assigned an initial ~2KB block of contiguous memory, forming its stack space.

The stack serves as the physical memory location for each function frame. The image below illustrates the physical memory allocated to the main function frame on the stack.


Figure 1 - Main Function Frame

In Figure 1, a segment of the stack is delineated for the main function, forming what is known as a "Function Stack Frame". This frame serves to demarcate the main function's boundary within the stack and is created during the execution of the function call. Additionally, within the main frame, memory for the counter variable is located at address 0xc000012028.

Function Calls

The main function calls the increment function as shown on line 31. A new function call means that the goroutine needs to create a new stack frame for increment function(called function). To successfully execute this function call, data needs to be passed across the stack frame and placed into the newly created stack frame as specified in the declaration of the increment function on line 5. In this specific case, an integer value(counter) is expected to be copied and passed during the call.

As the increment function can only read and write to memory locations within its own frame, it needed a variable val of type int to store and access its own copy of the counter value being passed. The passed value of counter is copied and passed into the newly created val variable inside the increment function frame.


Figure 2 - Main & Increment Frames

You'll notice that the stack now comprises two frames: one for the main function and, beneath it, one for the increment function. Within the increment frame, the variable val holds the copied value of 1 passed during the function call. Positioned at address 0xc0000a2018, the val variable resides lower in memory, reflecting the sequential arrangement of frames along the stack. This ordering is merely an implementation detail devoid of significance. Crucially, the goroutine extracted the counter value from the main frame and duplicated it within the increment frame using the val variable.

The control executes all the lines under the increment function and the output on the terminal should look something like this:

Val value: 2
Val Address: 0xc0000a2018
Enter fullscreen mode Exit fullscreen mode

Function Returns

After executing all the lines under the increment function, the control returns to the main function with a small change to the stack frames.

The stack frame associated with the increment function is now part of the garbage memory because the control has shifted to main function thereby making the main function frame the active frame. The memory that was framed for the increment function is left untouched.

It's pointless to tidy up the memory of the returning function's frame because there's no way to know if that memory will be needed again. Hence, the memory remains unchanged. The stack memory for each frame is actually wiped clean during every function call. This cleaning process happens when values are initialized within the frame. Since all values are set to their default "zero value"(0 is the default value for the int type) during initialization, the stacks naturally tidy themselves up with each function call.


Figure 3 - Frames after increment func returns

The final output of the above code block on the terminal should look something like this:

Counter value: 1
Counter Address: 0xc000012028
Val value: 2
Val Address: 0xc0000a2018
Counter value after increment: 1
Counter Address after increment: 0xc0000a2010
Enter fullscreen mode Exit fullscreen mode

Sharing Values

In case if it was important for the increment function to operate directly on the counter variable that exists inside the main function's stack frame, pointers prove invaluable. They facilitate value sharing, enabling functions to operate on variables located outside their own stack frame.

Indirect Memory Access

The code block below performs a function call passing an address "by value". The value of the counter variable from the main stack frame is shared with the increment function.


1. package main
2. 
3. import (
4.  "fmt"
5. )
6. 
7. func increment(val *int) {
8.  // Increment the value of val
9.  *val++
10. 
11.     // Printing the value of the val variable
12.     fmt.Println("Val value:", val)
13. 
14.     // Printing the address of the val variable
15.     fmt.Println("Val Address:", &val)
16. 
17.     // Printing the value the val pointer points to
18.     fmt.Println("Val Points To:", *val)
19. 
20. }
21. 
22. func main() {
23.     // Declaring variable of type int
24.     var counter int
25. 
26.     // Setting the value of the counter variable to 1
27.     counter = 1
28. 
29.     // Printing the value of the counter variable
30.     fmt.Println("Counter value:", counter)
31. 
32.     // Printing the address of the counter variable
33.     fmt.Println("Counter Address:", &counter)
34. 
35.     // Pass counter as an argument to the increment function
36.     increment(&counter)
37. 
38.     // Printing the value of the counter variable
39.     fmt.Println("Counter value after increment:", counter)
40. 
41.     // Printing the address of the counter variable
42.     fmt.Println("Counter Address after increment:", &counter)
43. 
44. }

Enter fullscreen mode Exit fullscreen mode

The three interesting changes that were made to facilitate indirect memory access are as follows:

  1. On line 36, the code is not copying and passing the "value of" counter but instead the "address of" counter. "&" operator extracts the address of the counter variable in the main function frame. As Go is a pass by value language, so we are still passing a value but just in form of an address.

  2. On line 7, *int highlights that the increment function expects a address of the variable(pointer) rather than the variable itself. Since the variable counter is of type int, it's pointer type is *int.


Figure 4 - Frame Stack after func call to increment

  1. On line 9, the * character in *val++ is acting as an operator and extracts the value that pointer is pointing to. The pointer variable allows indirect memory access outside of the function's frame. The process of extracting the the value that pointer is pointing to is called dereferencing.


Figure 5 - Frame Stack after executing line 9

The final output of the above code block on the terminal should look something like this:

Counter value: 1
Counter Address: 0xc000012028
Val value: 0xc000012028
Val Address: 0xc00004a028
Val Points To: 2
Counter value after increment: 2
Counter Address after increment: 0xc000012028
Enter fullscreen mode Exit fullscreen mode

Top comments (0)