Go for Reviewers · Lesson 04

Pointers vs Values

In L3 you learned to read *. Now the why: when Go copies, and why it decides the receiver type.

Why this, now: "Pointer vs value receiver" is the #2 thing Go reviewers flag. Choosing wrong causes a whole class of bug that has no equivalent in Python: a method that "works" but silently mutates a copy, so the change vanishes. You need to catch that on sight.

1 · The fact Python never made you think about: Go copies

In Python, a variable is always a reference to an object. Passing it to a function, assigning it, sticking it in a list — all share the same object. There is no "copy unless you ask."

Go is the opposite by default. Assignment and function calls copy the value. Pass a struct to a function and the function gets its own copy; mutating it does nothing to the caller's. A pointer (*T) is how you opt out of copying — you hand over the address so the callee touches the original.

Python · always shared
def bump(a):
    a.count += 1     # mutates caller's obj

acct = Account()
bump(acct)
# acct.count is now 1
Go · copied unless pointer
func bump(a Account) {   // gets a COPY
    a.count++          // mutates the copy
}
acct := Account{}
bump(acct)
// acct.count is STILL 0 — bug!

func bump(a *Account) {  // gets the ADDRESS
    a.count++          // mutates the original
}
bump(&acct)            // acct.count is now 1 ✓
Where the Python analogy breaks — hard: the first bump(a Account) compiles and runs. No error. It just silently does nothing to the caller. Your Python brain reads it as "mutates the account" — Go reads it as "mutates a throwaway copy". This is the bug to train your eye for.

2 · Receivers are the same story

A method's receiver (the (s *Service) part) follows the exact same rule. A value receiver gets a copy of the type; a pointer receiver gets the address.

Value receiver · can't mutate
func (a Account) Deposit(n int) {
    a.balance += n   // changes the COPY
}
// acct.Deposit(100) → balance unchanged
Pointer receiver · mutates
func (a *Account) Deposit(n int) {
    a.balance += n   // changes the ORIGINAL
}
// acct.Deposit(100) → balance += 100 ✓
The rule of thumb (from the Go team): if the method needs to mutate the receiver, or the struct is large, or it holds a sync.Mutex / other no-copy field — use a pointer receiver. Use a value receiver only for small, immutable, basic-like types. When in doubt, use a pointer. [Code Review Comments]

3 · Both patterns, in your repo

The codebase follows the rule precisely — 72 *Service pointer receivers, value receivers only on tiny types.

VALUE receiver · types/address.go
type Address string   // tiny, immutable

func (a Address) String() string {
    return string(a)
}
func (a Address) Network() string {
    // reads a, never mutates
    ...
}
POINTER receiver · services/*/service.go
type Service struct {  // big, has caches
    config *Config
    client  Client
    cache  *dAppsCache
}
func (s *Service) GetCachedByID(...) {
    // shares one Service; no copying
}

Why these choices: Address is a one-word string — copying is free and it's never mutated, so a value receiver is clean (and means you can call .String() on a plain Address without taking its address). Service holds caches and config pointers — you want every method working on the same instance, and copying it per call would be wasteful and wrong. [Effective Go]

Consistency rule: don't mix receiver kinds on one type. If any method needs a pointer receiver, all of that type's methods should use pointer receivers — otherwise the method set gets confusing and interface satisfaction breaks (next section). [source]

4 · The subtle one: method sets & interfaces

This is where pointers-vs-values collides with Lesson 2. The rule:

If methods are on…then these satisfy an interface
value receiver (a T)both T and *T
pointer receiver (a *T)only *T — a plain T does not satisfy it

So if dapp_storage.Client's methods are on *Client (they are), then *Client satisfies the dapp.Client interface but a bare Client value does not. This is why you almost always see &Client{...} / NewClient() returning *Client passed into constructors — the pointer is what carries the full method set.

The error you'll see (and should recognize): cannot use Client{} (value of type Client) as dapp.Client value: Client does not implement dapp.Client (method ListDApps has pointer receiver) — that compile error is this rule firing. When a teammate's PR hits it, you now know the fix: pass &Client{}, not Client{}.

5 · The reviewer's decision card

Which receiver should this method have?

POINTER *T
Method mutates the receiver · struct is large · contains a Mutex/WaitGroup/other no-copy field · type already has any pointer-receiver method (consistency) · default when unsure.
VALUE T
Small, immutable, basic-like type (a named string/int, a tiny struct of value fields) · the method only reads · you want the method callable on non-addressable values (map elements, literals).
Reviewer's eye — what to flag:

6 · Drill — spot the copy bug

Instant feedback. Reps, not grades.

Q1 Does the change stick?
func (a Account) Withdraw(n int) { a.balance -= n }
// caller:
acct.Withdraw(50)
After this, what is acct.balance (started at 100)?
Q2 Why Address uses a value receiver
In types/address.go, func (a Address) String() string uses a value receiver. Best justification?
Q3 Interface satisfaction
*Client has the method ListDApps (pointer receiver). A PR does var c dapp.Client = Client{}. What happens?
Q4 Mutex trap
A struct has a sync.Mutex field. A teammate writes its methods with value receivers. Comment?
I'm your teacher — ask me anything.
Want to know why slices and maps seem to "mutate through a copy" even without a pointer (they're reference-backed — a real gotcha worth its own note)? Or how Go auto-takes-the-address when you call a pointer method on an addressable value (acct.Deposit() works without &)? Ask — both are common review confusions.

Terms introduced

Added to your GLOSSARY.md: