all_lessons/Go/04 · Structs & receiverslesson 5 / 18

Structs, methods & receivers: behavior attached to data

A struct is a value with a known layout (Lesson 02); a method is a function with one privileged argument. The whole lesson turns on a single decision Go forces you to make in the open — does this method take its receiver by value or by pointer? In most languages that question is hidden; in Go it is in the signature, and the right answer falls straight out of "who owns this data, and how does it move?" Get it right and you have data with behavior, no copy surprises, and no class hierarchy. Get it wrong and you write to a copy that vanishes — the most common beginner bug in the language.

The thesis, here
This is the cleanest example in the series of Go refusing to hide a cost. Other languages give you a single notion of "method" and quietly decide for you whether this is shared or copied. Go makes you write the choice — (c Counter) or (c *Counter) — so the reader can see, at the method's first line, whether calling it can mutate your value and what the call copies. Complexity must be earned: rather than a feature that papers over the value/pointer distinction, Go keeps the distinction visible and asks one small question of you. The cost is a decision per method; the payoff is that the next engineer never has to guess.
Linear position
Prerequisite: Lesson 02 (value semantics, the zero value, struct layout) and Lesson 03 (pointers, the stack/heap split, escape analysis). You need to know that assigning or passing a value copies it, and that a pointer is an address the compiler may keep on the stack or move to the heap.
New capability: define methods on your own types, choose value vs pointer receivers from first principles, predict exactly what a call copies and whether it can mutate, and explain method sets — which sets up interface satisfaction in Lesson 05.
The plan
Four moves. (1) Why "behavior attached to data" needs no classes — a method is just a function with a named receiver. (2) The one real decision: value vs pointer receiver, derived from mutation, copy cost, and the zero value. (3) The consistency rule and the method-set rule, and the pointer-receiver bug everyone hits once. (4) What it all costs, and why method sets are the hinge into interfaces.

1 · A method is a function with one privileged argument

Start from the engineering problem. You have data — a point, a counter, a buffer — and operations that belong with it. In a class-based language you'd reach for a class: bundle the fields and the methods, then (almost inevitably) a base class, an override, a hierarchy. Lesson 00 named the cost of that hierarchy: coupling that turns "change here" into "break there" three levels away. Go's bet is that you can attach behavior to data without the class machinery, because the only thing you actually needed was a function that knows which value it operates on.

So a Go method is exactly that: an ordinary function with one extra parameter, the receiver, written before the name. There is no class, no this keyword you didn't declare — you name the receiver yourself, like any parameter.

type Point struct {
    X, Y float64
}

// Method: the receiver (p Point) is the privileged first argument.
func (p Point) DistanceTo(q Point) float64 {
    dx, dy := p.X - q.X, p.Y - q.Y
    return math.Sqrt(dx*dx + dy*dy)
}

a := Point{0, 0}
b := Point{3, 4}
d := a.DistanceTo(b)   // d == 5; identical to DistanceTo(a, b) conceptually

Two facts make this more general than it looks. First, you can define a method on any named type you declare, not just structs — a named slice, a named integer, a function type:

type Celsius float64
type Fahrenheit float64
func (c Celsius) ToFahrenheit() Fahrenheit { return Fahrenheit(c*9/5 + 32) }

type IntSet []int
func (s IntSet) Contains(n int) bool { /* ... */ return false }

The one rule: the type must be defined in your package. You cannot bolt a method onto int or onto a type from another package — that would let any package secretly change a type's behavior, exactly the action-at-a-distance Go is built to prevent. Second, because a method is just a function, calling a.DistanceTo(b) is the same machine work as a plain function call: the receiver is passed like any argument. There is no vtable lookup, no dynamic dispatch — the compiler knows the concrete type at compile time and emits a direct call. (Dynamic dispatch enters in Lesson 05, and only through interfaces.)

2 · The one real decision: value receiver or pointer receiver

Here is the choice Go puts in your hands and most languages hide. Recall Lesson 02's iron rule: passing a value copies it. A value receiver is no exception — (p Point) means the method gets a fresh copy of the Point, and any change it makes dies when the method returns. A pointer receiver — (p *Point) — means the method gets the address of your Point, so it can change the original.

type Counter struct{ n int }

func (c Counter) IncValue()  { c.n++ }   // mutates a COPY — caller sees nothing
func (c *Counter) IncPtr()   { c.n++ }   // mutates the ORIGINAL through its address

var c Counter
c.IncValue(); c.IncValue()  // c.n is still 0  — the classic beginner bug
c.IncPtr();   c.IncPtr()    // c.n is now 2

(Go lets you write c.IncPtr() on an addressable value c — it silently takes (&c) for you. The reverse also works: a pointer can call a value-receiver method. The sugar is convenient but the semantics are set by the receiver type, not the call site, so read the receiver to know what happens.)

Three forces decide the answer, in priority order:

1. Must it mutate the receiver?
If the method needs to change the value's state and have the caller see it, you need a pointer receiver — a value receiver can only ever edit a copy. This is question #2 of the five: is the data being mutated in place (shared) or transformed into a new value (passed)?
2. Is the value large?
A value receiver copies the whole struct on every call. A Point is 16 bytes — copying is free. A struct with a dozen fields, or one embedding a 1 KB array, copies all of it every call; a pointer receiver copies one 8-byte word instead. Past a few words, the pointer is cheaper.
3. Does it hold a lock or must not be copied?
If the struct contains a sync.Mutex, a sync.WaitGroup, or anything that breaks when duplicated, copying it is a bug. go vet flags it. Such types take pointer receivers by rule — copying the lock would protect nothing.

Notice what is not on this list: "small and read-only." For a small value the method only reads, a value receiver is the honest default — it advertises in the signature "calling this cannot change your value," which is real information for the reader, and the copy is a few bytes the compiler often keeps in registers and never spills to memory. The decision tree:

SituationReceiverWhy — what the machine/reader gets
Method modifies the receiver's fields*TOnly a pointer reaches the original; a value receiver edits a discarded copy.
Struct is large (many fields / big array)*TCopy cost is one 8-byte word, not the whole struct, per call.
Contains a Mutex / WaitGroup / must-not-copy*TCopying the lock duplicates and de-syncs it — go vet errors.
Small, immutable-feeling value (Point, time.Time)TSignature promises "no mutation"; copy is a few bytes, often register-only.
Type is a basic kind (Celsius, a small enum)TCheap to copy and naturally treated as a value, like an int.

This is the same trade-off literacy the C++ track builds from the machine up — "what does this copy cost?" — but Go forces the answer into the one place the reader will look: the receiver. The cost claim is concrete: a value receiver copies sizeof(T) bytes on entry; a pointer receiver copies 8 bytes (a 64-bit address) and adds one indirection per field access. Below roughly 3–4 machine words the copy usually wins or ties; above it the pointer wins. When in doubt and the type is mutated or might be, default to pointer — but make it a decision, not a reflex.

3 · Be consistent, and meet the pointer-receiver method-set rule

Two rules turn the per-method choice into a per-type discipline.

Consistency. If any method on a type needs a pointer receiver, give all its methods pointer receivers — even the read-only ones. Mixing them is legal but it muddies the type: the reader can no longer tell at a glance "this type is used by value" or "by pointer," and the method set (below) splits in a way that surprises people. The standard library follows this: bytes.Buffer, sync.Mutex, strings.Builder are pointer-receiver types through and through; time.Time is a value-receiver type through and through.

The method-set rule — the piece that everything in Lesson 05 hangs on. A type's method set is the set of methods you can call through a value of that type. The rule:

value of type T → method set = { methods with (T) receiver } pointer of *T → method set = { methods with (T) receiver } ∪ { methods with (*T) receiver }

Read it carefully: the method set of *T includes the value-receiver methods, but the method set of T does not include the pointer-receiver methods. The reason is addressability. To call a pointer-receiver method, Go needs the value's address. A *T already is an address. A plain T value sometimes isn't addressable — a map element, a value returned from a function, a value in an interface — and you cannot take the address of something that has no stable home. So Go cannot guarantee a (*T) method is callable on every T, and therefore excludes it from T's method set entirely.

For direct calls on an addressable variable this is invisible — Go auto-takes the address (§2). It bites the moment an interface is involved:

type Stringer interface{ String() string }

type Big struct{ /* ... */ }
func (b *Big) String() string { return "big" }   // POINTER receiver

var b Big
var s Stringer = b    // COMPILE ERROR: Big does not implement Stringer
                      //   (String has pointer receiver)
var s Stringer = &b   // OK: *Big's method set includes String

The fix is almost always "use a pointer," and the error message tells you so. But you have to know the method-set rule to read the message — which is exactly why this lesson lands before interfaces. The mechanism, not a vibe: the value b stored in an interface has no addressable home, so the pointer method literally cannot be invoked, so it isn't in Big's method set.

The bug you will hit once
A value-receiver method that "updates" a field and then your change disappears (§2's IncValue), or a pointer-receiver type that "doesn't implement" an interface when you assign the value instead of &value (above). Both are the same root cause surfacing in two places: a value receiver edits a copy, and a value's method set excludes pointer methods. Internalize "pointer receiver ⇒ use a pointer, including when satisfying an interface" and both vanish.

4 · What it costs, and why this is the hinge into interfaces

Tally the bill in the series' terms. Go bought you "data with behavior" for almost nothing at runtime: a method call is a direct, statically-dispatched function call — no vtable, no boxing, identical cost to calling a free function with one more argument. There is no per-object overhead either; a struct with methods has the same layout and size as the same struct without them, because the methods aren't stored in the value (Lesson 02's layout still holds exactly). What you pay is one design decision per method — value or pointer — and the cognitive load of the method-set rule. Go judged that price worth it: the alternative (a hidden, uniform "everything is a reference" or "everything is a copy") removes the decision but also removes the reader's ability to see, from the signature, whether a call can mutate their value or how much it copies. Half (1) of the bargain in miniature — keep the distinction visible because the reader pays more for hiding it than for seeing it.

And note the escape-analysis tie to Lesson 03: a pointer receiver does not force a heap allocation. If the compiler can prove the receiver's lifetime stays within the call (it doesn't escape), the value lives on the stack and the *T is just a stack address — zero GC cost. Choosing a pointer receiver is a semantic choice about mutation and copy cost, not an allocation choice; the compiler still decides where the data lives.

The method set is the load-bearing concept here, because Lesson 05 is built directly on top of it. An interface is "a set of method signatures"; a type satisfies an interface when its method set includes all of them — implicitly, with no implements keyword. Everything you just learned about which methods are in T's set versus *T's set is precisely what decides whether T or only *T satisfies a given interface. You cannot reason about interface satisfaction without the method-set rule, which is why we earned it first.

Checkpoint exercise

Try it
Define type Account struct{ balance int } with two methods: Balance() int (returns the balance) and Deposit(amount int) (adds to it). (1) Write Deposit first with a value receiver, call it twice with 100, then print Balance() — watch it stay 0, and explain why using §2. (2) Fix it with a pointer receiver and confirm 200. (3) Now apply the consistency rule from §3: should Balance() be a value or pointer receiver, and why? (4) Declare type Stringer interface{ String() string }, add a String() method to Account with a pointer receiver, then try var s Stringer = someAccount and var s Stringer = &someAccount — predict which compiles before you run it, and name the method-set rule (§3) that explains the result. You have now reproduced both halves of the one bug everyone hits.

Where this points next

You can now attach behavior to data and you know precisely what lives in a type's method set and why. Lesson 05 spends that knowledge: an interface names a behavior — "anything with a Read([]byte) (int, error) method" — and a type satisfies it implicitly, simply by having those methods in its method set. The value/pointer-receiver choice you just learned is exactly what decides whether T or only *T qualifies, and the pointer-receiver method-set rule from §3 is the mechanism behind the most common interface compile error. Interfaces are how Go answers question #4 — "depend on a behavior, not a concretion" — and they are impossible to reason about without the method set you just built.

Takeaway
A method is just a function with a named receiver, so Go attaches behavior to data with no classes, no this, and no hierarchy — and a method call is a plain, statically-dispatched call with zero per-object overhead. The lesson's whole weight is on one decision Go refuses to hide: value receiver or pointer receiver. Choose a pointer when the method must mutate the receiver, when the struct is larger than a few machine words (copying one 8-byte address beats copying the whole value), or when it holds a lock that must not be copied; choose a value for small, read-only types where the signature's promise "this cannot change your value" is worth more than the few bytes copied. Be consistent across a type, and remember the method-set rule — *T can call both value and pointer methods, but T can call only value methods, because a plain value isn't always addressable. That single rule is the source of both the "my change disappeared" bug and the "doesn't implement the interface" bug, and it is the exact hinge into Lesson 05. The transferable habit: make the cost of a copy and the possibility of mutation visible in the signature, so the next reader never has to guess.

Interview prompts