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[]bytestring 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.