Slices Demystified
If you're writing Go, you're using slices. While arrays are fundamental, they are static and rigid. Slices, on the other hand, provide the dynamism and flexibility that make Go development efficient.
However, slices have a reputation for being tricky. Concepts like length, capacity, and the invisible "underlying array" can confuse beginners. This guide breaks down the four core operations you need to master to use slices effectively: allocation with make(), creating views via slicing, growing with append(), and ensuring independence with copy().
The Anatomy of a Go Slice
A slice is not the data itself; it is a structure (a descriptor) that points to a segment of a contiguous block of memory (the underlying array). Every slice descriptor contains three crucial elements:
- Pointer: Points to the first element of the segment in the underlying array.
- Length (
len): The number of elements currently accessible in the slice. - Capacity (
cap): The number of elements available in the underlying array, starting from the slice's pointer, before new memory must be allocated.
1. Allocating Slices with make()
Unlike arrays, which are usually initialized with literal values, slices that need initial space are often created using the built-in make() function. This is how you allocate the necessary underlying array memory upfront.
The signature for make() for slices is: make([]T, length, capacity).
Example: Using make()
package main
import "fmt"
func main() {
// 1. Specify Length and Capacity
// We want a slice of integers with 5 elements initially (len=5),
// but the underlying array should be able to hold 10 elements (cap=10).
s := make([]int, 5, 10)
fmt.Printf("Length: %d, Capacity: %d\n", len(s), cap(s))
// Output: Length: 5, Capacity: 10
}
Insight: If you omit the capacity argument, Go assumes the capacity is equal to the length. If you specify
make([]int, 0), you create an empty slice with zero length and zero capacity.
2. Key Slice Operation: Slicing (Creating Views)
Slicing is the act of creating a new slice descriptor that refers to a portion of an existing slice's (or array's) underlying memory.
The basic syntax is slice[low:high], where:
lowis the starting index (inclusive).highis the stopping index (exclusive).
The Basic Slicing Syntax
package main
import "fmt"
func main() {
// Underlying array holds 5 elements
source := []int{10, 20, 30, 40, 50}
// Create a new slice starting at index 1 (20) up to (but not including) index 4 (50).
subSlice := source[1:4]
fmt.Println("subSlice:", subSlice) // Output: [20 30 40]
fmt.Printf("Length: %d, Capacity: %d\n", len(subSlice), cap(subSlice))
// Length is 3 (20, 30, 40).
// Capacity is 4 (it can hold 20, 30, 40, and 50 before running out of space).
}
Controlling Capacity: The Full Slice Expression [low:high:max]
Sometimes you want to create a slice that prevents future additions from accessing the remaining elements of the underlying array. You can limit the new slice's capacity using a third index, max.
maxis the index where the new slice's capacity ends (exclusive).- The capacity of the new slice will be $max - low$.
package main
import "fmt"
func main() {
source := []int{10, 20, 30, 40, 50} // len 5, cap 5
// Slicing [start:end:capacity_limit]
// Start at index 1 (20), end before index 3 (40).
// Limit capacity at index 3 (index 3 holds 40).
constrained := source[1:3:3]
fmt.Println("constrained:", constrained) // Output: [20 30]
fmt.Printf("Length: %d\n", len(constrained)) // Length: 2
fmt.Printf("Capacity: %d\n", cap(constrained)) // Capacity: 3 - 1 = 2
}
This syntax is vital when you want to pass a portion of a slice to a function but guarantee that the function cannot accidentally modify the elements beyond its intended scope via
append().
The Shared Array Gotcha
Since slicing only creates a new view onto the same memory block, modifying elements in the new slice modifies the original source data.
package main
import "fmt"
func main() {
original := []int{1, 2, 3, 4}
view := original[1:3] // [2, 3]
// Modify an element in the 'view' slice
view[0] = 99
fmt.Println("View:", view) // Output: [99 3]
fmt.Println("Original:", original) // Output: [1 99 3 4]
// The original slice was mutated!
}
3. Growing Slices with append()
Slices are dynamic because of the built-in append() function. It handles adding elements and, crucially, managing the underlying memory when the capacity is exceeded.
Case 1: Capacity is Available
If the slice has remaining capacity, append() is fast. It simply places the new element into the next available spot in the existing underlying array and updates the slice's length.
Case 2: Capacity is Exceeded (Reallocation)
If the slice's capacity is full, append() performs a complex operation:
- It allocates a completely new, larger underlying array (usually doubling the current capacity).
- It copies all existing elements from the old array segment to the new array.
- It appends the new element(s) to the new array.
- It returns a new slice descriptor pointing to this new array.
package main
import "fmt"
func main() {
// Create a slice with len 2, cap 2
s := []int{1, 2}
fmt.Printf("Initial len: %d, cap: %d\n", len(s), cap(s))
// Append 3. Capacity is exceeded, reallocation occurs.
s = append(s, 3)
fmt.Printf("After append 3: len: %d, cap: %d\n", len(s), cap(s))
// Output: len: 3, cap: 4 (Capacity automatically increased)
// Append 4 and 5. Capacity is still available.
s = append(s, 4, 5)
fmt.Printf("After append 4, 5: len: %d, cap: %d\n", len(s), cap(s))
// Output: len: 5, cap: 8 (Capacity increased again, doubling the previous capacity)
}
Crucial Takeaway: When capacity is exceeded, the resulting slice is no longer tied to the original underlying array memory. This breaks the "shared array gotcha" for the newly appended slice.
4. Breaking the Bond with copy()
If you have a slice and need to create a true, independent duplicate—one that can be modified without affecting the original (or vice versa)—you must use the copy() function.
copy() requires that the destination slice has enough memory allocated to hold the elements from the source slice. This means you usually combine make() with copy().
package main
import "fmt"
func main() {
original := []int{10, 20, 30}
// 1. Allocate a destination slice with the correct length/capacity
independentCopy := make([]int, len(original))
// 2. Copy the elements from original to independentCopy
copy(independentCopy, original)
// Modify the copy
independentCopy[0] = 999
fmt.Println("Original:", original) // Output: [10 20 30] (Unchanged)
fmt.Println("Independent Copy:", independentCopy) // Output: [999 20 30]
}
Note on
copy():copy()copies the minimum number of elements possible between the source and destination slices. If the destination slice is smaller than the source, only the elements that fit are copied.
Summary of Slice Operations
| Operation | Purpose | Impact on Underlying Array |
|---|---|---|
make([]T, l, c) | Allocate memory and define initial length/capacity. | Creates the initial underlying array. |
s[low:high] | Create a new slice descriptor (view) referencing a segment. | No change; creates shared memory access. |
append(s, val) | Add elements to the slice. | If capacity is exceeded, allocates a new, larger array and moves data. |
copy(dst, src) | Create a true, independent duplicate. | Forces an element-by-element copy into a new or existing array segment. |
Mastering the interaction between length, capacity, and the underlying array is key to writing efficient and bug-free Go code. When in doubt about memory sharing, use make() combined with copy() to guarantee independence!