Go Data Structures & Algorithms

code

Slices in Go

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
}
  • Data is a pointer to the underlying array.
  • Len is the length of the slice (number of elements it contains).
  • Cap is 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.