We saw in the previous chapter how to combine some fields of different types in a container called a struct to create a new type that we can use in our functions and programs. We also saw how to gather n objects of the same type into a single array that we can pass as a single value to a function.
This is fine, but do you remember :ref:`the note we wrote about arrays<arrays-note>`? Yes, that one about the fact that they don't grow or shrink, that an array of 10 elements of a given type is totally different of an array of, say, 9 elements of the same type.
This kind of inflexibility about arrays leads us to this chapter's subject.
You'd love to have arrays without specifying sizes, right? Well, that is possible in a manner of speaking, we call 'dynamic arrays' in Go slices.
A slice is not actually a 'dynamic array' but rather a reference (think pointer) to a sub-array of a given array (anywhere from empty to all of it). You can declare a slice just like you declare an array, omitting the size.
As we see in the above snippet, we can declare a slice literal just like an array literal, except that we leave out the size number.
We can create slices by slicing an array or even an existing slice. That is taking a portion (a 'slice') of it, using this syntax array[i:j]. Our created slice will contain elements of the array that start at index i and end before j. (array[j] not included in the slice)
- When slicing, first index defaults to 0, thus ar[:n] means the same as ar[0:n].
- Second index defaults to len(array/slice), thus ar[n:] means the same as ar[n:len(ar)].
- To create a slice from an entire array, we use ar[:] which means the same as ar[0:len(ar)] due to the defaults mentioned above.
Ok, let's go over some more examples to make this even clearer. Read slowly and pause to think about each line so that you fully grasp the concepts.
Does it make more sense now? Fine!
Is that all there is? What's so interesting about slices?
Lets show you more examples below for the yummy sliced fruity goodness.
Say we need to find the biggest value in an array of 10 elements. Good, we know how to do it. Now, imagine that our program has the task of finding the biggest value in many arrays of different sizes! Aha! See? One function is not enough for that, because, remember, the types: [n]int and [m]int are different! They can not be used as an input of a single function.
Slices fix this quite easily! In fact, we need only write a function that accepts a slice of integers as an input parameter, and create slices of arrays.
Let's see the code.
You see? Using a slice as the input parameter of our function made it reusable and we didn't had to rewrite the same function for arrays of different sizes. So remember this: whenever you think of writing a function that takes an array as its input, think again, and use a slice instead.
Let's see other advantages of slices.
We stated earlier that slices are references to some underlying array (or another slice). What exactly does that mean? It means that slicing an array or another slice doesn't simply copy some of the array's elements into the new slice, it instead make the new slice point to the element of this sliced array similar to the way pointers operate.
In other words: changing an element's value in a slice will actually change the value of the element in the underlying array to which the slice points. This changed value will be visible in all slices and slices of slices containing the array element.
Let's see an example:
Let's talk about the PrintByteSlice function a little bit. If you analyze its code, you'll see that it loops until before len(slice)-1 to print the element slice[i] followed by a comma. At the end of the loop, it prints slice[len(slice)-1] (the last item) with a closing bracket instead of a comma.
PrintByteSlice works with slices of bytes, of any size, and can be used to print arrays content also, by using a slice that contains all of its elements as an input parameter. This proves the utility of the advice before: always think of slices when you're about to write functions for arrays.
Until now, we've learned that slices are truly useful as input parameters for functions which expect blocks of a variable size of consecutive elements of the same type. We have learned also that slices are references to portions of an array or another slice.
That is not all. Here's an awesome feature of slices.
One of our concerns with arrays is that they're inflexible. Once you declare an array, its size can't change. It won't shrink nor grow. How unfortunate!
Slices can grow or shrink.
How so? Conceptually, a slice is a struct of three fields:
- A pointer to the first element of the array where the slice begins.
- A length which is an int representing the total number of elements in the slice.
- A capacity which is an int representing the number of available elements.
Schematically, since you love my diagrams.
The picture above is a representation of a slice that starts from the 5th element of the array. It contains exactly 4 elements (its length) and can contain up to 6 elements on the same underlying array (its capacity) starting from the first element of the slice.
Simply said: Slicing does not make a copy of the elements, it just creates a new structure holding a different pointer, length, and capacity.
To make things even easier, Go comes with a built-in function called make, which has the signature: func make(T, len, cap) T.
Where T stands for the element type of the slice to be created. The make function takes a type, a length, and an optional capacity. When called, make allocates an array and returns a slice that refers to that array.
The optional capacity parameter, when omitted, defaults to the specified length. These parameters can be inspected for a given slice, using the built-in functions: - len(slice) which returns a slice's length, and - cap(slice) which returns a slice's capacity.
The zero value of a slice is nil. The len and cap functions will both return 0 for a nil slice.
We may find ourselves wondering "Why use make instead of new that we discussed when we studied :ref:`pointers<function-new>`?"
Let's discuss the reason why we use make and not new to create a new slice:
Recall that new(T) allocates memory to store a variable of type T and returns a pointer to it. Thus new(T) returns *T, right?
So for example if we had used new(int) to create a slice of integers, new will allocate the internal structure discussed above and that will consist of a pointer, and two integers (length and capacity). new will then return us a pointer of the type *int -- which... let's face it... is not what we need!
What we do need is a function which:
- Returns the actual structure not a pointer to it. i.e. returns int not *int.
- Allocates the underlying array that will hold the slice's elements.
Do you see the difference? make doesn't merely allocate enough space to host the structure of a given slice (the pointer, the length and the capacity) only, it allocates both the structure and the underlying array.
Back to our resizability of slices. As you may guess, shrinking a slice is really easy! All we have to do is reslice it and assign the new slice to it.
Now, let's see how -- using only what we have learned until now -- we grow a slice. A very simple, straight forward way of growing a slice might be:
- make a new slice
- copy the elements of our slice to the one we created in 1
- make our slice refer to the slice created in 1
Let's discuss this program a little bit.
First, the PrintIntSlice function is similar to the PrintByteSlice function we saw earlier. Except that we improved it to support nil slices.
Then the most important one is GrowIntSlice which takes two input parameters: a int slice and an int add that represents the amount of elements to add to the capacity.
GrowIntSlice first makes a new slice with a new_capacity that is equal the original slice's capacity plus the add parameter.
Then it copies elements from slice to new_slice in the highlighted loop in lines 16, 17 and 18.
And finally, it returns the new_slice as an output.
The approach you may be used to or that first comes to mind is often implemented as a built-in Go function provided for you already. This is most definitely one such example. There is a built-in Go function called copy which takes two arguments: source and destination, and copies elements from the source to the destination and returns the number of elements copied.
The signature of copy is: func copy(dst, src T) int
So we can replace the lines 16, 17, 18 with this very simple one:
You know what? Let's write another example and use the copy function in it.
Appending to a slice is a very common operation and as such Go has a built-in function called append that we will see in details very soon.
Phew, that was a long chapter, eh? We'll stop here for now, go out, have a nice day, and see you tomorrow for another interesting composite type. If you are completely enthralled thus far by this exquisite book then do yourself a favor before continuing and go get a tea or coffee and take a nice 10 minute break.