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]
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:
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.
Length (
len
): The number of elements in the slice. This is accessible via the built-inlen()
function.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 thecap()
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:
A new, larger array is allocated.
All elements from the old array are copied over to the new one.
The new element is added.
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!
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!
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)
Creating Independent Copies
So, how do you create a truly independent copy of a slice that does not share its underlying array?
-
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]
-
The
copy
Function: Go has a built-incopy
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 thecopy()
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)
Really great article. Clear examples!