Go Data Structures & Algorithms
Slices in Go
Table of Contents
Slices are one of the most powerful and frequently used data structures in Go. They provide a more flexible, dynamic alternative to arrays with excellent performance characteristics. Understanding slices is crucial for effective Go programming, as they are used extensively in the standard library and in most Go codebases.
What is a Slice?
A slice in Go is a dynamic, variable-length sequence that provides a view into an underlying array. Conceptually, a slice consists of three components:
- A pointer to the underlying array
- The length of the slice (number of elements it contains)
- The capacity of the slice (maximum number of elements it can hold without reallocation)
This design makes slices both powerful and efficient, combining the flexibility of dynamic arrays with the performance of fixed-size arrays.
Creating Slices
There are several ways to create slices in Go. Let's explore each method with examples.
Using the Make Function
The make function is the most common way to create a slice. It allocates a zeroed array and returns a slice that refers to that array:
// Create a slice with length 5 and capacity 5
slice := make([]int, 5)
// Create a slice with length 3 and capacity 10
slice := make([]int, 3, 10)
In the first example, we create a slice of integers with both length and capacity of 5. In the second example, we create a slice with length 3 but capacity 10, meaning it initially contains 3 elements but can grow to hold up to 10 elements without reallocation.
Slice Literals
You can create a slice using a slice literal, which looks similar to an array literal but without the specified length:
// Create a slice with 3 elements
fruits := []string{"apple", "banana", "cherry"}
This creates a slice with both length and capacity of 3.
Creating a Slice from an Array
You can create a slice from an existing array by specifying a range of indices:
array := [5]int{1, 2, 3, 4, 5}
slice := array[1:4] // Creates a slice containing [2, 3, 4]
The slice expression array[low:high] creates a slice that includes elements from index low up to, but not including, index high.
Creating a Slice from Another Slice
Similarly, you can create a slice from an existing slice:
originalSlice := []int{1, 2, 3, 4, 5}
newSlice := originalSlice[1:4] // Creates a slice containing [2, 3, 4]
Slice Operations
Let's look at common operations you can perform with slices in Go.
Accessing Elements
You can access slice elements using index notation, just like with arrays:
slice := []string{"apple", "banana", "cherry"}
fmt.Println(slice[1]) // Output: banana
Modifying Elements
You can modify slice elements using index notation:
slice := []string{"apple", "banana", "cherry"}
slice[1] = "blueberry"
fmt.Println(slice) // Output: [apple blueberry cherry]
Slicing Operations
You can create sub-slices using the slicing operator:
slice := []int{1, 2, 3, 4, 5}
// Different ways to slice
a := slice[1:4] // [2, 3, 4]
b := slice[:3] // [1, 2, 3]
c := slice[2:] // [3, 4, 5]
d := slice[:] // [1, 2, 3, 4, 5] (copy of the entire slice)
Appending to Slices
The built-in append function adds elements to the end of a slice:
slice := []int{1, 2, 3}
slice = append(slice, 4)
fmt.Println(slice) // Output: [1 2 3 4]
// Append multiple elements
slice = append(slice, 5, 6, 7)
fmt.Println(slice) // Output: [1 2 3 4 5 6 7]
// Append another slice
otherSlice := []int{8, 9, 10}
slice = append(slice, otherSlice...)
fmt.Println(slice) // Output: [1 2 3 4 5 6 7 8 9 10]
The append function is variadic, meaning it can take any number of arguments. When appending another slice, you need to use the ellipsis (...) to "unpack" the slice.
Copying Slices
The built-in copy function copies elements from one slice to another:
src := []int{1, 2, 3, 4, 5}
dst := make([]int, len(src))
copied := copy(dst, src)
fmt.Println(dst) // Output: [1 2 3 4 5]
fmt.Println(copied) // Output: 5 (number of elements copied)
The copy function copies the minimum of the lengths of the source and destination slices.
Deleting Elements
Go doesn't have a built-in function to delete elements from a slice, but you can achieve this using slicing and append:
// Delete element at index 2
slice := []int{1, 2, 3, 4, 5}
slice = append(slice[:2], slice[3:]...)
fmt.Println(slice) // Output: [1 2 4 5]
Inserting Elements
Similarly, you can insert elements using slicing and append:
// Insert 99 at index 2
slice := []int{1, 2, 3, 4, 5}
slice = append(slice[:2], append([]int{99}, slice[2:]...)...)
fmt.Println(slice) // Output: [1 2 99 3 4 5]
Slice Internals
Understanding the internal structure of slices is crucial for writing efficient Go code.
Slice Header
A slice in Go is represented by a slice header, which is a struct with three fields:
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
Datais a pointer to the underlying array.Lenis the length of the slice (number of elements it contains).Capis the capacity of the slice (maximum number of elements it can hold without reallocation).
This structure explains why slices are reference types in Go. When you assign a slice to a variable or pass it to a function, you're copying the slice header, not the underlying array.
Slice Growth
When you append to a slice and its capacity is exceeded, Go allocates a new, larger array and copies the elements from the old array to the new one. The growth strategy is implementation-dependent, but typically, the new capacity is roughly double the old capacity.
package main
import "fmt"
func main() {
s := make([]int, 0)
for i := 0; i < 10; i++ {
s = append(s, i)
fmt.Printf("len=%d cap=%d %v\n", len(s), cap(s), s)
}
}
This code demonstrates how the capacity of a slice grows as elements are appended. The output might look something like:
len=1 cap=1 [0]
len=2 cap=2 [0 1]
len=3 cap=4 [0 1 2]
len=4 cap=4 [0 1 2 3]
len=5 cap=8 [0 1 2 3 4]
len=6 cap=8 [0 1 2 3 4 5]
len=7 cap=8 [0 1 2 3 4 5 6]
len=8 cap=8 [0 1 2 3 4 5 6 7]
len=9 cap=16 [0 1 2 3 4 5 6 7 8]
len=10 cap=16 [0 1 2 3 4 5 6 7 8 9]
Notice how the capacity doubles when the slice needs to grow beyond its current capacity.
Sharing Underlying Arrays
Multiple slices can share the same underlying array. This is both powerful and potentially dangerous:
original := []int{1, 2, 3, 4, 5}
slice1 := original[1:3] // [2, 3]
slice2 := original[2:4] // [3, 4]
// Modifying slice1 affects both original and slice2
slice1[1] = 99
fmt.Println(original) // Output: [1 2 99 4 5]
fmt.Println(slice1) // Output: [2 99]
fmt.Println(slice2) // Output: [99 4]
In this example, modifying an element in slice1 also affects original and slice2 because they all share the same underlying array.
Common Slice Patterns
Let's explore some common patterns and idioms for working with slices in Go.
Filtering Elements
You can filter elements in a slice using a loop and append:
// Filter even numbers
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
var evens []int
for _, num := range numbers {
if num%2 == 0 {
evens = append(evens, num)
}
}
fmt.Println(evens) // Output: [2 4 6 8 10]
Transforming Elements (Map)
You can transform each element in a slice:
// Double each number
numbers := []int{1, 2, 3, 4, 5}
doubled := make([]int, len(numbers))
for i, num := range numbers {
doubled[i] = num * 2
}
fmt.Println(doubled) // Output: [2 4 6 8 10]
Removing Duplicates
You can remove duplicates from a slice using a map:
func removeDuplicates(slice []int) []int {
seen := make(map[int]bool)
result := []int{}
for _, value := range slice {
if _, ok := seen[value]; !ok {
seen[value] = true
result = append(result, value)
}
}
return result
}
numbers := []int{1, 2, 2, 3, 4, 4, 5}
unique := removeDuplicates(numbers)
fmt.Println(unique) // Output: [1 2 3 4 5]
Reversing a Slice
You can reverse a slice in-place:
func reverse(slice []int) {
for i, j := 0, len(slice)-1; i < j; i, j = i+1, j-1 {
slice[i], slice[j] = slice[j], slice[i]
}
}
numbers := []int{1, 2, 3, 4, 5}
reverse(numbers)
fmt.Println(numbers) // Output: [5 4 3 2 1]
Performance Analysis
Understanding the performance characteristics of slices is crucial for writing efficient Go code.
Time Complexity
| Operation | Time Complexity | Explanation |
|---|---|---|
| Access by Index | O(1) | Slices provide constant-time access to any element by index |
| Append (amortized) | O(1) | Appending is usually constant time, but occasionally requires O(n) time when the slice needs to grow |
| Append (worst case) | O(n) | When the slice needs to grow, all elements must be copied to a new array |
| Slicing | O(1) | Creating a sub-slice is a constant-time operation |
| Copy | O(n) | Copying requires iterating through all elements |
| Search (unsorted) | O(n) | In an unsorted slice, you need to check each element in the worst case |
| Search (sorted) | O(log n) | In a sorted slice, you can use binary search |
Memory Considerations
Slices are efficient in terms of memory usage, but there are some considerations:
- Slice headers are small (24 bytes on a 64-bit system), making them efficient to pass around.
- Slices hold references to arrays, which means the array cannot be garbage collected as long as any slice referencing it is still in use.
- Slicing a large array and keeping a reference to a small part can prevent the entire array from being garbage collected, leading to memory leaks.
Best Practices
Here are some best practices to follow when working with slices in Go:
Pre-allocate When Possible
If you know the approximate size of your slice in advance, pre-allocate it to avoid reallocations:
// Bad: Potentially many reallocations
var data []int
for i := 0; i < 10000; i++ {
data = append(data, i)
}
// Good: Pre-allocate with make
data := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
data = append(data, i)
}
Use Copy to Avoid Shared References
If you want to ensure that modifications to a slice don't affect other slices, make a copy:
original := []int{1, 2, 3, 4, 5}
// Create a completely independent copy
clone := make([]int, len(original))
copy(clone, original)
// Now modifying clone doesn't affect original
clone[0] = 99
fmt.Println(original) // Output: [1 2 3 4 5]
fmt.Println(clone) // Output: [99 2 3 4 5]
Be Careful with Large Slices
When working with large slices, be mindful of memory usage:
// This can lead to memory leaks if largeSlice is very large
largeSlice := make([]int, 1000000)
smallSlice := largeSlice[0:10] // smallSlice references the large array
// Better approach: Create a copy to allow the large array to be garbage collected
smallSlice = make([]int, 10)
copy(smallSlice, largeSlice[:10])
Use the Three-Index Slice Form When Needed
The three-index slice form allows you to control the capacity of the resulting slice:
original := []int{1, 2, 3, 4, 5}
// Two-index form: slice[low:high]
// Capacity is original.cap - low
slice1 := original[1:3]
fmt.Printf("len=%d cap=%d\n", len(slice1), cap(slice1)) // len=2 cap=4
// Three-index form: slice[low:high:max]
// Capacity is max - low
slice2 := original[1:3:4]
fmt.Printf("len=%d cap=%d\n", len(slice2), cap(slice2)) // len=2 cap=3
The three-index form is useful when you want to limit the capacity of a slice to prevent future appends from modifying elements beyond what you intend.
Common Pitfalls
Here are some common pitfalls to avoid when working with slices in Go:
Appending to a Slice in a Loop
Be careful when appending to a slice inside a loop, especially when using the slice itself in the loop:
// This can lead to unexpected behavior
slice := []int{1, 2, 3}
for i, v := range slice {
slice = append(slice, v)
fmt.Println(i, v, slice)
if i >= 5 {
break
}
}
In this example, the range loop uses a copy of the slice at the start of the loop, so the appended elements are not considered in the iteration.
Modifying a Slice While Iterating
Modifying a slice while iterating over it can lead to unexpected behavior:
// This can lead to unexpected behavior
slice := []int{1, 2, 3, 4, 5}
for i := 0; i < len(slice); i++ {
if slice[i] == 3 {
// Removing an element changes the indices of subsequent elements
slice = append(slice[:i], slice[i+1:]...)
i-- // Adjust index to account for the removed element
}
}
A safer approach is to create a new slice with the elements you want to keep.
Not Checking Slice Bounds
Accessing a slice beyond its bounds will cause a panic:
slice := []int{1, 2, 3}
// This will panic
value := slice[5]
// Safer approach
if i < len(slice) {
value = slice[i]
} else {
// Handle the out-of-bounds case
}
Forgetting to Capture the Result of Append
The append function returns a new slice, which you must capture:
// Wrong: This doesn't modify the original slice
slice := []int{1, 2, 3}
append(slice, 4) // Result is discarded
fmt.Println(slice) // Output: [1 2 3]
// Correct: Capture the result
slice = append(slice, 4)
fmt.Println(slice) // Output: [1 2 3 4]
Conclusion
Slices are one of the most powerful and flexible data structures in Go. They combine the performance of arrays with dynamic sizing capabilities, making them suitable for a wide range of applications. By understanding how slices work internally and following best practices, you can write efficient and correct Go code.
In the next section, we'll explore maps, another fundamental data structure in Go that provides key-value storage with fast lookups.