Day 8 — Pointers & escape analysis
Day 8 — Pointers & escape analysis
Stage I · ~3h (theory-heavy)
Goal: Use pointers deliberately—not fearfully—and read escape analysis (-gcflags=-m) well enough to explain stack vs heap placement of your Day 7-style types.
Go is garbage collected. You do not free. Escape analysis decides whether a variable can live on the stack or must be heap-allocated. Your job is clear ownership and correct mutability; the compiler handles most placement.
Why this day exists
Pointers show up in three conversations that people conflate:
- Correctness: mutation, optional values, shared identity
- Performance: avoid giant copies; sometimes increase heap pressure
- APIs: method sets, interfaces, JSON
null
Without a model, learners either spray * everywhere or panic at the first *T. Day 8 separates necessity from superstition, then shows the compiler’s notes.
Theory 1 — Addresses and dereferences
x := 10
p := &x // p has type *int; points to x
fmt.Println(*p) // 10
*p = 20
fmt.Println(x) // 20| Expression | Meaning |
|---|---|
&x |
Address of x (type *T if x is T) |
*p |
Dereference pointer p |
*T |
Type: pointer to T |
Zero value is nil
var p *int
fmt.Println(p == nil) // true
// *p // panic: nil pointer dereferenceAlways ensure non-nil before dereference, or document that a method accepts nil receivers.
new and composite literals
p := new(int) // *int, points to zero int
*p = 3
q := &Item{SKU: "A1"} // same idea for structs — more idiomaticPrefer &T{...} for structs; new(T) is rare but valid.
Theory 2 — Why take a pointer?
1. Mutate the caller’s value
func inc(n *int) {
*n++
}2. Avoid copying large structs
func (c *HugeConfig) Validate() error { /* ... */ }A multi-kilobyte config should not be copied on every method call.
4. Optional values
func maxAge(a *int) string {
if a == nil {
return "unspecified"
}
return fmt.Sprintf("%d", *a)
}Alternative later: option types / sql.NullInt64 patterns—but *T is common in APIs.
When values are better
- Small structs (
time.Timeis a value type by design)
- Immutable snapshots
- Map keys (pointers as keys compare by address, which is usually wrong for “same content”)
- Reducing GC pressure when escape would force heap allocation of many tiny objects
Theory 3 — Pointers to pointers (rare)
func setIfNil(pp **int, v int) {
if *pp == nil {
x := v
*pp = &x
}
}Almost never needed in application code. If you want a function to replace a slice header or map variable for the caller, prefer returning the new value:
func add(s []int, v int) []int { return append(s, v) }Theory 4 — Escape analysis (the mental model)
Stack allocation
- Cheap
- Lifetime tied to function activation
- No GC work for that object after return
Heap allocation
- Required when the compiler cannot prove the object dies before the function returns
- GC must track it
- Still automatic—no manual free
What forces escape (typical)
| Pattern | Why it escapes |
|---|---|
Returning &local |
Caller holds reference past return |
| Assigning to interface that outlives | Interface may store pointer |
| Sending pointer on channel | Concurrent lifetime unknown |
| Storing in slice/map that escapes | Shared structure outlives frame |
Calling unknown function with *T |
Compiler conservatively assumes retention |
Exact rules evolve with the compiler; read the output, do not memorize a myth list as law.
How to read -gcflags=-m
go build -gcflags='-m' .
# more detail:
go build -gcflags='-m -m' .Sample messages:
./main.go:12:6: moved to heap: x
./main.go:20:9: &Item{...} escapes to heap
./main.go:33:18: inlining call to fmt.Println
| Phrase | Rough meaning |
|---|---|
moved to heap |
Variable cannot stay on stack |
escapes to heap |
Expression’s result may be heap-allocated |
does not escape |
Stays stack-friendly (good for hot paths) |
inlining call |
Call folded; often enables better analysis |
Escape analysis messages are hints, not performance guarantees. Measure with benchmarks later (Stage VII).
Theory 5 — Common pointer pitfalls
Copying a mutex / copiable-looking struct
type Safe struct {
mu sync.Mutex
n int
}
func (s Safe) IncWrong() { // value receiver copies mutex!
s.mu.Lock()
s.n++
s.mu.Unlock()
}sync.Mutex must not be copied after first use. Use pointer receivers for types containing mutexes.
Returning pointer to loop variable (historical)
Since Go 1.22, per-iteration variables make this safer; still prefer clear code:
var out []*int
for i := 0; i < 3; i++ {
i := i
out = append(out, &i)
}Slice of pointers vs slice of values
items := []Item{{SKU: "a"}, {SKU: "b"}}
var ptrs []*Item
for i := range items {
ptrs = append(ptrs, &items[i]) // OK: address of slice element
}If you append to items later and reallocate, old pointers may dangle logically (point at old array). Prefer owning []*Item from creation if identity matters.
Theory 6 — Pointers and interfaces (preview bridge)
type Stringer interface {
String() string
}
type P struct{ X int }
func (p *P) String() string { return fmt.Sprint(p.X) }
// var s Stringer = P{} // error: P does not implement Stringer
var s Stringer = &P{X: 1} // OKMethod sets differ for T and *T. Day 11 is the full story; today connect it to receiver choice.
Worked examples bank
Example A — Mutation clarity
package main
import "fmt"
type Point struct{ X, Y int }
func moveValue(p Point, dx, dy int) Point {
p.X += dx
p.Y += dy
return p
}
func movePtr(p *Point, dx, dy int) {
p.X += dx
p.Y += dy
}
func main() {
p := Point{1, 2}
p = moveValue(p, 1, 1)
movePtr(&p, 1, 1)
fmt.Println(p)
}Example B — Optional config field
type Options struct {
TimeoutMS *int
Verbose bool
}
func effectiveTimeout(o Options, def int) int {
if o.TimeoutMS == nil {
return def
}
return *o.TimeoutMS
}Example C — Escape demo program
package main
type Node struct {
Value int
Next *Node
}
func stackCandidate() int {
x := 10
return x // x need not escape
}
func heapForced() *int {
x := 10
return &x // x escapes
}
func linked() *Node {
n := &Node{Value: 1}
n.Next = &Node{Value: 2}
return n
}Build with -gcflags=-m and annotate each line in a comment with what you saw.
Example D — Large struct: value vs pointer method
type Big struct {
// pretend many fields
Data [1024]byte
N int
}
func (b Big) CountValue() int { return b.N }
func (b *Big) CountPtr() int { return b.N }Compare escape/inlining notes when calling both in a loop (micro). Prefer clarity; do not optimize blindly.
Example E — Constructor returning pointer
func NewNode(v int) *Node {
return &Node{Value: v} // typically heap
}This is normal and good. Escape is not a failure.
Labs
Suggested workspace: ~/lab/90daysofx/go/day08
Lab 1 — Port Day 7 types and measure
mkdir -p ~/lab/90daysofx/go/day08
cd ~/lab/90daysofx/go/day08
go mod init example.com/day08Copy or reimplement a simplified Item / Store from Day 7.
go build -gcflags='-m' . 2> escape.txtIn escape.txt (or a journal), highlight:
- Something that does not escape
- Something that escapes to heap
- One line you could change (value vs pointer) and how the message changes
Lab 2 — Intentional nil safety
Implement a method on *Item that is safe when the receiver is nil. Call it on a nil pointer and on a real pointer. Contrast with a method that panics on nil.
Lab 3 — Optional pointer fields
Add an optional *int discount percent to a purchase calculator. Nil means no discount. Show JSON-less CLI flags parsing "none" vs a number into *int.
Lab 4 — Prove mutex copy danger (optional, careful)
Create a type with sync.Mutex, call a value-receiver lock method from two sequential calls and explain why this is wrong for concurrent use. Do not need multi-goroutine code yet—just document the copy.
Common gotchas
| Gotcha | Fix |
|---|---|
| Nil dereference | Check or guarantee non-nil |
| Pointers for everything “for speed” | Measure; extra heap can be slower |
Ignoring method set (T vs *T) |
Match receivers to interface needs |
Copying structs containing sync types |
Pointers + no copy |
| Fear of heap escape | Escaping is normal for shared objects |
| Using pointer map keys for equal content | Use values or canonical IDs |
| Dangling mental model from C | GC keeps pointed memory alive |
Checkpoint
&/*/*Tvocabulary solid
- Three good reasons to use a pointer
- Read
-gcflags=-moutput and explained two lines
- Nil-safe method demonstrated
- Did not conclude “heap = bad”
- Connected pointer receivers to future interfaces
Commit
git add .
git commit -m "day08: pointers + escape analysis notes"Tomorrow
Day 9 — Packages & layout: multi-package modules, exported names, internal/, and a layout that scales past a single main.go without inventing a framework.