Saltar a contenido

08 - Pointers and Memory

What this session is

About an hour. Pointers are the topic that scares beginners the most and turns out to be small once you actually meet them. By the end of this page you'll understand what a pointer is, when to use one, and why methods sometimes have *Type in their receiver.

We will go slowly. Pointers feel weird the first three times.

The problem

When you pass a value to a function, Go gives the function its own copy. Changes the function makes don't affect the caller.

package main

import "fmt"

type Counter struct {
    Value int
}

func increment(c Counter) {
    c.Value = c.Value + 1
}

func main() {
    counter := Counter{Value: 0}
    increment(counter)
    fmt.Println(counter.Value)   // 0 - not 1!
}

Type and run. Surprise: the counter is still 0. Why?

increment received its own copy of the counter. It bumped that copy. The original is untouched.

For numbers this is fine - most functions don't want to mutate their inputs. But sometimes you do. You want increment(counter) to actually increment the counter. That's what pointers are for.

What a pointer is

A pointer is a value that stores the address of another value. Instead of being "the counter," a pointer is "where the counter lives."

Two operators:

  • &x - read as "the address of x." Gives you a pointer to x.
  • *p - read as "the value pointed to by p." Goes from pointer to actual value. Called dereferencing.
package main

import "fmt"

func main() {
    x := 42
    p := &x                   // p is a pointer to x
    fmt.Println(x)            // 42
    fmt.Println(p)            // something like 0xc00001a098 - an address
    fmt.Println(*p)           // 42 - what p points to
    *p = 100                  // write through the pointer
    fmt.Println(x)            // 100 - x changed!
}

Type and run carefully. Notice the two important moves:

  • p := &x made p point at x. Now p knows where x lives.
  • *p = 100 followed the pointer back to x and wrote 100 into it. x is now 100.

The type of p is *int. Read it as "pointer to int." You'll see this notation in function signatures: func foo(n *int) means "foo takes a pointer to int."

Fixing the counter

package main

import "fmt"

type Counter struct {
    Value int
}

func increment(c *Counter) {
    c.Value = c.Value + 1
}

func main() {
    counter := Counter{Value: 0}
    increment(&counter)
    fmt.Println(counter.Value)   // 1!
}

Two changes:

  • func increment(c *Counter) - the parameter is now a pointer to a Counter, not a Counter.
  • increment(&counter) - we pass the address of counter, not a copy.

Inside increment, c.Value = ... writes through the pointer to the original. The change sticks.

Notice: even though c is a pointer, we wrote c.Value, not (*c).Value. Go quietly does the dereferencing for field access. This is purely a convenience. Both forms work; the short one is what everyone writes.

Pointer receivers on methods (revisited from page 05)

Now the * on a method receiver makes sense:

func (c *Counter) Increment() {
    c.Value = c.Value + 1
}

This says: "the receiver is a pointer to a Counter, not a copy of one." When you call counter.Increment(), Go quietly passes the address of counter, the method mutates through it, the change sticks.

Compare to a value receiver:

func (c Counter) Increment() {
    c.Value = c.Value + 1   // mutates a copy; pointless
}

This compiles, runs, and does nothing visible. A classic beginner bug.

The rule of thumb (stronger version of what page 05 said):

  • If the method modifies the receiver's fields → use *Type.
  • If the receiver is a big struct (lots of fields) → use *Type even if you don't modify it. Saves the copy.
  • If the receiver is small and immutable → either works; pick one and be consistent across all methods on the type.

When in doubt, use *Type. Real Go code uses pointer receivers ~80% of the time.

nil pointers

A pointer can be nil - pointing at nothing. Dereferencing a nil pointer is a panic (crash).

var p *int            // p is nil
fmt.Println(p == nil) // true
fmt.Println(*p)       // PANIC: nil pointer dereference

You'll see this crash often when starting out. The fix is almost always "make sure the pointer is set to something before you use it." Either initialize it (p := &x) or check before dereferencing:

if p != nil {
    fmt.Println(*p)
}

When to use pointers

The honest answer:

  1. A method needs to mutate its receiver. Use a pointer receiver.
  2. You're passing a large struct around and don't want to copy it every time. Use a pointer.
  3. You explicitly want a "nullable" value - a thing that might or might not be set. Use a pointer; check nil.
  4. You're sharing state between functions, and they all need to see updates. Use a pointer.

Otherwise: pass values around. Copying is cheap for small things. Go's compiler is good at avoiding actual copies when it safely can.

What about memory? Stack vs heap?

You may have heard programmers talk about "stack" and "heap." Quick story:

  • The stack is fast memory used for function calls - every call grows it; every return shrinks it. Limited size.
  • The heap is general-purpose memory that outlives any single function call. Garbage-collected (Go cleans up things you stop using).

Go decides for you. The compiler does escape analysis to figure out whether a value can safely live on the stack (faster) or must live on the heap (longer-lived). You almost never have to think about it.

The only thing you need to know now: taking the address of something with & may or may not cause it to live on the heap. Don't worry about it. Write clear code; let the compiler decide.

The full picture lands when you read "Go runtime" content later. For now: trust the language.

Going deeper

You can write production Go without knowing any of this. But a few things will eventually matter for performance, debugging, or just reading senior code with confidence.

Watch the compiler decide: -gcflags="-m"

You can ask the compiler to tell you what escaped to the heap and why:

$ go build -gcflags="-m" main.go
./main.go:5:6: can inline counter
./main.go:11:8: &n escapes to heap: flow: ~r0 = &n
./main.go:11:8: moved to heap: n

"Moved to heap" means: this variable, which looks local, actually lives on the heap because something escaped. Most of the time that's fine. In hot paths it's worth reducing.

The two common reasons something escapes: 1. You return a pointer to it. The caller might keep that pointer past your function's lifetime, so the value can't die when your function does. 2. You store it in something whose lifetime the compiler can't prove (e.g. an interface{} value passed to fmt.Println - the compiler often can't tell what that function will do with it).

You won't tune this for normal code. You will eventually use it to chase a benchmark.

Struct field order matters (a little)

Go aligns struct fields on natural boundaries. A bool is 1 byte but the next field might need to start on an 8-byte boundary, leaving 7 bytes of padding:

type Sparse struct {
    A bool    // 1 byte
              // 7 bytes padding
    B int64   // 8 bytes
    C bool    // 1 byte
              // 7 bytes padding
} // 24 bytes total

type Dense struct {
    B int64   // 8 bytes
    A bool    // 1 byte
    C bool    // 1 byte
              // 6 bytes padding at the end
} // 16 bytes total

Dense packs the same fields into 16 bytes by grouping the wide fields first. For one struct, 8 bytes is nothing. For a slice of a million of them, it's 8 MB.

The order isn't a style rule - readability comes first - but if a struct is allocated in huge quantities and you're hunting bytes, group wide fields together.

sync.Pool for hot allocations

Suppose your HTTP handler builds a 4KB buffer for every request. Each allocation pressures the garbage collector. sync.Pool lets you reuse them:

var bufPool = sync.Pool{
    New: func() any { return make([]byte, 0, 4096) },
}

func handle(w http.ResponseWriter, r *http.Request) {
    buf := bufPool.Get().([]byte)[:0]   // grab one, reset length
    defer bufPool.Put(buf)
    // ... use buf ...
}

A pool is a per-goroutine cache. Two surprises: - The pool can drop anything any time (especially during GC). It's a cache, not storage. - Get returns a value that might still have stuff in it from before. Reset before using.

Production servers use this heavily for buffers and request structs. Don't reach for it preemptively - only when allocation profiles show it's worth it.

Stacks grow (Go's secret weapon)

Each goroutine starts with a tiny stack - 8 KB on modern Go. If your function calls go deeper than that, the runtime detects it, allocates a bigger stack, copies the current stack into the new one, fixes up pointers, and continues. You see nothing.

This is why Go can run a million goroutines with reasonable memory: most of them never need more than the initial 8 KB. It's also why deep recursion in Go doesn't blow up like it does in some other languages - until you actually hit the per-goroutine cap (1 GB by default).

You'll never write code for this. But when someone says "goroutines are cheap because of growable stacks," now you know what they mean.

Reading a memory profile

When something feels slow, the next-level move is pprof:

import _ "net/http/pprof"

func main() {
    go http.ListenAndServe("localhost:6060", nil)
    // ... rest of your program ...
}

Now while your program runs:

$ go tool pprof http://localhost:6060/debug/pprof/heap
(pprof) top 10

You'll see which functions allocated the most memory. It's the Go-native answer to "where is my program spending bytes."

You'll learn this in depth later. For now: know it exists, know how to start it, recognize the output when you see it.

Exercise

In a new file account.go:

  1. Define a struct Account with two fields: Owner (string) and Balance (float64).

  2. Add a method Deposit(amount float64) that adds amount to Balance. Choose the right receiver type (value or pointer).

  3. Add a method Withdraw(amount float64) error that:

  4. Subtracts amount from Balance if Balance >= amount.
  5. Returns nil on success.
  6. Returns an error like "insufficient funds: have X, want Y" otherwise.

  7. In main:

    acct := Account{Owner: "Alice", Balance: 100}
    acct.Deposit(50)
    fmt.Println(acct.Balance)            // 150
    if err := acct.Withdraw(200); err != nil {
        fmt.Println("withdraw failed:", err)
    }
    acct.Withdraw(50)
    fmt.Println(acct.Balance)            // 100
    

  8. Make sure both Deposit and Withdraw actually mutate acct. (If they don't, you picked the wrong receiver type.)

What you might wonder

"Why don't I always use pointers, to be safe?" Two reasons. (1) Pointers can be nil, which means an extra failure mode. (2) Pointers point at shared state, which means accidental mutation surprises. Values are simpler when simpler will do.

"Pointers in C scared me. Are Go pointers like that?" A lot less scary. No pointer arithmetic (you can't p + 1 to "move along an array"). No manual free() - the garbage collector handles it. No casting between types. About 80% of what makes C pointers scary doesn't exist in Go.

"Is *Counter a different type from Counter?" Yes, but Go is forgiving. You can call value-receiver methods on a pointer and vice versa most of the time. The type is technically distinct; the syntax is convenient.

"What's a **Counter?" A pointer to a pointer. You'll see it rarely. Real-world reasons: a function that wants to replace what a pointer points to. Don't worry about it now; recognize it when you see it.

Done

You can now: - Explain what a pointer is: a value holding the address of another value. - Use &x to take an address and *p to dereference. - Choose between value and pointer receivers on methods (mutation → pointer). - Recognize and avoid nil pointer crashes. - Stop fearing pointers.

You've now learned every basic mechanic of Go the language. From here on, the pages are about putting these mechanics together: concurrency (using all of them at once across multiple things running in parallel), testing (verifying your code does what you think), packages (organizing it), and eventually reading and contributing to real projects.

Next page: how Go does "do many things at once" - goroutines and channels.

Next: Concurrency 10109-concurrency-101.md

Comments