DEV Community

Cover image for Slices in Go: Grow Big or Go Home
Phuong Le
Phuong Le

Posted on • Originally published at victoriametrics.com

Slices in Go: Grow Big or Go Home

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)
Enter fullscreen mode Exit fullscreen mode

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.

Array's length and capacity in Go assembly code

Array's length and capacity in 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
Enter fullscreen mode Exit fullscreen mode

The situation above could be represented as the following diagram.

Slice's length and capacity

Slice's length and capacity

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 and cap 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
Enter fullscreen mode Exit fullscreen mode

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.

Slice and its underlying array

Slice and its underlying array

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Slice's capacity

Slice's capacity

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)

Collapse
 
alandelosky profile image
Alan de Losky

nice article!)