Pointers and Memory: &, * and an Intro to Escape Analysis
Module 2: Composite Data Types & Object-Oriented Design | Lesson: m02-l01 | Level: Beginner After this lesson you will be able to: understand pointer semantics and the memory model, master the difference between new and make, and use escape analysis to optimize code performance.
Learning Objectives
- Use
&and*correctly to take addresses and dereference. - Accurately distinguish when to use
newversusmake, and the differences in their return values. - Explain the behavioral difference in memory between value passing and pointer passing.
- Use
-gcflags=-mfor escape analysis and understand the logic of stack versus heap allocation.
Core Concepts
2.1 Pointer Semantics: & and *
A pointer is the memory address of a variable. In Go, a pointer type is written *T.
- Address operator &: gets a variable's memory address. For example, &x returns the address of x, of type *T.
- Dereference operator *: accesses or modifies the value through an address. For example, *p accesses the value that p points to.
- Nil pointer: a zero-value pointer that points to no valid memory. Dereferencing a nil pointer causes a panic.
- Pointer size: occupies one machine word — 8 bytes on a 64-bit platform, 4 bytes on a 32-bit platform.
2.2 The Difference Between new and make
Go has only two built-in functions for allocating memory, but their purposes are very different:
- new(T): allocates zeroed memory and returns a pointer of type *T. It does not initialize the internal structure; it only zeroes it.
- make(T, args): used only for the three built-in reference types slice, map and chan. It allocates and initializes the internal data structures, and returns the type T itself (not a pointer).
Important fact: it is not that new cannot be used for slices/maps, but that it should not be: it only allocates a zero value (giving a *[]int pointing at a nil slice, or a *map[K]V pointing at a nil map) which you must dereference before use. To create a usable reference type, use make.
2.3 Value Passing vs Pointer Passing
Go function arguments are passed by value by default.
- Value passing: copies the variable's value. For int, struct and the like, modifying the copy does not affect the original.
- Pointer passing: copies the address. Modifying what *p points to affects the original variable.
- Slices are value types: the slice header (pointer, length, capacity) is passed by value, but the underlying array is shared. Therefore modifying slice elements affects the original slice, but reassigning it (e.g. s = append(s, 1)) does not change the original header.
2.4 Stack and Heap: Escape Analysis
- Stack: automatically allocated and freed with function calls; fast, but limited in size.
- Heap: managed by the GC; longer lifetime, large space, but allocation and reclamation have a cost.
- Escape analysis: the compiler's process of deciding whether a variable lives on the stack or the heap. If a variable is still referenced after the function returns, it "escapes" to the heap.
- Tool: use
go build -gcflags=-morgo run -gcflags=-mto inspect the escape analysis results.
Example Code
Example 1: Pointer Basics and a new/make Comparison
Demonstrates taking addresses and dereferencing, and the difference between creating slices and maps with new and make.
package main
import (
"fmt"
)
func main() {
// 1. Pointer basics
var x int = 10
p := &x // take the address; p has type *int
fmt.Printf("x value: %d, address: %p\n", x, p)
*p = 20 // modify through dereference
fmt.Printf("x after modification: %d\n", x)
// 2. Using new
// new returns a pointer to a zeroed value of the type
pInt := new(int)
*pInt = 30
fmt.Printf("new-allocated int value: %d\n", *pInt)
// 3. Using make
// make is used for slice, map, chan, and returns an initialized instance
s := make([]int, 0, 5) // length 0, capacity 5
s = append(s, 1)
fmt.Printf("make-created slice: %v\n", s)
m := make(map[string]int, 10) // the hint is just an initial-capacity suggestion; maps have no cap
m["key"] = 100
fmt.Printf("make-created map: %v\n", m)
}
Expected output:
x value: 10, address: 0xc000012110 (varies on each run)
x after modification: 20
new-allocated int value: 30
make-created slice: [1]
make-created map: map[key:100]
Example 2: An Introduction to Escape Analysis
Demonstrates when a variable escapes to the heap, using -gcflags=-m to observe the compiler's behavior.
package main
import "fmt"
// escapeExample demonstrates an escape
func escapeExample() *int {
x := 10
// x is returned after the function returns, so it escapes to the heap
return &x
}
func main() {
p := escapeExample()
fmt.Println(*p)
}
Expected output:
10
(Note: run go run -gcflags=-m example2.go to see the escape information. The actual output contains ./example2.go:9:2: moved to heap: x, showing that x escapes to the heap. You will also see a escapes to heap / b escapes to heap; those come from boxing of the ...any arguments to fmt.Printf, which is a different matter from the return-value escape. Line numbers change with the file contents, and inlining-related hints may also appear on each compilation.)
Common Mistakes & Pitfalls
-
Misusing new to create a slice/map and then operating on it directly
- Wrong code:
s := new([]int); s = append(s, 1) - Why:
new([]int)returns*[]int, pointing at a nil slice, which is legal in itself; but the first argument toappendmust be a slice, so writingappend(s, 1)fails to compile (first argument to append must be a slice). To use it you must explicitly dereference:s := new([]int); *s = append(*s, 1). In practice it is still better to usemake([]int, 0)orvar s []int. - Correct approach: use
maketo create slices or maps.
- Wrong code:
-
Believing that passing a slice passes a copy
- Wrong assumption: after calling
func modifySlice(s []int) { s[0] = 99 }, the original slice is unchanged. - Why:
s[0] = 99modifies the underlying array, so the original slice sees it (the actual output is[99 2 3]). Passing a*[]intpointer is only necessary when you want to change the slice header itself (length/capacity/reassignment). A slice is "descriptor passed by value + underlying array shared". - Correct approach: understand the value-passing nature of the slice header and the sharing of the underlying array.
- Wrong assumption: after calling
-
Indexing a pointer-typed map
- Wrong code:
m := new(map[string]int); m["key"] = 1 - Why:
mis of type*map[string]intand cannot be indexed directly. The compiler reports:invalid operation: cannot index m (variable of type *map[string]int). - Correct approach:
(*m)["key"] = 1, or more simply, just usemake.
- Wrong code:
Best Practices
- Prefer value passing: for small structs or primitive types, value passing is more intuitive and avoids nil-pointer risk.
- Use pointers for large structs or when you need to modify the original: this avoids copy overhead, or when you explicitly need to modify the caller's data.
- Watch out for unnecessary small-object escapes on hot paths: check with
go build -gcflags=-m; but constructing and returning an object pointer (such asfunc NewX() *X) is an idiomatic Go pattern, in which case heap allocation is by design and not something to avoid. - Initialize slices with make: unless you specifically need a nil slice, use
maketo ensure the slice is usable. - Do not rely on pointer address stability: addresses and relative offsets may differ on every run; do not depend on them.
Exercises
- Concept question: explain the difference in memory allocation between
new([]int)andmake([]int, 0), and the difference in their return types. - Coding exercise: write a function
swap(a, b *int)that swaps the values of two integers. Test it inmain. - Advanced/debugging exercise: can the code below correctly modify a slice element? Explain the memory semantics of passing a slice as an argument, and judge whether using
*[]inthere is necessary.go func modify(s *[]int) { (*s)[0] = 99 } func main() { s := []int{1, 2, 3} modify(&s) fmt.Println(s) }
Reference Answers
Reference Answers (click to expand / collapse)
Question 1
new([]int) returns *[]int: it allocates a block of memory on the heap or stack to hold the zero value of []int (i.e. a nil slice header), and returns a pointer to that memory. It does not allocate an underlying array.
make([]int, 0) returns []int: it allocates and initializes the slice header (pointer, length, capacity); for a zero-length slice it usually does not allocate an underlying array (sharing zerobase).
The difference is: new returns a pointer that you must dereference; make returns the value itself, ready to use.
Question 2
package main
import "fmt"
func swap(a, b *int) {
*a, *b = *b, *a
}
func main() {
x, y := 10, 20
swap(&x, &y)
fmt.Printf("x: %d, y: %d\n", x, y)
}
Explanation: by passing pointers, the function dereferences and swaps the values internally, thereby modifying the original variables.
Question 3
This code is perfectly legal in current Go (*[]int is an ordinary pointer type).
Analysis:
- A slice is a value type, but its underlying array is shared.
- modify takes a *[]int and accesses and modifies the underlying array element through (*s)[0].
- Is *[]int necessary? If you only modify elements, passing []int suffices (because the underlying array is shared). But if you need to modify the slice header itself (such as *s = append(*s, ...) to change length/capacity), you must pass a pointer.
- This exercise only modifies elements, so passing []int is more idiomatic, but passing *[]int is also entirely correct and legal.
Lesson Summary
- Pointers take addresses with
&and dereference with*; their zero value isnil. newallocates a zero value and returns a pointer;makeinitializes reference types and returns the instance.- A slice is a value type, but its underlying array is shared, so modifying elements affects the original slice.
- Escape analysis decides whether a variable lives on the stack or the heap;
-gcflags=-mis an important debugging tool.
Next lesson preview: struct definitions and field access — the foundation of Go's object-oriented style.