Golang Project Structure

Tips and tricks for writing and structuring Go code

Did You Know That Slices in Go Can Take Three Indices?

Language

  • unknown

by

I’m going to show you a little snippet of syntax today that even many experienced Go programmers may not be aware exists, which involves performing an operation on an array or slice with the use of three different indices.

Three cute cats sitting in a row.
Three: it’s the magic number.

We’ll look in more detail at this special syntax that’s used in a slice-indexing operation below.

Accessing a Slice With a Single Index

First, let’s do a little recap, so we understand exactly what we mean by using an index with a slice.

The example below shows how to access a value within a slice — or array — by placing a single index within square brackets after the slice’s variable name:

package main

import "fmt"

func main() {
	myFavouriteNumbers := []int{8, 64, 512, 4096, 32768}

	fmt.Printf(
		"The 3rd number in the slice is %d.\n",
		myFavouriteNumbers[2],
	)
}

This code should print "The 3rd number in the slice is 512.".

People who are new to programming could easily assume that myFavouriteNumbers[2] would access the number 64, since that’s the second number in the slice and we provided an index of two, however, it’s important to remember that slices in Go (just like dynamic arrays in other C-based programming languages) are zero-indexed.

That means that the first index is always 0. It follows, therefore, that the third index is 2, and not 3, as might naively be expected.

Likewise, the last index is always the length of the slice minus one. Since, in this case, len(myFavouriteNumbers) equals 5, the last index would be 4.

Reslicing by Using Two Indices

An array or slice can be resliced by using two indices inside square brackets, with each index separated by a colon.

The first index is the lower bound, which is included in the reslice, and the second index is the upper bound, which is excluded from the reslice.

If you prefer more mathematically inspired terminology, then you can think of it as a half-open range (or a half-closed interval, which refers to exactly the same thing).

So now let’s modify our previous example to take a reslice from the myFavouriteNumbers slice that we defined:

package main

import "fmt"

func main() {
	myFavouriteNumbers := []int{8, 64, 512, 4_096, 32_768}
	twoOfMyFavouriteNumbers := myFavouriteNumbers[1:3]
	
	for _, n := range twoOfMyFavouriteNumbers {
		fmt.Printf("Number: %d\n", n)
	}
}

You can see that running the code above will print out only two numbers, because only index one and index two are part of the reslice.

However, reslicing our original slice didn’t copy those elements: it just provides another level of abstraction on top of the original data, giving us another way to access those same two elements.

Let’s now compare the lengths and capacities of our two slices to see what we can learn from that:

package main

import "fmt"

func main() {
	myFavouriteNumbers := []int{8, 64, 512, 4_096, 32_768}
	twoOfMyFavouriteNumbers := myFavouriteNumbers[1:3]

	fmt.Printf(
		"Length: %d and %d\n",
		len(myFavouriteNumbers),
		len(twoOfMyFavouriteNumbers),
	)

	fmt.Printf(
		"Capacity: %d and %d\n",
		cap(myFavouriteNumbers),
		cap(twoOfMyFavouriteNumbers),
	)
}

We can see that the length of our reslice is two, while the length of the original slice is five, which is exactly what we might expect given that we specifically stipulated a range of two elements.

However, it’s worth noting that the capacity of the reslice is exactly the same as the original slice, except it is one less, because the reslice misses off the first element. This is because, as we’ve just mentioned, both slices point to the same underlying data.

A more practical demonstration of this could be seen if we were to append an element to our reslice: it would overwrite the third element of our original slice, even though the reslice had no way to access that element.

A Note About English Grammar

In case you hadn’t noticed already, it’s worth mentioning that the word indices is the formal plural of the word index.

This is because both the singular and plural forms of the word come ultimately from the ancient language Latin.

So we talk of having one index, but many indices.

It is also, however, perfectly acceptable to talk of indexes, if you prefer keep your English grammar simple.

Slicing With Capacity Using Three Indices

Since version 1.2 of the Go programming language was released all the way back in 2013, we have the ability to use three indices at once.

We saw above how we could reslice an existing array or slice with the use of two indices, but by adding a third index, also separated from the second index by a colon, we can set a capacity for our reslice:

package main

import "fmt"

func main() {
	myFavouriteNumbers := []int{8, 64, 512, 4_096, 32_768}
	twoOfMyFavouriteNumbers := myFavouriteNumbers[1:3:3]

	fmt.Printf(
		"Length: %d and %d\n",
		len(myFavouriteNumbers),
		len(twoOfMyFavouriteNumbers),
	)

	fmt.Printf(
		"Capacity: %d and %d\n",
		cap(myFavouriteNumbers),
		cap(twoOfMyFavouriteNumbers),
	)
}

The capacity of the reslice is now two. This may be confusing, because we set the third index to three, so it could be reasonable to think that the reslice should have a capacity of three.

However, the capacity is defined in relation to the origin of the reslice. In other words, we always need to subtract the first index from the third index (in this case, 3 - 1) in order to get the true capacity.

Only when the first index is zero will the capacity of the reslice be equal to the third index.

It is also important to note that, since they share the same underlying data, the capacity of the reslice must never be greater than the capacity of the original slice — and, of course, it always must be large enough to hold the selected number of elements in the reslice.

How to Reduce the Capacity of a Slice to its Length

We can use what we’ve learnt to get rid of any excess capacity from a slice, as shown in the example below:

package main

import (
	"fmt"
)

func main() {
	oneToTen := make([]int, 10, 1000)

	for i := 0; i < len(oneToTen); i++ {
		oneToTen[i] = i + 1
	}

	fmt.Println(oneToTen, cap(oneToTen))

	// reduce the capacity to the length
	oneToTen = oneToTen[0:len(oneToTen):len(oneToTen)]

	fmt.Println(oneToTen, cap(oneToTen))
}

We begin by initializing a slice called oneToTen, which has a length of 10 but a capacity of 1000, allowing more than ten elements to be appended to it without requiring any further memory allocations.

However, once we've added the ten numbers to the slice and printed them out, we decide that we didn't really need the extra capacity after all.

So we reslice oneToTen, using the three-index syntax that we looked at in the previous section, in order to ensure that the capacity is no greater than the length of the slice, which is 10, as we had declared initially.

The one-liner that we use to reslice the oneToTen slice can be used to reduce the capacity of any slice to its length, if we replace oneToTen with the appropriate variable name.

We can see when we print out oneToTen's capacity for the final time that it is now set to 10, which is what we want, and no longer 1000.

Reslicing With Default Indices

Finally, let's look at one last snippet of example code before we discuss what it shows us about how we can sometimes — but not always — rely on default indices when reslicing:

package main

import "fmt"

func main() {
	myFavouriteNumbers := []int{8, 64, 512, 4_096, 32_768}

	// this works, from 0 inclusive to 3 exclusive
	fmt.Println(myFavouriteNumbers[:3])

	// this works, from 2 inclusive to 5 exclusive
	fmt.Println(myFavouriteNumbers[2:])

	// this works, from 0 inclusive to 5 exclusive
	fmt.Println(myFavouriteNumbers[:])
	
	// this works, from 0 inclusive to 2 exclusive,
	// with a capacity of 2
	fmt.Println(myFavouriteNumbers[:2:2])
	
	// this does not work, because a default capacity
	// cannot be inferred by the compiler
	fmt.Println(myFavouriteNumbers[:2:])
}

As we can see in the examples above, when reslicing an array or slice with either the two-index or three-index syntax, it is possible to leave at least one of the indices out and the compiler will supply a sensible default.

In the two-index syntax, if you do not provide the first index, it will be assumed that you want to start from the first index, which is zero. Equally, if you do not provide the second index, it will be assumed that you want to go up to the length of the slice or array.

In the three-index syntax, the first index will default to zero if it is not explicitly provided. However, unlike in the two-index syntax, the second index must always be provided.

The third index in the three-index syntax must also always be provided --- since if you ever want the compiler to use a default capacity, then you should use the two-index syntax instead.

Leave a Reply

Your email address will not be published. Required fields are marked *