DEV Community

Cover image for Go's Data Structures: A Deep Dive into Arrays and Slices
Mohammad Aman
Mohammad Aman

Posted on

Go's Data Structures: A Deep Dive into Arrays and Slices

In the world of Go, arrays and slices are fundamental data structures used for managing ordered collections of data. While they might seem similar at a glance, their underlying mechanics are profoundly different. An array is a simple, fixed-size container, while a slice is a powerful, flexible, and dynamic tool. Understanding the distinction, especially the internal workings of a slice, is crucial for any Go developer aiming to write efficient and bug-free code.

This article will break down these two types, explore the hidden machinery that powers slices, and demonstrate how to use them effectively to avoid common pitfalls.

The Foundation: Arrays as Value Types

First, let's clarify what an array is. An array in Go is a numbered sequence of elements of a single, specific type with a fixed length. The size of the array is part of its type definition, meaning [4]int and [5]int are two completely different and incompatible types.

The most important characteristic of an array is that it is a value type. Like an int or a string, when you assign an array to a new variable or pass it to a function, the entire array is copied.

Consider this example:

// a1 is an array of 4 integers
a1 := [4]int{10, 20, 30, 40}

// When we assign a1 to a2, a complete copy is made
a2 := a1 

// Changing the value in a2 does NOT affect a1
a2[1] = 25 

fmt.Println(a1) // Output: [10 20 30 40]
fmt.Println(a2) // Output: [10 25 30 40]

Enter fullscreen mode Exit fullscreen mode

Because a2 is a separate copy, the change made to it is isolated. This predictable behavior is useful, but the fixed size and the cost of copying make arrays less flexible for many common programming tasks. This is where slices come in.

Slices: The Dynamic and Powerful View

A slice is the more common, flexible, and idiomatic way to handle ordered lists in Go. Unlike an array, a slice does not have a fixed size. It is a dynamically-sized, flexible view into the elements of an underlying array.

The key to avoiding bugs is to burn this fact into your memory: a slice doesn't directly store data. Instead, it's a small descriptor object that describes a portion of an underlying array.

The Three Hidden Properties of a Slice

A slice is a lightweight data structure, or header, that describes a contiguous section of an underlying array. This header contains three pieces of information:

  1. Pointer to the starting point: This doesn't always point to the first element of the underlying array, but rather to the specific element where the slice's view begins.

  2. Length (len): The number of elements in the slice. This is accessible via the built-in len() function.

  3. Capacity (cap): The maximum number of elements the slice can hold without needing to reallocate a new underlying array. It's measured from the start of the slice to the end of the underlying array. You can find this with the cap() function.

Think of the underlying array as a long bookshelf. A slice is like a pair of bookends that you place on that shelf. The length is the number of books between the bookends, and the capacity is the number of books from the first bookend to the very end of the shelf. You can move the bookends to create different views (slices) of the same bookshelf (underlying array).

Growth and the append Function

The dynamic nature of slices comes from the append function. When you append an element, one of two things happens, depending on the slice's capacity:

  • Case 1: There is enough capacity (len < cap). If the underlying array has spare room, the new element is placed in the next available slot. The slice's pointer doesn't change, but its length is incremented.

  • Case 2: The capacity is full (len == cap). If the underlying array is full, Go performs a crucial, behind-the-scenes operation:

  1. A new, larger array is allocated.

  2. All elements from the old array are copied over to the new one.

  3. The new element is added.

  4. The slice's pointer is updated to point to this new array, and its length and capacity are updated accordingly.

This automatic reallocation is what makes slices so convenient, but it's also the source of many subtle bugs.

The Dangers of Shared Underlying Arrays

Because multiple slices can point to the same underlying array, a change made through one slice can be visible in another.

Let's look at the linked example from the book:

s1 := []int{1, 2, 3, 4, 5}
s2 := s1 // s2 now points to the SAME underlying array as s1

s1[3] = 99 // This modifies the shared underlying array

fmt.Println(s1) // Output: [1 2 3 99 5]
fmt.Println(s2) // Output: [1 2 3 99 5]  <-- s2 sees the change!

Enter fullscreen mode Exit fullscreen mode

Here, s1 and s2 are two distinct slice headers, but they both point to the same memory.

Now, let's see how append can break this link:

s1 := []int{1, 2, 3, 4, 5} // len=5, cap=5
s2 := s1

// Appending to s1 exceeds its capacity.
// Go creates a NEW underlying array for s1.
s1 = append(s1, 6) 

// s2 still points to the OLD array. The link is broken.
s1[3] = 99

fmt.Println(s1) // Output: [1 2 3 99 5 6]
fmt.Println(s2) // Output: [1 2 3 4 5] <-- s2 is unaffected!

Enter fullscreen mode Exit fullscreen mode

This behavior is logical once you understand the mechanics, but it can be surprising if you mistakenly believe s2 := s1 creates an independent copy.

Controlling Slices: make and True Copies

Pre-allocating with make

If you know roughly how many elements a slice will hold, you can create it with a specific length and capacity using the make function. This is a performance optimization. By pre-allocating a large enough underlying array, you prevent Go from having to perform multiple costly reallocations and copies as you append elements.

The syntax is make([]T, length, capacity).

// A slice with length 0, but capacity for 10 elements.
// The underlying array has 10 slots ready.
s1 := make([]int, 0, 10)

// A slice with length 10 and capacity 50.
s3 := make([]int, 10, 50)

Enter fullscreen mode Exit fullscreen mode

Creating Independent Copies

So, how do you create a truly independent copy of a slice that does not share its underlying array?

  1. The append Idiom (Most Common): This is the most common and readable way to create a safe copy. You append the source slice to a new, empty slice. This forces an allocation of a new underlying array.

    s1 := []int{1, 2, 3, 4, 5}
    s2 := append([]int{}, s1...) // The "..." unpacks s1 into individual arguments
    
    s1[3] = 99 // This will NOT affect s2
    fmt.Println(s1) // Output: [1 2 3 99 5]
    fmt.Println(s2) // Output: [1 2 3 4 5]
    
    
  2. The copy Function: Go has a built-in copy function. Its main caveat is that it will only copy as many elements as the destination slice has length. You must ensure the destination slice is properly sized beforehand.

    s1 := []int{1, 2, 3, 4, 5}
    s2 := make([]int, len(s1)) // Create s2 with the correct length
    
    numCopied := copy(s2, s1) // Perform the copy
    
    s1[3] = 99 // This will NOT affect s2
    fmt.Println(s1) // Output: [1 2 3 99 5]
    fmt.Println(s2) // Output: [1 2 3 4 5]
    fmt.Println("Elements copied:", numCopied) // Output: 5
    
    

Conclusion: Key Takeaways

  • Arrays are fixed-size and are value types. A copy of an array is a completely new, independent instance.

  • Slices are dynamic, flexible headers that provide a view into an underlying array.

  • A slice header contains a pointer, a length, and a capacity.

  • Multiple slices can share the same underlying array. Modifying the data through one slice can affect others.

  • The append function will create a new underlying array if the current capacity is exceeded, which can break the link between two slices that previously shared data.

  • Use make([]T, len, cap) to pre-allocate memory for performance gains.

  • To create a truly independent copy of a slice, use the s2 := append([]int{}, s1...) idiom or the copy() function.

While slices add a layer of complexity compared to simple arrays, their power and flexibility are indispensable in Go. By internalizing the concepts of the pointer, length, capacity, and the behavior of append, you can leverage slices effectively and avoid the subtle, hard-to-find bugs they can otherwise cause.

Top comments (1)

Collapse
 
wancat profile image
Justin Lin

Really great article. Clear examples!