willworth.dev
View RSS feed

Understanding Go Slices: Avoiding Hidden Pitfalls

Published on

Understanding Go Slices: Avoiding Hidden Pitfalls

Go slices provide a powerful way to work with collections, but they come with a few quirks that can trip up developers, especially those coming from languages like JavaScript. If you’ve ever run into unexpected behavior when modifying slices, this guide will help you understand what’s happening and how to avoid common pitfalls.

The Core Difference: Arrays vs. Slices

  • Arrays in Go are fixed-size sequences of elements.
  • Slices are a more flexible abstraction over arrays, consisting of:
    • A pointer to an underlying array.
    • A length (the number of elements in use).
    • A capacity (the maximum number of elements before needing reallocation).

A slice can expand as long as it remains within the array’s capacity. However, once it exceeds that capacity, Go allocates a new, larger array and copies the old data into it.

The Hidden Danger: Slice Reallocation

Consider the following scenario:

arr := []int{1, 2, 3, 4, 5}
slice1 := arr[1:3] // slice1 = [2, 3]
slice2 := slice1 // slice2 references the same data

slice1 = append(slice1, 99) // Might trigger a reallocation

fmt.Println(slice1) // [2, 3, 99]
fmt.Println(slice2) // [2, 3], but NOT [2, 3, 99]

At first, slice1 and slice2 share the same underlying array. But when append(slice1, 99) is called, slice1 may exceed its original capacity, causing Go to allocate a new array. Now slice1 and slice2 reference different arrays, leading to a stale reference problem where slice2 does not reflect the new data.

Best Practices to Avoid Slice Pitfalls

1. Be Aware of Reallocation

If a slice has enough capacity, append() will modify the same underlying array. If not, a new array is allocated, and the old one remains unchanged for any other slices still pointing to it.

2. Use copy() for Independent Slices

If you need to ensure two slices do not share memory, explicitly copy the data:

slice1 := []int{1, 2, 3}
slice2 := make([]int, len(slice1))
copy(slice2, slice1) // Now slice2 is a separate copy

Now, modifications to slice1 will not affect slice2.

3. Be Careful When Returning Slices

If a function returns a slice that references a larger array, it might lead to unintended memory retention. Use copy() if you want an independent slice.

4. Preallocate Slice Capacity When Possible

To minimize unnecessary reallocation, preallocate the slice with a sufficient capacity:

slice := make([]int, 0, 100) // Preallocates space for 100 elements

5. Use Pointers When Modifying Shared Slices

If you need a function to modify a slice in a way that persists for the caller, pass a pointer:

func modifySlice(s *[]int) {
*s = append(*s, 42) // Modifies the caller’s slice directly
}

Conclusion

Go slices are powerful but can lead to unexpected behavior if you're not aware of how they share and reallocate memory. By understanding when slices share an array, when they reallocate, and how to use copy(), append(), and preallocation wisely, you can write more predictable and efficient Go code.