Back to Notes

Go Receivers & Control Flow

Value vs. Pointer Receivers

What: func (t T) M() operates on a copy; func (t *T) M() on the original. Choice affects mutation, copy cost, method sets, and interface satisfaction.

Syntax/Signature: Method set rule: T → value-receiver methods only; *T → both.

type Counter struct{ n int }

func (c *Counter) Inc() { c.n++ }       // mutates
func (c Counter) Value() int { return c.n } // read-only, copy is fine

var c Counter
c.Inc() // sugar: (&c).Inc() — works because c is addressable

How it works under the hood: A value receiver receives a copy in registers/stack; a pointer receiver receives an address, which typically forces the value to escape to the heap if the compiler can't prove it doesn't outlive the frame. The auto-& sugar only applies to addressable operands — map elements, values inside interfaces, and function-return temporaries are not addressable, so pointer-receiver calls on them fail to compile.

Interview angle: "Rules of thumb for choosing?" — (1) needs mutation or identity → pointer; (2) large struct → pointer; (3) contains a sync.Mutex or other no-copy field → pointer, always (see [[Go Sync Primitives]]); (4) consistency: don't mix receivers on one type; (5) map/chan/func/small immutable values → value is fine.

Gotchas / common wrong answers:

  • "Value receivers are always slower for big structs" — often, but escape-analysis heap allocation from pointer receivers can cost more than a copy; measure.
  • Mixing receivers, then being surprised T doesn't satisfy an interface needing the pointer-receiver method (see [[Go Interface Internals]]).
  • Calling a pointer-receiver method in a for range over a slice of values: for _, v := range xs { v.Inc() } mutates the copy v, not the element — use for i := range xs { xs[i].Inc() }.

Trade-offs / when NOT to use: Value receivers enable safe concurrent reads without locks and keep values usable in maps. Pointer receivers are viral: once one method needs *T, make them all *T.

defer / panic / recover

What: defer schedules a call to run when the function returns (LIFO); panic unwinds the stack running defers; recover inside a deferred function stops the unwind and returns the panic value.

Syntax/Signature: defer f(args)args evaluated at defer time; recover() is meaningful only when called directly by a deferred function.

func CopyFile(dst, src string) (err error) {
	r, err := os.Open(src)
	if err != nil {
		return err
	}
	defer r.Close() // read side: error on close rarely matters

	w, err := os.Create(dst)
	if err != nil {
		return err
	}
	defer func() { // write side: Close error is a data-loss signal
		if cerr := w.Close(); cerr != nil && err == nil {
			err = cerr // named return lets defer override
		}
	}()

	_, err = io.Copy(w, r)
	return err
}

How it works under the hood: Since 1.14, most defers are open-coded: the compiler inlines the deferred call at each return site guarded by a bitmask — near-zero overhead (~1ns), no allocation. Defers in loops or with variable counts fall back to heap/stack-allocated _defer records in a linked list. panic walks that list; recover flips a flag so unwinding stops and the function returns normally (which is why named returns are the only way to hand a value back after recovery).

Interview angle: "What does defer fmt.Println(i) print if i changes later?" — the value at defer time. "When is recover useless?" — called outside a deferred func, or in a helper the deferred func calls indirectly — recover works only when directly called by the deferred function. "Panic across goroutines?" — a panic in a goroutine cannot be recovered by its parent; it kills the process. That's why worker pools wrap each task in its own defer/recover.

Gotchas / common wrong answers:

  • defer resp.Body.Close() before checking err != nil from http.Get — on error resp may be nil ⇒ nil-pointer panic inside defer.
  • Defer in a loop: runs at function end, not iteration end — file handles pile up; extract an inner function.
  • Believing recover gives you the stack — pair it with debug.Stack() yourself, and re-panic (panic(r)) if you can't actually handle it.
  • Deferred func() { err = ... } requires a named return; assigning to a local does nothing.

Trade-offs / when NOT to use: Panics are for programmer errors (impossible states), never control flow across API boundaries — libraries recover at the boundary and return errors (encoding/json does exactly this internally).

Interview Drills

  1. A slice of structs is looped with for _, v := range xs { v.Inc() } where Inc is a pointer-receiver method, but the elements never change. Why?v is a fresh copy of each element on every iteration — calling Inc() on it (via the compiler's auto-& sugar, since v itself is addressable) mutates that copy, not the original element in the slice. Fix: index directly, for i := range xs { xs[i].Inc() }, which operates on the real element's address.
  2. Why does defer resp.Body.Close() immediately after resp, err := http.Get(url), before checking err, risk a panic? — On a genuine error, http.Get can return a nil resp — deferring resp.Body.Close() before confirming err == nil schedules a call on a nil pointer's field, which panics when the deferred call actually runs. Always check the error first, defer the close only in the success path.
  3. Why can't a goroutine's panic be recovered by the goroutine that spawned it? — Each goroutine has its own independent stack and panic/recover mechanism — a panic unwinds only its own goroutine's stack; there's no cross-goroutine propagation of panics at all. An unrecovered panic in any goroutine crashes the entire process, which is why worker pools wrap each individual task in its own defer/recover rather than relying on an outer recover to catch failures from spawned goroutines.

Cross-links

[[Go Interface Internals]] · [[Go Sync Primitives]] · [[Go Data Structure Internals]] · [[Go Goroutines & Scheduling]]