In the Go progamming language, slices are one of the most basic and fundamental data structures. And yet, slices often exhibit strange behaviors: when a slice is passed as argument to a function, some – but not all – of its data can be modified by the function. In this article, we aim for an in-depth understanding of slices and their behavior by looking at how they are implemented under the hood and how the memory behaves when performing common slice operations.
What are slices? A bird’s view
At first glance, slices are Go’s equivalent for the vector
type in C++ or ArrayList
in Java. A slice is a dynamic array: it’s a container that stores a sequence of elements indexed by integers starting from 0
. The elements are stored contiguously in memory. Unlike Go arrays, slices can be resized. In the following code snippet, we illustrate common operations on slices:
|
|
How are slices implemented?
Under the hood, slices are implemented as a struct
with three variables: a pointer to the data array, the length of the slice and the capacity of the slice. In this section, we will use valid Go code to shed light on this hidden implementation.
For instance, the following diagram illustrates the memory layout of a slice s
with length 5
and capacity 7
. The elements of s
are {1, 2, 3, 4, 5}
, they are stored contiguously at memory location 0xe830
.
Because the slice object only contains a pointer to the data array and not the array itself, the actual size of the array does not impact the size of the slice object. For instance, in the code below we create two slices, one with 0 elements and one with 5 elements, but Sizeof
shows that the size of the slice objects is 24 bytes either way.
|
|
What happens when an element is added to a slice?
Since the elements of a slice are stored contiguously in memory, it can happen that there is not enough space at the end of the slice to add a new element. In this case, a new memory location with more space is allocated to the slice and the elements are copied to the new location. Go keeps track of how much space is reserved for the slice through the capacity
value and will roughly double the capacity every time the slice is reallocated.
The slideshow below illustrates what happens in memory when we append elements to a slice. Use the buttons to move to the next codeline.
Revealing the underlying struct implementation
We can use pointer tricks to force the compiler to reveal this hidden struct by reinterpreting the memory address as a struct pointer. This is what the reinterpretAsStruct
function does in the code below:
|
|
Since the array pointers are the same, every modification in the struct1.array
is automatically reflected in the slice s
:
|
|
Passing a slice as argument to a function
When a slice is passed as argument to a function, the underlying struct
object is passed by value to the function, which means that the function receives a copy of the struct object.
The diagram below illustrates what happens when passing a slice s
to the function giveMeSlice
:
|
|
Even though the function only receives a copy of the array pointer, it can still access the original elements by following the pointer. This explains why a function can modify the elements of a slice.
But when the function resizes a slice, then only its own copy of the length and capacity variables are updated and this update is not propagated to the original slice object.
The code below illustrates these considerations:
|
|