Arrays and Slices in Go

📚 Comprehensive Guide ⏱️ 30 min read 🎯 Intermediate Level

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.