Go - Data Structures
Concept: Arrays offer a predictable memory access pattern, leading to efficient data retrieval due to hardware prefetching.
Why it matters: The CPU prefetcher reads ahead in memory; predictable sequential access turns cache misses into cache hits automatically.
package main
import "fmt"
// array: fixed size, contiguous, prefetcher-friendly
func sumArray(data [1024]int64) int64 {
var total int64
for i := 0; i < len(data); i++ {
total += data[i] // stride-1: every access is the next cache line element
}
return total
}
func main() {
var data [1024]int64
for i := range data {
data[i] = int64(i)
}
fmt.Println(sumArray(data))
}
Gotcha: Passing a large array by value copies the entire array — pass a pointer or use a slice instead.
Concept: Traversing an array in row-major order aligns with cache lines, resulting in significant performance gains compared to column-major traversal.
Why it matters: Row-major access matches how Go lays out 2D arrays in memory — column-major skips across cache lines causing repeated misses.
package main
import (
"fmt"
"time"
)
const N = 1024
var grid [N][N]int64
// row-major: inner loop increments column — sequential memory addresses
func rowMajorSum() int64 {
var total int64
for row := 0; row < N; row++ {
for col := 0; col < N; col++ {
total += grid[row][col] // grid[row] is a contiguous [N]int64
}
}
return total
}
// column-major: inner loop increments row — jumps N*8 bytes each step
func colMajorSum() int64 {
var total int64
for col := 0; col < N; col++ {
for row := 0; row < N; row++ {
total += grid[row][col] // skips across rows — cache line wasted
}
}
return total
}
func main() {
t := time.Now(); rowMajorSum(); fmt.Println("row:", time.Since(t))
t = time.Now(); colMajorSum(); fmt.Println("col:", time.Since(t))
}
Gotcha: Assuming loop order doesn't matter for performance — column-major on a 1024×1024 int64 grid is ~5× slower.
Concept: Mechanical Sympathy — understanding and aligning software design with the underlying hardware architecture.
Why it matters: Writing code that works with the CPU's prefetcher, cache, and branch predictor gives free performance without algorithmic changes.
package main
import "fmt"
// struct layout matches hot path: frequently-accessed fields at the front
// hot fields (accessed every iteration) before cold fields (rarely accessed)
type Particle struct {
X, Y, Z float64 // hot: accessed every frame — fits in one cache line
Mass float64 // hot: used in physics calculation
Name string // cold: only for debugging — push to back
CreatedAt int64 // cold: metadata
}
func sumMass(particles []Particle) float64 {
total := 0.0
for i := range particles {
total += particles[i].Mass // hot field — likely already in cache from X,Y,Z load
}
return total
}
func main() {
particles := make([]Particle, 1000)
for i := range particles {
particles[i].Mass = float64(i) * 0.1
}
fmt.Println(sumMass(particles))
}
Gotcha: Putting a large string or slice field before hot numeric fields — it pollutes every cache line load with cold data.
Concept: Cache Lines — units of data transfer between main memory and CPU caches; a 64-byte cache line means accessing one byte brings in surrounding 63 bytes.
Why it matters: Every cache miss costs ~100 cycles; designing data structures that fit in one cache line turns misses into hits.
package main
import (
"fmt"
"unsafe"
)
// 64 bytes = one cache line on most modern CPUs
// this struct fits entirely in one cache line
type CacheLineFriendly struct {
A int64 // 8 bytes
B int64 // 8 bytes
C int64 // 8 bytes
D int64 // 8 bytes
E int64 // 8 bytes
F int64 // 8 bytes
G int64 // 8 bytes
H int64 // 8 bytes
} // total: 64 bytes — exactly one cache line
func main() {
var s CacheLineFriendly
fmt.Printf("size: %d bytes\n", unsafe.Sizeof(s)) // 64
// accessing any field loads all 8 fields — subsequent accesses are free
s.A = 1
s.H = 8 // already in cache from loading s.A
fmt.Println(s.A, s.H)
}
Gotcha: False sharing: two goroutines writing to different fields of the same struct share a cache line — causes cache invalidation between CPU cores.
Concept: TLB (Translation Lookaside Buffer) — a cache that translates virtual memory addresses to physical addresses; predictable access patterns benefit the TLB.
Why it matters: TLB misses force expensive page table walks (~100 cycles each) — sequential access keeps the TLB warm and avoids them.
package main
import "fmt"
// sequential access: TLB translates one page, covers ~512 int64 accesses
// random access: each element may be on a different page — potential TLB miss per access
func sequentialAccess(data []int64) int64 {
var sum int64
for i := range data { // page-sequential — TLB covers large ranges
sum += data[i]
}
return sum
}
func randomAccess(data []int64, indices []int) int64 {
var sum int64
for _, idx := range indices { // random page jumps — TLB thrashes
sum += data[idx]
}
return sum
}
func main() {
data := make([]int64, 100000)
indices := make([]int, 100000)
for i := range data {
data[i] = int64(i)
indices[i] = i // sequential indices for this demo
}
fmt.Println(sequentialAccess(data))
fmt.Println(randomAccess(data, indices))
}
Gotcha: Using a map[int]int64 instead of []int64 for indexed data — map buckets are scattered across pages causing constant TLB pressure.
Concept: Value Semantics — each piece of code works on its own copy of data, ensuring isolation and preventing unintended side effects.
Why it matters: Value semantics eliminate shared-state bugs by construction — no mutation can surprise a caller that owns its own copy.
package main
import "fmt"
type Config struct {
Debug bool
Workers int
Host string
}
// caller's config is untouched — function works on its own copy
func run(cfg Config) {
cfg.Debug = true // modifies only the local copy
cfg.Workers = 0 // does not affect caller
fmt.Println("run cfg:", cfg)
}
func main() {
cfg := Config{Debug: false, Workers: 4, Host: "localhost"}
run(cfg)
fmt.Println("main cfg:", cfg) // unchanged — {false 4 localhost}
}
Gotcha: Using value semantics for a large struct in a hot loop — copying 1KB structs per iteration is measurably slower than passing a pointer.
Concept: Pointer Semantics — data is shared through a single copy, enhancing efficiency but requiring careful management.
Why it matters: Pointer semantics avoid copying large data structures but introduce shared mutable state — synchronization becomes the programmer's responsibility.
package main
import "fmt"
type Buffer struct {
data []byte
pos int
}
// pointer receiver: shared access — all callers see the same buffer state
func (b *Buffer) Write(p []byte) int {
b.data = append(b.data, p...)
n := len(p)
b.pos += n
return n
}
func (b *Buffer) Bytes() []byte { return b.data }
func main() {
buf := &Buffer{}
buf.Write([]byte("hello"))
buf.Write([]byte(" world"))
fmt.Println(string(buf.Bytes())) // hello world — shared state accumulated
}
Gotcha: Sharing a pointer to a Buffer across goroutines without a mutex — concurrent Write calls corrupt the internal state.
Concept: String Data Structure — strings in Go consist of a pointer to a backing array of bytes and a length value.
Why it matters: Strings are two-word headers — copying a string is cheap (copies only the header, not the bytes); this makes strings safe to pass by value.
package main
import (
"fmt"
"unsafe"
"reflect"
)
func main() {
s := "hello, world"
// string header: reflect.StringHeader exposes the two-word structure
header := (*reflect.StringHeader)(unsafe.Pointer(&s))
fmt.Printf("data ptr: %x\n", header.Data)
fmt.Printf("length: %d\n", header.Len)
fmt.Printf("header size: %d bytes\n", unsafe.Sizeof(s)) // 16 on 64-bit
// slicing creates a new header pointing into the same backing array
sub := s[7:12] // "world"
subHeader := (*reflect.StringHeader)(unsafe.Pointer(&sub))
fmt.Printf("sub data ptr: %x\n", subHeader.Data) // offset by 7 bytes from s
fmt.Printf("sub length: %d\n", subHeader.Len) // 5
}
Gotcha: Converting string to []byte to modify it — this always copies the backing array; instead, build the string fresh.
Concept: Value Semantic for range — iterates over a copy of the array, providing isolation from external mutations.
Why it matters: The copy is made once at loop start — mutations to the original inside the loop don't affect iteration bounds or values.
package main
import "fmt"
func main() {
arr := [5]int{10, 20, 30, 40, 50}
// value semantic for range: arr is copied at loop start
// modifying arr inside the loop does NOT affect v
for i, v := range arr {
arr[i] = 0 // modifying original — copy already made
fmt.Println(v) // prints original values: 10 20 30 40 50
}
fmt.Println("arr after:", arr) // [0 0 0 0 0] — original was modified
}
Gotcha: Expecting the range copy to reflect mutations made to the array before the loop — the copy captures state at loop entry, not at each iteration.
Concept: Pointer Semantic for range — directly indexes into the data, offering efficiency but exposing the loop to side effects.
Why it matters: Direct indexing avoids the copy but means mutations within the loop are immediately visible in subsequent iterations.
package main
import "fmt"
func main() {
nums := [5]int{1, 2, 3, 4, 5}
// pointer semantic: index directly — no copy made
for i := 0; i < len(nums); i++ {
if nums[i] == 3 {
nums[i+1] = 99 // side effect: next iteration sees 99, not 4
}
fmt.Println(nums[i]) // 1 2 3 99 5
}
}
Gotcha: Using pointer semantic range when shrinking the slice inside the loop — len is recomputed each iteration, but the upper bound may have shifted.
Concept: Data Semantic Consistency — adhering to consistent data semantics throughout a codebase is crucial for readability and bug prevention.
Why it matters: Mixing value and pointer semantics for the same type across a codebase creates cognitive overhead and subtle mutation bugs.
package main
import "fmt"
type Temperature float64
// consistent: all operations use value semantics — Temperature is a built-in-like type
func (t Temperature) ToCelsius() Temperature { return (t - 32) * 5 / 9 }
func (t Temperature) ToFahrenheit() Temperature { return t*9/5 + 32 }
func (t Temperature) String() string { return fmt.Sprintf("%.1f°", float64(t)) }
func main() {
boiling := Temperature(212) // Fahrenheit
celsius := boiling.ToCelsius()
fmt.Println(boiling, "F =", celsius, "C") // 212.0° F = 100.0° C
}
Gotcha: Defining some methods with value receivers and others with pointer receivers on the same type — breaks interface satisfaction and confuses semantic intent.
Concept: Slices are dynamic arrays — offering the performance benefits of arrays while providing flexibility in size.
Why it matters: Slices give you contiguous-memory performance with runtime-variable length — the best of both worlds for most use cases.
package main
import "fmt"
func main() {
// slice vs array: same underlying memory model, but size is runtime-flexible
arr := [5]int{1, 2, 3, 4, 5} // fixed: size is part of the type
sli := []int{1, 2, 3, 4, 5} // dynamic: size known only at runtime
// slice can grow; array cannot
sli = append(sli, 6, 7, 8)
fmt.Println(arr)
fmt.Println(sli)
// both are contiguous in memory — same cache performance for sequential access
fmt.Printf("arr[0] addr: %p\n", &arr[0])
fmt.Printf("sli[0] addr: %p\n", &sli[0])
}
Gotcha: Using [N]T when the size varies at runtime — you'll end up with [maxN]T wasting memory for small inputs.
Concept: Slices are reference types — their values are pointers to underlying data structures.
Why it matters: Passing a slice passes a header (ptr+len+cap) by value — but the backing array is shared, so mutations are visible to the caller.
package main
import "fmt"
func zero(s []int) {
for i := range s {
s[i] = 0 // modifies the shared backing array — caller sees this
}
}
func main() {
data := []int{1, 2, 3, 4, 5}
fmt.Println("before:", data) // [1 2 3 4 5]
zero(data) // passes header by value — but mutations go through to backing array
fmt.Println("after:", data) // [0 0 0 0 0] — caller sees changes
}
Gotcha: Appending inside a function and expecting the caller to see the new elements — append may create a new backing array, severing the connection.
Concept: Slice Structure — a pointer to a backing array, a length indicating accessible elements, and a capacity representing total elements in the backing array.
Why it matters: Understanding the three-word header explains all slice behaviors: sharing, growing, reslicing, and the append contract.
package main
import (
"fmt"
"unsafe"
"reflect"
)
func main() {
s := make([]int, 3, 5) // len=3, cap=5
// expose the three-word slice header
header := (*reflect.SliceHeader)(unsafe.Pointer(&s))
fmt.Printf("data ptr: %x\n", header.Data)
fmt.Printf("len: %d\n", header.Len)
fmt.Printf("cap: %d\n", header.Cap)
fmt.Printf("header size: %d bytes\n", unsafe.Sizeof(s)) // 24 on 64-bit
s[0], s[1], s[2] = 10, 20, 30
// sub-slice shares the same backing array
sub := s[1:3]
fmt.Printf("sub data ptr: %x\n", (*reflect.SliceHeader)(unsafe.Pointer(&sub)).Data)
fmt.Println("sub:", sub) // [20 30] — same memory, different header
}
Gotcha: Assuming len(s) == cap(s) after make([]T, n, m) where n < m — len is n, cap is m, and elements [n:m] exist but are inaccessible without reslicing.
Concept: make function is used to create slices and pre-allocate their backing arrays, optimizing performance by reducing reallocations.
Why it matters: Pre-allocating with make([]T, 0, n) avoids repeated doublings during append — one allocation instead of log₂(n).
package main
import "fmt"
func collectWithMake(n int) []int {
// pre-allocate: exactly one backing array allocated — no reallocs
result := make([]int, 0, n)
for i := 0; i < n; i++ {
result = append(result, i*i) // never exceeds initial capacity
}
return result
}
func collectWithoutMake(n int) []int {
// no pre-allocation: backing array reallocated ~log2(n) times
var result []int
for i := 0; i < n; i++ {
result = append(result, i*i)
}
return result
}
func main() {
a := collectWithMake(1024)
b := collectWithoutMake(1024)
fmt.Println(a[0], b[0], len(a) == len(b)) // same values, different allocation cost
}
Gotcha: Using make([]T, n) when you want to append — elements [0:n] are already zero-initialized and append adds AFTER them, giving a slice of length 2n.
Concept: Reference Types — data types where the value is a reference (pointer) to the actual data; includes slices, maps, channels, interfaces, and functions.
Why it matters: Reference types share state implicitly — understanding which types are references prevents unexpected aliasing bugs.
package main
import "fmt"
func main() {
// all reference types: copying the variable copies the reference, not the data
// slice
s1 := []int{1, 2, 3}
s2 := s1 // copies the header — s2 shares backing array with s1
s2[0] = 99
fmt.Println(s1[0]) // 99 — shared mutation
// map
m1 := map[string]int{"a": 1}
m2 := m1 // copies the map pointer
m2["a"] = 99
fmt.Println(m1["a"]) // 99 — shared mutation
// channel
ch1 := make(chan int, 1)
ch2 := ch1 // same channel — both send/receive from same queue
ch1 <- 42
fmt.Println(<-ch2) // 42
}
Gotcha: Using copy() for maps and channels thinking it deep-copies the data — only []T has a copy built-in; maps must be manually copied.
Concept: Value Semantics for Reference Types — Go uses value semantics to pass slice values around, avoiding heap pollution.
Why it matters: Passing slices by value (not *[]T) keeps the slice header on the stack while the backing array stays on the heap — optimal memory layout.
package main
import "fmt"
// pass slice by value — header copied (cheap), backing array shared (correct)
func process(data []int) []int {
result := make([]int, len(data))
for i, v := range data {
result[i] = v * 2
}
return result // new slice returned — no mutation of caller's slice
}
// avoid: passing *[]int pollutes the heap and signals mutation intent incorrectly
func processPtr(data *[]int) { // only needed if appending results back to caller
for i, v := range *data {
(*data)[i] = v * 2
}
}
func main() {
input := []int{1, 2, 3, 4, 5}
output := process(input)
fmt.Println(input) // [1 2 3 4 5] — unchanged
fmt.Println(output) // [2 4 6 8 10]
}
Gotcha: Using *[]T parameters when you only need to read or transform — value semantics are sufficient and clearer.
Concept: append is a value-semantic mutation API — works on copies and returns new copies, minimizing side effects.
Why it matters: append always returns a new slice header — ignoring the return value is a bug; the original slice is never mutated in place.
package main
import "fmt"
func main() {
s := []int{1, 2, 3}
// append RETURNS the new slice — must be assigned
s = append(s, 4, 5)
fmt.Println(s) // [1 2 3 4 5]
// common bug: discarding the return value
_ = append(s, 6) // 6 is LOST — s is unchanged
fmt.Println(s) // still [1 2 3 4 5]
// variadic form: spread a slice with ...
extra := []int{6, 7, 8}
s = append(s, extra...)
fmt.Println(s) // [1 2 3 4 5 6 7 8]
}
Gotcha: Calling append(s, x) without reassigning — the most common append bug; the linter catches it as "result of append not used".
Concept: Growth behavior — doubles capacity under 1000 elements; increases by ~25% over 1000 elements.
Why it matters: Understanding growth curves lets you decide when make([]T, 0, n) is worth it versus paying the doubling tax.
package main
import "fmt"
func main() {
s := make([]int, 0)
prev := 0
for i := 0; i < 2050; i++ {
s = append(s, i)
if cap(s) != prev {
growth := 0
if prev > 0 {
growth = (cap(s) - prev) * 100 / prev
}
fmt.Printf("len=%4d cap=%4d growth=+%d%%\n", len(s), cap(s), growth)
prev = cap(s)
}
}
// observe: ~100% growth until ~1024, then ~25% growth after
}
Gotcha: Assuming a fixed doubling ratio — Go's growth algorithm accounts for both size and alignment; exact numbers vary by Go version.
Concept: Memory leaks in Go often stem from goroutines, maps used as caches, incorrect usage of append, and failure to close resources.
Why it matters: Go's GC reclaims unreachable memory — but "reachable but unneeded" memory (a live map cache or goroutine) never gets reclaimed.
package main
import (
"fmt"
"runtime"
)
// leak: sub-slice keeps entire large backing array alive
func leakySubslice() []byte {
large := make([]byte, 1<<20) // 1MB
return large[:4] // 4 bytes visible, 1MB kept alive
}
// fix: copy only what you need — large array becomes unreachable
func safeSubslice() []byte {
large := make([]byte, 1<<20)
result := make([]byte, 4)
copy(result, large[:4]) // large is now unreachable after this function returns
return result
}
func main() {
_ = leakySubslice()
_ = safeSubslice()
runtime.GC()
var stats runtime.MemStats
runtime.ReadMemStats(&stats)
fmt.Printf("heap alloc: %d KB\n", stats.HeapAlloc/1024)
}
Gotcha: Using s = s[:0] to "clear" a slice — the backing array is still referenced and cannot be GC'd; use s = nil or s = s[:0:0].
Concept: Sharing slices through pointers can lead to unexpected behavior when append reallocates the backing array.
Why it matters: After append triggers reallocation, any pointer that was pointing into the old backing array now points to stale memory.
package main
import "fmt"
func main() {
original := make([]int, 3, 4)
original[0], original[1], original[2] = 1, 2, 3
// alias shares the same backing array
alias := original[:2]
fmt.Printf("original ptr: %p\n", &original[0])
fmt.Printf("alias ptr: %p\n", &alias[0]) // same pointer
// append to original — still within capacity, no realloc
original = append(original, 4)
fmt.Printf("after append within cap — alias[0] ptr: %p\n", &alias[0]) // same
// append triggers realloc — original moves, alias stays on old backing array
original = append(original, 5, 6, 7) // exceeds cap=4 — new backing array
original[0] = 99 // modifies NEW array
fmt.Println("original[0]:", original[0]) // 99
fmt.Println("alias[0]: ", alias[0]) // 1 — alias still on OLD array
}
Gotcha: Using append on a slice obtained from a function parameter (pointer semantic) and assuming the caller's copy updated — it didn't if reallocation occurred.
Concept: Code reviews should pay close attention to append calls especially outside decode or unmarshal functions.
Why it matters: append outside a dedicated mutation context is a signal of uncontrolled slice growth that may cause aliasing bugs or unexpected sharing.
package main
import (
"encoding/json"
"fmt"
)
type Event struct {
Name string `json:"name"`
}
// correct: append contained within unmarshal — caller gets a clean, independent slice
func parseEvents(data []byte) ([]Event, error) {
var events []Event
if err := json.Unmarshal(data, &events); err != nil {
return nil, fmt.Errorf("parseEvents: %w", err)
}
return events, nil // events slice is owned by this function — no shared backing array
}
func main() {
raw := []byte(`[{"name":"login"},{"name":"logout"}]`)
events, err := parseEvents(raw)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(events)
}
Gotcha: Appending to a slice received as a parameter and returning it — the caller may hold a stale reference to the original backing array.
Concept: Strings in Go are essentially immutable byte slices.
Why it matters: Immutability makes strings safe to share across goroutines without synchronization; it also means every "modification" allocates a new string.
package main
import "fmt"
func main() {
s := "hello"
// strings are immutable — no in-place modification
// s[0] = 'H' // compile error: cannot assign to s[0] (strings are not addressable)
// to "modify": create a new string via conversion
b := []byte(s) // copies into a mutable byte slice
b[0] = 'H'
modified := string(b) // copies back into a new immutable string
fmt.Println(s) // hello — original unchanged
fmt.Println(modified) // Hello — new string
}
Gotcha: Converting string → []byte → string in a tight loop — each conversion allocates; use strings.Builder for accumulation.
Concept: Code Points and UTF-8 — strings are encoded using UTF-8 where characters may be represented by one or more bytes.
Why it matters: Indexing a string with s[i] gives a byte, not a character — multi-byte characters require range or utf8 package for correct iteration.
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
s := "Hello, 世界" // ASCII + multi-byte CJK characters
fmt.Println("byte length:", len(s)) // 13 bytes
fmt.Println("rune count: ", utf8.RuneCountInString(s)) // 9 characters
// byte indexing — gives raw bytes, NOT characters
fmt.Printf("s[7] = %x\n", s[7]) // first byte of 世 (e4), not the full rune
// range decodes UTF-8 runes correctly
for i, r := range s {
fmt.Printf("index=%2d rune=%c bytes=%d\n", i, r, utf8.RuneLen(r))
}
}
Gotcha: Using len(s) to count characters in a string containing non-ASCII — it counts bytes, not runes.
Concept: Copy Function — efficiently copies data between slices; slicing syntax on arrays creates temporary slices for use with copy.
Why it matters: copy guarantees correct behavior even with overlapping slices; it copies only min(len(dst), len(src)) elements.
package main
import "fmt"
func main() {
src := []int{1, 2, 3, 4, 5}
dst := make([]int, 3)
// copy: returns number of elements copied — min(len(dst), len(src))
n := copy(dst, src)
fmt.Printf("copied %d elements: %v\n", n, dst) // 3: [1 2 3]
// copy from array: use slice syntax to create a temporary slice
arr := [5]byte{'a', 'b', 'c', 'd', 'e'}
buf := make([]byte, 3)
copy(buf, arr[:]) // arr[:] is a temporary slice header — no new allocation
fmt.Println(string(buf)) // abc
// overlapping copy: handles correctly (unlike C's memcpy)
data := []int{1, 2, 3, 4, 5}
copy(data[1:], data[:]) // shift right by 1 — overlap handled correctly
fmt.Println(data) // [1 1 2 3 4]
}
Gotcha: Passing a nil destination to copy — it copies 0 elements silently; always ensure dst is allocated with the correct length.
Concept: Understanding the different behaviors of value and pointer semantic for range when modifying slices within the loop.
Why it matters: The choice of range form determines whether in-loop modifications affect the current iteration — picking the wrong one causes silent logic bugs.
package main
import "fmt"
func main() {
// value semantic: range captures a copy of the slice header at loop start
// length is fixed — appending inside loop doesn't affect iteration count
s := []int{1, 2, 3}
for _, v := range s {
if v == 2 {
s = append(s, 99) // s now has 4 elements — but loop still runs 3 times
}
fmt.Print(v, " ")
}
fmt.Println()
fmt.Println("s:", s) // [1 2 3 99]
// pointer semantic: len(s2) re-evaluated every iteration — reflects changes
s2 := []int{1, 2, 3}
for i := 0; i < len(s2); i++ {
if s2[i] == 2 {
s2 = append(s2, 99) // now len=4 — loop runs one extra time
}
fmt.Print(s2[i], " ") // 1 2 3 99
}
fmt.Println()
}
Gotcha: Shrinking the slice inside a pointer-semantic loop — len decreases, causing index-out-of-bounds on the next iteration.
Concept: Value Semantic for range with Slice Modification — the loop is unaffected by modifications to the original slice.
Why it matters: The snapshot behavior gives safe iteration even if the slice is modified — the iteration count and values are fixed at loop entry.
package main
import "fmt"
func main() {
items := []string{"a", "b", "c", "d"}
// value semantic range: snapshot of header taken — len=4 fixed
processed := make([]string, 0, len(items))
for _, item := range items {
// even if items is resliced here, the range still sees 4 elements
processed = append(processed, item+item)
}
fmt.Println(processed) // [aa bb cc dd]
fmt.Println(items) // [a b c d] — untouched
}
Gotcha: Relying on value semantic range to prevent all mutation — mutations to the backing array elements ARE visible even though the iteration count is fixed.
Concept: Pointer Semantic for range with Slice Modification — modifying a slice's length during iteration can lead to index out of range errors.
Why it matters: The index-based loop reads len(s) dynamically — if you shrink s inside the loop, the last index check will skip elements or panic.
package main
import "fmt"
func main() {
// safe pointer-semantic loop that modifies elements in-place
nums := []int{1, 2, 3, 4, 5}
for i := 0; i < len(nums); i++ {
nums[i] *= 10 // in-place mutation — safe because len doesn't change
}
fmt.Println(nums) // [10 20 30 40 50]
// unsafe: shrink the slice inside the loop
data := []int{1, 2, 3, 4, 5}
for i := 0; i < len(data); i++ {
if data[i] == 3 {
data = data[:i] // shrink — next iteration: i=3, len=3, loop exits early
}
fmt.Print(data[i], " ")
}
fmt.Println()
fmt.Println("data:", data) // [1 2]
}
Gotcha: Growing the slice inside a pointer-semantic loop — you create an infinite loop because len keeps increasing.