09 — Pointers and Methods — Receivers, Value vs Pointer, and GC
Pointers and methods bled into each other until I treated them as one topic — which is what they are. The model that clicked: a pointer is a memory address with no arithmetic, a method is just a function with a receiver argument, and the one decision that runs through both is value-versus-pointer: value receivers copy and cannot mutate, pointer receivers alias and can. [1][2] Go's memory story — automatic garbage collection, escape analysis choosing stack vs heap — sits underneath and explains when pointers cost anything at all. Once the value/pointer receiver choice felt like a single dial, methods stopped being a separate concept from functions.
Pointers — addresses without the footguns
A pointer holds the memory address of another variable. Declare with *Type, take an address with &, dereference with * [1]:
x := 42
p := &x // p is *int, points at x
fmt.Println(*p) // 42 — read through the pointer
*p = 99 // write through the pointer
fmt.Println(x) // 99 — x is changedTwo things make Go pointers safer than C's. There is no pointer arithmetic — you cannot do p++ to walk to the next memory cell, which removes an entire category of buffer-overrun bugs. And there is no manual free — the garbage collector reclaims memory once nothing references it, so dangling-pointer bugs essentially do not exist [3]. The result is a pointer that gives you the two things you actually need (pass-by-reference semantics and avoiding large copies) without the sharp edges.
Pointers to structs have one ergonomic shortcut: p.Field works directly and means the same as (*p).Field [4]. You almost never write the explicit dereference for struct field access.
Pointers with maps and slices — already references
The subtlety that confused me: slices and maps do not usually _need_ a pointer because they are reference-like types already [5]. Passing a slice to a function copies the slice header, and both headers share the underlying array — so modifying an existing element is visible to the caller without any pointer. The only time you need a *[]T or *map[K]V is when the function must reassign the variable itself (e.g., call append in a way the caller sees, or replace the map). For ordinary read/write of shared contents, the bare slice or map is enough.
Methods — functions with a receiver
A method is a function declared with a receiver argument, which attaches it to a type [6]:
type Rectangle struct{ Width, Height float64 }
// Value receiver — operates on a copy
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
r := Rectangle{Width: 3, Height: 4}
fmt.Println(r.Area()) // 12The (r Rectangle) between func and the method name is the receiver — it plays the role of this or self in other languages, but it is an explicit named parameter. Methods can be defined on any type in the same package, not just structs, which is how you add behavior to type MySlice []int or type Celsius float64.
Pointer receivers vs value receivers
The receiver can be a value or a pointer, and the choice is the most consequential method decision [2][7]:
// Pointer receiver — receives *Rectangle, can mutate the original
func (r *Rectangle) Scale(factor float64) {
r.Width *= factor
r.Height *= factor
}
// Value receiver — receives a copy, cannot mutate the caller's struct
func (r Rectangle) Perimeter() float64 {
return 2 * (r.Width + r.Height)
}The rule I use, drawn from the standard library's own conventions:
- Use a pointer receiver when the method mutates the receiver, when the struct is large (copying is wasteful), or for consistency once any method on the type has a pointer receiver.
- Use a value receiver when the method only reads, the type is small, and you want the method callable on both values and pointers.
The consistency point matters: a type should generally pick one receiver kind and stick with it, because value and pointer receivers satisfy interfaces differently (a value can only satisfy an interface with value methods; a pointer can satisfy both). Mixing them across a type creates confusion about which set of methods exists on T versus *T.
Go smooths the call site: r.Scale(2) works whether r is a value (Go auto-takes the address) or a pointer (Go auto-dereferences), so the caller does not care. The decision lives in the method declaration, not the call.
Memory management and garbage collection
Underneath all of this, Go manages memory automatically. The runtime decides, via escape analysis, whether each variable is allocated on the stack (fast, freed automatically when the function returns) or the heap (slower, reclaimed by the garbage collector) [8]. The rule of thumb: if a variable's address escapes the function — returned as a pointer, stored in a global, captured by a goroutine — it goes on the heap; otherwise it stays on the stack. You can ask the compiler to report its decisions with go build -gcflags="-m".
The garbage collector is a concurrent, tri-color mark-and-sweep collector tuned for low pause times [9]. It runs alongside your program, not in a stop-the-world burst, which is why Go services can hit sub-millisecond GC pauses in practice. The practical implication: you rarely think about deallocation, but you _do_ think about allocation rate, because every heap allocation is eventual GC work. Reducing allocations (pre-sized slices, object pooling with sync.Pool, value receivers for small structs) directly reduces GC pressure. This is the one place where "Go has a GC" stops being a free lunch and starts being a performance consideration.
How I use this
The value-vs-pointer decision is the lever I pull most. For any method that mutates its receiver, I reach for a pointer receiver without thinking — there is no debate. For read-only methods on small structs, a value receiver keeps things simple and lets the type satisfy interfaces from both values and pointers. The consistency rule then propagates: if one method needs a pointer, the rest get pointers too, so the set of methods is uniform. On the memory side, I run go build -gcflags="-m" when a hot path allocates more than I expect, and the escape-analysis output tells me exactly which local variables are fleeing to the heap. The picture — pointer-as-safe-address, method-as-function-with-receiver, GC-as-concurrent-sweeper — is what makes the tradeoffs visible.
References
[1] The Go Authors, "Go Tour: Pointers," go.dev, 2024. [Online]. Available: https://go.dev/tour/moretypes/1
[2] The Bug Shots, "Understanding Value and Pointer Receivers in Golang," Medium, 2024. [Online]. Available: https://medium.com/the-bug-shots/understanding-value-and-pointer-receivers-in-golang-82dd73a3eef9
[3] SafetyCulture Engineering, "An overview of memory management in Go," Medium, 2024. [Online]. Available: https://medium.com/safetycultureengineering/an-overview-of-memory-management-in-go-9a72ec7c76a8
[4] The Go Authors, "Go Tour: Pointers to structs," go.dev, 2024. [Online]. Available: https://go.dev/tour/moretypes/4
[5] The Go Authors, "Go Maps in Action," The Go Blog, 2013. [Online]. Available: https://go.dev/blog/maps
[6] The Go Authors, "Go Tour: Methods," go.dev, 2024. [Online]. Available: https://go.dev/tour/methods/1
[7] Stackademic, "Go Method Receivers: Understanding Value vs. Pointer and When to Use Each," Medium, 2024. [Online]. Available: https://blog.stackademic.com/go-method-receivers-understanding-value-vs-pointer-and-when-to-use-each-74ef82d66a5c
[8] S. Narayan, "How Go Manages Memory and Why It's So Efficient," Medium, 2024. [Online]. Available: https://medium.com/@siddharthnarayan/how-go-manages-memory-and-why-its-so-efficient-68c13133ba1c
[9] The Go Authors, "A Guide to the Go Garbage Collector," go.dev, 2024. [Online]. Available: https://tip.golang.org/doc/gc-guide
Knowledge check · Question 1 of 5
Why does Go forbid pointer arithmetic?
Comments
Leave a Comment
You must be signed in to comment
0 Comments
No comments yet. Be the first to comment!