open atlas
↑ Back to track
Go, zero to senior GO · 01 · 01

Types and structs: value semantics, zero values, and the slice header

Go copies structs by default — a pointer is an explicit opt-in, and zero values like sync.Mutex work uninitialized. Embedding is composition, not inheritance. A slice is a 24-byte header over a shared array: append within cap silently aliases, past cap it reallocates.

GO Middle ◷ 17 min
Level
FoundationsJuniorMiddleSenior
Already know this unit? Take a 1-minute quick check →

A reporting service sorted a month of transactions, took the top three with top := sorted[:3], and then appended a synthetic “TOTAL” row to top before rendering. In production, every export had one corrupted transaction — the fourth row of the full report was silently replaced by the TOTAL line. Nobody could reproduce it locally: the test dataset was small enough that append happened to reallocate. In production the backing array had spare capacity, so append(top, totalRow) wrote straight into sorted[3] — the slice header said len 3, cap 31, and Go did exactly what the header allowed. The bug wasn’t in append; it was in believing that a slice is an array rather than a 24-byte window onto someone else’s memory.

Value semantics: everything copies unless you say otherwise

When you misread Go’s copy semantics, you either mutate the wrong copy in silence or grab a pointer you didn’t mean to share — both bugs can take an hour to find. Go’s default is the opposite of Java’s and Python’s: assignment, function arguments, and range loop variables copy the value. Assign a struct and you get a second, independent struct; mutate the copy and the original never notices. Sharing is an explicit decision spelled *T — a pointer is how you opt in to aliasing, not something the language does behind your back.

type Account struct {
    Owner   string
    Balance int64
}

func drainCopy(a Account) { a.Balance = 0 }   // mutates a copy; caller unaffected
func drain(a *Account)    { a.Balance = 0 }   // mutates the caller's struct

func main() {
    acc := Account{Owner: "ada", Balance: 100}
    drainCopy(acc) // acc.Balance is still 100
    drain(&acc)    // acc.Balance is now 0

    accounts := []Account{{Owner: "a"}, {Owner: "b"}}
    for _, a := range accounts {
        a.Balance = 50 // writes to the loop COPY — accounts is unchanged
    }
    for i := range accounts {
        accounts[i].Balance = 50 // index into the slice — this one sticks
    }
}

The same rule decides method receivers. A value receiver func (a Account) X() operates on a copy — fine for read-only methods on small structs. A pointer receiver func (a *Account) X() operates on the original — required for mutation, and conventional once a struct is big (copying a 10-field struct on every call costs more than one pointer) or contains anything uncopyable. The canonical uncopyable thing is sync.Mutex: copy a struct holding a locked mutex and you now have two mutexes that know nothing about each other — every “locked” section runs unprotected. go vet’s copylocks check exists because this bug ships constantly.

Tradeoff: value semantics buy you locality and freedom from aliasing bugs — a copied struct lives on the stack, costs zero GC, and nobody can mutate it under you. Pointers buy mutation and avoid copying big payloads, but every *T is a potential heap escape (the compiler must prove the pointee doesn’t outlive the frame) and a potential data race. The Go habit is: small, immutable-ish data by value; identity-bearing or mutex-holding data by pointer — and never mixed receivers on one type.

Zero values are designed to be useful

In Go, declared-but-uninitialized memory isn’t garbage — it’s the zero value: 0, "", nil, false, and for structs, all fields zeroed recursively. The standard library treats this as an API contract: var mu sync.Mutex is an unlocked mutex ready to Lock(); var buf bytes.Buffer is an empty buffer ready to Write(); var wg sync.WaitGroup is ready to Add(). No constructor, no init ceremony. When you design your own types, the senior move is to make the zero value mean something (“empty config” / “no-op logger”) so callers can embed your type in their structs and never call a constructor. Where a zero value genuinely can’t work (a type that needs a map, a channel, or a file handle), provide NewX() and document that the zero value is invalid — don’t half-work.

type Counter struct {
    mu sync.Mutex     // zero value: unlocked, usable
    n  map[string]int // zero value: nil map — readable, but writes panic
}

func (c *Counter) Inc(k string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    if c.n == nil {
        c.n = make(map[string]int) // lazy init keeps the zero value usable
    }
    c.n[k]++
}

That nil map detail is the classic trap: reading from a nil map returns zeros, but writing panics. A usable-zero-value type either lazily initializes or avoids maps in its zero path.

Embedding is composition, not inheritance

Go has no class hierarchy. Embedding — placing a type in a struct without a field name — promotes the inner type’s fields and methods to the outer type’s call surface, and that’s all it does. s.Log() on an embedded *Logger is sugar for s.Logger.Log(). There is no virtual dispatch: when the embedded type’s method calls another method, it calls its own, never an “override” you defined on the outer struct. If you came from Java expecting template-method polymorphism, this is where it breaks — the embedded method has no idea your outer type exists. Embedding answers “this type has a logger and exposes its methods,” never “this type is a kind of logger.” Want polymorphism? That’s what interfaces are for (next lessons).

Why this works

Why did Go reject inheritance on purpose? Deep hierarchies couple your code to a parent’s implementation details: a base-class change ripples into every subclass, and overriding creates action-at-a-distance where reading one class tells you nothing about runtime behavior. Embedding keeps the dependency shallow and mechanical — promoted methods are resolvable at compile time by reading two structs, the “fragile base class” problem can’t occur because the inner type can’t call outward, and reuse stays a la carte: embed two types and you’ve composed both method sets, something single inheritance can’t express. The cost is that you must wire shared behavior explicitly instead of inheriting it — Go considers that explicitness a feature.

Slices: a 24-byte header over a shared array

A slice is not an array. It’s a three-word header — pointer, len, cap — 24 bytes on 64-bit, pointing into a backing array that may be shared by other slices. s[2:5] allocates nothing: it builds a new header over the same memory. That makes slicing O(1) and free — and makes mutation visible through every header that shares the array.

append is where this bites. If len equals cap, append allocates a bigger array (roughly doubling under 256 elements, smoothly tapering toward ~1.25× for large slices in the current runtime), copies, and returns a header pointing at the new memory — the original is now detached. But if there’s spare capacity, append writes in place, into the shared backing array — that’s the Hook’s corrupted report: sorted[:3] had cap 31, so the appended row landed in sorted[3].

a := make([]int, 3, 8)   // len 3, cap 8
b := append(a, 99)       // cap has room: writes a's backing array at index 3
a = append(a, 7)         // SAME slot: b[3] is now 7, not 99

safe := append([]int(nil), a...) // idiomatic full copy: detached header
limited := a[:3:3]               // full slice expr: cap pinned to 3 → next append reallocs

Two defenses: copy when handing data across an ownership boundary (append([]T(nil), s...) or copy), or pin capacity with the three-index slice s[low:high:max] so a downstream append is forced to reallocate. Failure mode beyond corruption: a tiny slice of a huge array keeps the whole array alive — slicing 64 bytes out of a 100 MB read and storing it caches 100 MB, because the GC sees the backing array as reachable through your 24-byte header.

Quiz

A struct holds a sync.Mutex, and its Lock-using method is declared with a VALUE receiver: func (c Counter) Inc(). Several goroutines call Inc concurrently. What actually happens?

Quiz

Given a := make([]int, 3, 8), then b := append(a, 99), then a = append(a, 7). What is b[3]?

Recall before you leave
  1. 01
    What exactly is a slice, and what are the two distinct ways append can behave?
  2. 02
    How do value semantics, pointer receivers, and zero values interact when you design a Go type?
Recap

Go’s default is value semantics: assignment, function arguments, and range loop variables copy, and a copied struct is fully independent — sharing happens only when you write a pointer. That decides receivers: value receivers for small read-only methods, pointer receivers for mutation, big structs, or anything holding a sync.Mutex, because a copied mutex is two mutexes and zero mutual exclusion — the copylocks vet check exists for that exact shipped bug. Zero values are an API contract, not garbage memory: an uninitialized sync.Mutex, bytes.Buffer, or WaitGroup is ready to use, and well-designed types follow suit by making the zero value meaningful, lazily initializing nil maps (whose reads return zeros but whose writes panic). Embedding promotes an inner type’s fields and methods onto the outer type and nothing more — promoted methods cannot be overridden and the inner type never calls outward, so it expresses has-a composition, never is-a inheritance; polymorphism belongs to interfaces. A slice is a 24-byte header — pointer, len, cap — over a backing array that other slices may share: slicing is free and aliasing, append writes in place whenever spare capacity exists (the corrupted-report bug, where appending to sorted[:3] overwrote sorted[3]) and reallocates only when len reaches cap, roughly doubling small arrays and tapering toward ~1.25x for large ones. At ownership boundaries, detach deliberately: copy the data or pin capacity with the three-index slice so the next append must reallocate — and remember that a tiny slice of a huge buffer keeps the whole buffer alive for the GC. Now when you see a slice passed across a function boundary, your first question should be: do these two headers share the same backing array, and does my caller expect not to notice what I append?

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.