This is an excerpt of the post; the full post is available here: https://victoriametrics.com/blog/go-slice/
New developers often think slices are pretty simple to get, just a dynamic array that can change size compared to a regular array. But honestly, it's trickier than it seems when it comes to how they change size.
So, let's say we have a slice variable a
, and you assign it to another variable b
. Now, both a
and b
are pointing to the same underlying array. If you make any changes to the slice a
, you're gonna see those changes reflected in b
too.
But that's not always the case.
The link between a
and b
isn't all that strong, and in Go, you can't count on every change in a
showing up in b
.
Experienced Go developers think of a slice as a pointer to an array, but here's the catch: that pointer can change without notice, which makes slices tricky if you don't fully understand how they work. In this discussion, we'll cover everything from the basics to how slices grow and how they're allocated in memory.
Before we get into the details, I'd suggest checking out how arrays work first.
How Slice is Structured
Once you declare an array with a specific length, that length is "locked" in as part of its type. For example, an array of [1024]byte
is a completely different type from an array of [512]byte
.
Now, slices are way more flexible than arrays since they're basically a layer on top of an array. They can resize dynamically, and you can use append()
to add more elements.
There are quite a few ways you can create a slice:
// a is a nil slice
var a []byte
// slice literal
b := []byte{1, 2, 3}
// slice from an array
c := b[1:3]
// slice with make
d := make([]byte, 1, 3)
// slice with new
e := *new([]byte)
That last one isn't really common, but it's legit syntax.
Unlike arrays, where len
and cap
are constants and always equal, slices are different. In arrays, the Go compiler knows the length and capacity ahead of time and even bakes that into the Go assembly code.
But with slices, len
and cap
are dynamic, meaning they can change at runtime.
Slices are really just a way to describe a 'slice' of the underlying array.
For example, if you have a slice like [1:3], it starts at index 1 and ends just before index 3, so the length is 3 - 1 = 2.
func main() {
array := [6]int{0, 1, 2, 3, 4, 5}
slice := array[1:3]
fmt.Println(slice, len(slice), cap(slice))
}
// Output:
// [1 2] 2 5
The situation above could be represented as the following diagram.
The len
of a slice is simply how many elements are in it. In this case, we have 2 elements [1, 2]
. The cap
is basically the number of elements from the start of the slice to the end of the underlying array.
That definition of capacity above is a bit inaccurate, we will talk about it in growing section.
Since a slice points to the underlying array, any changes you make to the slice will also change the underlying array.
"I know the length and capacity of a slice through the
len
andcap
functions, but how do I figure out where the slice actually starts?"
Let me show you 3 ways to find the start of a slice by looking in its internal representation.
Instead of using fmt.Println
, you can use println
to get the raw values of the slice:
func main() {
array := [6]byte{0, 1, 2, 3, 4, 5}
slice := array[1:3]
println("array:", &array)
println("slice:", slice, len(slice), cap(slice))
}
// Output:
// array: 0x1400004e6f2
// slice: [2/5]0x1400004e6f3 2 5
From that output, you can see that the address of the slice's underlying array is different from the address of the original array, that's weird, right?
Let's visualize this in the diagram below.
If you've checked out the earlier post on arrays, you'll get how elements are stored in an array. What's really happening is that the slice is pointing directly to array[1]
.
The second way to prove it is by getting the pointer to the slice's underlying array using unsafe.SliceData
:
func main() {
array := [6]byte{0, 1, 2, 3, 4, 5}
slice := array[1:3]
arrPtr := unsafe.SliceData(slice)
println("array[1]:", &array[1])
println("slice.array:", arrPtr)
}
// Output:
// array[1]: 0x1400004e6f3
// slice.array: 0x1400004e6f3
When you pass a slice to unsafe.SliceData, it does a few checks to figure out what to return:
- If the slice has a capacity greater than 0, the function returns a pointer to the first element of the slice (which is
array[1]
in this case). - If the slice is nil, the function just returns nil.
- If the slice isn't nil but has zero capacity (an empty slice), the function gives you a pointer, but it's pointing to "unspecified memory address".
You can find all of this documented in the Go documentation.
"What do you mean by 'This pointer is pointing to an unspecified memory address'?"
It's a bit out of context, but let's satisfy our curiosity :)
In Go, you can have types with zero size, like struct{}
or [0]int
. When the Go runtime allocates memory for these types, instead of giving each one a unique memory address, it just returns the address of a special variable called zerobase
.
You're probably getting the idea, right?
The 'unspecified' memory we mentioned earlier is this zerobase address.
func main() {
var a struct{}
fmt.Printf("struct{}: %p\n", &a)
var b [0]int
fmt.Printf("[0]int: %p\n", &b)
fmt.Println("unsafe.SliceData([]int{}):", unsafe.SliceData([]int{}))
}
// Output:
// struct{}: 0x104f24900
// [0]int: 0x104f24900
// unsafe.SliceData([]int{}): 0x104f24900
Pretty cool, right? It's like we just uncovered a little mystery that Go was keeping under wraps.
Let's move on to the third way.
Behind the scenes, a slice is just a struct with three fields: array
- a pointer to the underlying array, len
- the length of the slice, and cap
- the capacity of the slice.
type slice struct {
array unsafe.Pointer
len int
cap int
}
This is also the setup for our third way to figure out the start of a slice. While we're at it, we'll prove that the struct above is indeed how slices work internally.
type sliceHeader struct {
array unsafe.Pointer
len int
cap int
}
func main() {
array := [6]byte{0, 1, 2, 3, 4, 5}
slice := array[1:3]
println("slice", slice)
header := (*sliceHeader)(unsafe.Pointer(&slice))
println("sliceHeader:", header.array, header.len, header.cap)
}
// Output:
// slice [2/5]0x1400004e6f3
// sliceHeader: 0x1400004e6f3 2 5
The output is exactly what we expect and we usually refer to this internal structure as the slice header (sliceHeader
). There's also a reflect.SliceHeader
in the reflect
package, but that's deprecated.
Now that we've mastered how slices are structured, it's time to dive into how they actually behave.
How Slice Grows
Earlier, I mentioned that "the cap is basically the length of the underlying array starting from the first element of the slice up to the end of that array." That's not entirely accurate, it's only true in that specific example.
For instance, when you create a new slice using slicing operations, there's an option to specify the capacity of the slice:
func main() {
array := [6]int{0, 1, 2, 3, 4, 5}
slice := array[1:3:4]
println(slice)
}
// Output:
// [2/3]0x1400004e718
By default, if you don't specify the third parameter in the slicing operation, the capacity is taken from the sliced slice or the length of the sliced array.
In this example, the capacity of slice is set to go up to index 4 (exclusive, like the length) of the original array.
So, let's redefine what the capacity of a slice really means.
"The capacity of a slice is the maximum number of elements it can hold before it needs to grow."
If you keep adding elements to a slice and it surpasses its current capacity, Go will automatically create a larger array, copy the elements over, and use that new array as the slice's underlying array.
The full post is available here: https://victoriametrics.com/blog/go-slice/
Top comments (1)
nice article!)