Understanding Arrays and Slices
The Memory Model
Arrays and slices are fundamental data structures in Go, but they work very differently under the hood. Arrays are fixed-size, value types that live on the stack (when small) or heap. Slices are dynamic, reference types that provide a view into an underlying array.
Key Differences
- Arrays: Fixed size, value semantics, size is part of the type
- Slices: Dynamic size, reference semantics, built on top of arrays
- Memory: Arrays are copied by value, slices share backing arrays
- Performance: Arrays avoid heap allocation for small sizes
Arrays in Go
Array Fundamentals
Arrays in Go have a fixed size that is part of their type. An [5]int
is a different
type from [10]int
. This compile-time size guarantee enables certain optimizations
but limits flexibility.
// Array declaration and initialization var arr1 [5]int // Zero-valued: [0 0 0 0 0] var arr2 = [3]string{"Go", "is", "awesome"} arr3 := [4]int{1, 2, 3, 4} // Compiler counts elements arr4 := [...]int{2, 4, 6, 8, 10} // Type: [5]int // Sparse arrays with indices arr5 := [10]int{0: 1, 9: 10} // [1 0 0 0 0 0 0 0 0 10] // Multi-dimensional arrays var matrix [3][3]int matrix[1][1] = 5
Array Operations and Semantics
Arrays in Go are passed by value, meaning the entire array is copied when passed to functions. This can be expensive for large arrays but provides value semantics and prevents unintended mutations.
// Arrays are values - assignment copies arr1 := [3]int{1, 2, 3} arr2 := arr1 // Full copy arr2[0] = 99 fmt.Println(arr1) // [1 2 3] - unchanged fmt.Println(arr2) // [99 2 3] // Comparing arrays if arr1 == arr2 { // Arrays are comparable if elements are fmt.Println("Equal") } // Iterating over arrays for i, v := range arr1 { fmt.Printf("arr[%d] = %d\n", i, v) } // Pass by value - expensive for large arrays func modifyArray(arr [1000000]int) { arr[0] = 999 // Doesn't affect original } // Use pointers for efficiency func modifyArrayPtr(arr *[1000000]int) { arr[0] = 999 // Modifies original }
Slices - Dynamic Arrays
Slice Internals
A slice is a descriptor containing three components: a pointer to the underlying array, the length of the slice, and its capacity. This structure makes slices both flexible and efficient.
Slice Header Structure
type slice struct { ptr *T // Pointer to underlying array len int // Current number of elements cap int // Capacity from ptr to end of array }
Creating and Initializing Slices
// Various ways to create slices var s1 []int // nil slice: ptr=nil, len=0, cap=0 s2 := []int{} // Empty slice: ptr≠nil, len=0, cap=0 s3 := []int{1, 2, 3} // Literal: len=3, cap=3 // Using make s4 := make([]int, 5) // len=5, cap=5, zero-valued s5 := make([]int, 3, 10) // len=3, cap=10 // Creating from array arr := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9} s6 := arr[2:7] // [2 3 4 5 6], cap=8 s7 := arr[:] // Entire array as slice // Full slice expression a[low:high:max] s8 := arr[2:7:8] // len=5, cap=6 (not 8)
Slice Operations and Behavior
Append and Growth
The append
function is the primary way to grow slices. When a slice's capacity
is exceeded, Go allocates a new, larger array and copies the elements. The growth strategy
typically doubles capacity for small slices and grows by ~25% for larger ones.
⚠️ Append Gotchas
- Append may or may not allocate a new backing array
- Always use the returned value:
slice = append(slice, elem)
- Multiple slices can share the same backing array until append causes reallocation
- Appending to a sub-slice can overwrite data in the original
// Append operations s := []int{1, 2, 3} fmt.Printf("len=%d cap=%d %v\n", len(s), cap(s), s) s = append(s, 4) // Single element s = append(s, 5, 6, 7) // Multiple elements // Append another slice other := []int{8, 9, 10} s = append(s, other...) // Note the ... // Growth demonstration var growth []int for i := 0; i < 20; i++ { oldCap := cap(growth) growth = append(growth, i) if cap(growth) != oldCap { fmt.Printf("Growth: len=%d new cap=%d\n", len(growth), cap(growth)) } }
Copy Operations
The copy
function creates independent copies of slice data. It returns the number
of elements copied, which is the minimum of the source and destination lengths.
// Copy operations src := []int{1, 2, 3, 4, 5} dst := make([]int, 3) n := copy(dst, src) // Copies min(len(dst), len(src)) fmt.Printf("Copied %d elements: %v\n", n, dst) // [1 2 3] // Full copy fullCopy := make([]int, len(src)) copy(fullCopy, src) // Copy overlapping slices s := []int{1, 2, 3, 4, 5} copy(s[2:], s[:]) // [1 2 1 2 3]
Advanced Slice Techniques
Slice Tricks and Patterns
// Filter a slice in place func filter(s []int, keep func(int) bool) []int { n := 0 for _, x := range s { if keep(x) { s[n] = x n++ } } return s[:n] } // Remove element at index (preserving order) func remove(s []int, i int) []int { copy(s[i:], s[i+1:]) return s[:len(s)-1] } // Remove without preserving order (faster) func removeUnordered(s []int, i int) []int { s[i] = s[len(s)-1] return s[:len(s)-1] } // Insert element at index func insert(s []int, i int, v int) []int { s = append(s, 0) copy(s[i+1:], s[i:]) s[i] = v return s } // Reverse a slice func reverse(s []int) { for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 { s[i], s[j] = s[j], s[i] } }
Memory Management and Performance
Understanding slice memory management is crucial for writing efficient Go code. Slices can lead to memory leaks if not handled carefully, especially when creating sub-slices of large arrays.
Performance Best Practices
- Preallocate: Use
make([]T, 0, cap)
when final size is known - Reuse slices: Clear and reuse instead of allocating new ones
- Avoid memory leaks: Copy small parts of large slices to release memory
- Benchmark: Measure performance impact of slice operations
// Preallocate for known size func collectEvenNumbers(nums []int) []int { // Worst case: all numbers are even result := make([]int, 0, len(nums)) for _, n := range nums { if n%2 == 0 { result = append(result, n) } } return result } // Avoid memory leaks with sub-slices func getFirstLine(data []byte) []byte { for i, b := range data { if b == '\n' { // Don't do: return data[:i] // This keeps entire data in memory // Do: copy to release original line := make([]byte, i) copy(line, data[:i]) return line } } return nil } // Clear slice for reuse func clearSlice(s []int) []int { return s[:0] // Keeps capacity, resets length }
Multi-dimensional Slices
Creating and Working with 2D Slices
Multi-dimensional slices are slices of slices. Each inner slice can have different lengths, creating jagged arrays. This flexibility comes at the cost of multiple allocations.
// Create 2D slice (matrix) func make2D(rows, cols int) [][]int { matrix := make([][]int, rows) for i := range matrix { matrix[i] = make([]int, cols) } return matrix } // Jagged array jagged := [][]int{ {1, 2, 3}, {4, 5}, {6, 7, 8, 9}, } // Efficient 2D slice with single allocation func makeEfficient2D(rows, cols int) [][]int { data := make([]int, rows*cols) // Single allocation matrix := make([][]int, rows) for i := range matrix { matrix[i] = data[i*cols : (i+1)*cols] } return matrix } // Transpose matrix func transpose(matrix [][]int) [][]int { if len(matrix) == 0 { return nil } rows := len(matrix) cols := len(matrix[0]) result := make2D(cols, rows) for i := 0; i < rows; i++ { for j := 0; j < cols; j++ { result[j][i] = matrix[i][j] } } return result }
Common Pitfalls and Solutions
Slice Header Copies
When you pass a slice to a function, you're passing a copy of the slice header, not the underlying data. Modifications to elements affect the original, but changes to the slice itself (append, reslicing) don't.
// Slice header is copied, but data is shared func modifySlice(s []int) { s[0] = 999 // Affects original s = append(s, 100) // Doesn't affect original } // Return modified slice or use pointer func appendToSlice(s []int, val int) []int { return append(s, val) } func appendToSlicePtr(s *[]int, val int) { *s = append(*s, val) }
Range Loop Variable Capture
⚠️ Common Range Loop Mistake
The loop variable in a range loop is reused across iterations. Capturing it in a goroutine or closure often leads to unexpected behavior.
// WRONG: All goroutines will use last value values := []int{1, 2, 3, 4, 5} for _, v := range values { go func() { fmt.Println(v) // Bug: prints 5 five times }() } // CORRECT: Copy the value for _, v := range values { v := v // Shadow with local copy go func() { fmt.Println(v) }() } // OR: Pass as parameter for _, v := range values { go func(val int) { fmt.Println(val) }(v) }
Performance Comparison
Operation | Array | Slice | Notes |
---|---|---|---|
Declaration | O(n) - zeroed | O(1) - nil slice | Arrays always allocate |
Access | O(1) | O(1) | Both have direct indexing |
Append | Not supported | O(1) amortized | Slice may reallocate |
Copy | O(n) - value copy | O(1) - header copy | Slice data shared until modified |
Pass to function | O(n) - full copy | O(1) - header copy | Arrays expensive for large sizes |
Memory | Stack (small) or heap | Always heap for backing array | Escape analysis determines |
Best Practices and Guidelines
When to Use Arrays vs Slices
- Use arrays when:
- Size is known at compile time and won't change
- You need value semantics
- Working with small, fixed-size data (e.g., RGB values, coordinates)
- Avoiding heap allocations is critical
- Use slices when:
- Size may vary at runtime
- You need to append/remove elements
- Passing large collections to functions
- Working with subsets of data
Memory Optimization Strategies
// 1. Preallocate when possible ids := make([]int, 0, expectedSize) // 2. Use copy for small extracts from large slices func extractSmall(large []byte, start, end int) []byte { small := make([]byte, end-start) copy(small, large[start:end]) return small // Original can be GC'd } // 3. Reset slices for reuse buffer = buffer[:0] // Keep capacity // 4. Use sync.Pool for temporary slices var bufferPool = sync.Pool{ New: func() interface{} { return make([]byte, 0, 1024) }, } // 5. Avoid slice expressions in hot paths // Instead of: processData(data[start:end]) // Consider: processDataRange(data, start, end)
🎯 Practice Exercises
Exercise 1: Implement a Ring Buffer
Create a fixed-size ring buffer using a slice that overwrites old data when full.
Exercise 2: Matrix Operations
Implement matrix multiplication for 2D slices with proper error handling.
Exercise 3: Memory-Efficient String Split
Write a string splitting function that minimizes allocations and memory usage.
Exercise 4: Slice Pool
Create a pool of reusable slices to reduce garbage collection pressure.