Good evening

I’ve been quite confused by slices’ behaviour in Go until recently. The points of concern were the expandability of a slice and the need for pointers to slices.

It all became clearer after reading this blogpost. I will simply summarize it, touching upon the said questions, and discuss the following code example.

package main

import (
	"fmt"
)

func foo(s []byte) {
	s = s[0:1]
	s[0] = 42
}

func bar(s *[]byte) {
	*s = (*s)[0:1]
	(*s)[0] = 100
}

func main() {
	s := make([]byte, 10, 20)
	fmt.Println(s)

	foo(s)
	fmt.Println(s)

	bar(&s)
	fmt.Println(s)

	s = s[:20] //valid, even though the initial length is 10
	// s = s[:21] //panic
}

The out put of this is

[0 0 0 0 0 0 0 0 0 0]
[42 0 0 0 0 0 0 0 0 0]
[100]

Pointers to slices

At first, it might seem meaningless. Slices, along with channels, maps and some other things, are reference types (according to this and this. Even though the term “reference type” is no longer in use officially, it is still used by the community). Whenever we pass a slice to a function, we can modify its contents directly; unlike arrays, it is not copied, it seems to be passed by reference.

The word “seems” is the key here. According to the aforementioned blogpost, we pass not the array as an argument, but a structure that contains a pointer to the array’s first element and the length of the slice. We don’t see that structure and cannot (or can we?) access it directly, it is conveniently abstracted away by Go’s sugar.

Structures are passed by value; if we modify something in it inside the function, the original remains the same. Since the size is located in that hidden structure, foo() does not alter it because the structure is passed by value. Yet, the first element is now 42 because it takes the pointer to the underlying array and modifies the element in it. That’s why s in foo() seems to behave somewhat like both a value and a reference.

So, if we want to shrink or expand a slice inside another function, we have to pass it as a pointer like we did in bar(). And yes we have to write (*s) as shown there, because element access and reslicing operations have higher priorities compared to pointer dereference (I couldn’t find any literature on that, it’s from experience).

Slice expansion

How far can we expand a slice though? Basically, until we have enough space in the underlying array. In other words, capacity determines it. We set the capacity to 20 when we created the slice, so we can make it as big as 20 without problems. If we ever need to grow beyond that, we can make a bigger slice and copy elements from the old one into it and then reassign the new slice to the old one. Or we could use append to add elements and let it handle reallocation automatically.

Be careful with the starting position

Reslicing from the right as we’ve done above is not a problem, as long as we are within capacity. Doing it from the left is a different story though. If you do something like s[2:], then, unless you preserve the original array elsewhere, you lose access to the data that is to the left of your new start (to the first two elements of the original slice in this case). And no, s[-1:] is not allowed. Moreover, the capacity will instantly go down.

func main() {
	s := make([]byte, 10, 20)
	fmt.Println("len", len(s), "cap", cap(s)) //len 10 cap 20

	s = s[5:]
	fmt.Println("len", len(s), "cap", cap(s)) //len 5 cap 15
}

If we assume that the “hidden structure” we discussed earlier holds the pointer to the first element of the underlying array, then the address in that pointer is simply moved forward and the memory that now stands in front of it is deallocated (maybe it isn’t deallocated internally, but we lose access to it in any case). More info.

In conclusion

Slices can be confusing and even inconsistent. And there is much more to them than discussed here, so I recommend reading the original blogpost as well. They are a powerful tool once you get experienced with them.

Thanks for tuning in!