Golang Slices

Golang Slices

Ain't no Data Structure

Hey there! Let's talk about slices in-depth. You may want to check out arrays first, if in case, here's the link to Golang Arrays.

Slice

In Golang, Slice is just an array data structure but with dynamic size i.e, it can grow its size when needed. At least, that's what I thought of in the beginning. But it's a little bit different.

Let's see the attributes of a slice first. A pointer referencing the first element, length and capacity of the slice.

  • Pointer refers to the first element

  • length, the number of elements

  • Capacity, the maximum number of elements it can hold

package main
import "fmt"

func main(){
    arr := []int{10, 20, 30}
    fmt.Println(arr)     // prints [10 20]
    fmt.Println("lenght =", len(arr)) // prints Length = 3
    fmt.Println("Capacity =", cap(arr)) // prints Capacity = 3
}

The declaration arr := []{10, 20, 30} would look as follows.

As you can see, the pointer is pointing to the address 200 of the first element 10, and as we used literal declaration, both the length and capacity are the same, 3.

In the beginning, I mentioned slice as just an array data structure but with dynamic size; that's just for naive understanding. Actually, slice is not a data structure of its own.

According to go.dev docs, A slice is a descriptor of an array segment, meaning it only references some region of the array.

Slice, a hidden array

When we declare an array without mentioning the size, it becomes a slice.

What happens here is, the slice that we declared would actually create an array internally and points the slice to that hidden array. This array can't be seen by a programmer. And that's the reason why the type of slice returns us []T, an array of type T.

package main
import "fmt"

func main(){
   arr := []int{10, 20, 30}
   fmt.Printf("Type of arr is %T", arr) // prints Type of arr is []int
}

As you can see, when declared arr := []{10,20,30}, a hidden array is created, and our slice arr is pointing to that array (apologies for bad naming conventions).

The flexibility of using slice over an array is, we don't need to worry about the size.

You might be things, if it's just an array under the hood, how it resizes dynamically? Check the Dynamic arrays part down the page if too curious.

Slicing

As you know slicing is the technique of getting a segment of an array. If we slice an array, we're basically referring to a region of that original array.

package main
import "fmt"

func main(){
    arr := [5]int{1,2,3,4,5}  // an array of size 5
    slice := arr[1:3]        
    fmt.Println(slice)       // prints [2 3]
    fmt.Println(len(slice))  // prints 2 
    fmt.Println(cap(slice))  // prints 4
}

If you can recall the attributes of slice, the pointer is also known as slice header. In the above code snippet, the slice header is pointing to the address of the element 2 of original array arr.

A caveat here is, if we try to change the elements of slice sliced from the array arr, the modifications will also reflect on the original array too, because, that's how pointers work.

package main
import "fmt"

func main(){
    arr := [5]int{1,2,3,4,5}     // an array of size 5
    slice := arr[1:3]    
    fmt.Println("Before =", arr) // [1 2 3 4 5]
    slice[0] = 121               // changing first element of slice  
    fmt.Println("After =", arr)  // [1 121 3 4 5]
}

Changing the first element of slice which is 2 to 121 also modified the original array's arr second element 2 to 121.

We can also slice a slice, which would just change the slice header to a new reference and updates the length and capacity attributes accordingly. Try it out by adding this line slice = slice[1:]to above code. Fine, here's how it would look

Initializing slice

So far we've seen literal declaration. We can also use a built-in function known as make(). Make function creates dynamically-sized arrays. Make takes the following arguments, type, length and capacity.

make(type, len, cap)

package main
import "fmt"

func main(){
    arr := make([]int, 5, 10)
    fmt.Println(arr)          // prints [0 0 0 0 0]
}

The above declaration means, make []int with size/length 5 and with a capacity of 10. And the reason for all zeros is, make initializes the array. As no values are provided, the default value for int which is 0, is initialized.

  • We can discard the capacity argument if length and capacity needed to be same, by declaring like this arr := make([]int, 10)

  • We can even discard the length argument if initializing map type.

Here's a twist;

package main
import "fmt"

func main(){
    arr := make([]int, 5, 10)
    fmt.Println(arr)   // prints [0 0 0 0 0]
    arr[7] = 20        // raises index out of range error    
}

The above code when executes raises an error. You might be wondering, though we are trying to assign to an index 7 which is under the capacity is 10, why the error?

make only initializes the hidden array with a given length, the capacity is kinda like reserved for further initialization but not initialized.

Then what's the point of having capacity set if it can't be used, you may ask.

package main
import "fmt"

func main(){
    arr := make([]int, 5, 10)

    // this line sets the length to 8 now
    arr = arr[0: 7+1]  // initializes the arr with specified range
    fmt.Println(arr)   // prints [0 0 0 0 0 0 0 0]
    arr[7] = 20
    fmt.Println(arr)   // prints [0 0 0 0 0 0 0 20]
}

Actually, we don't need to bother about capacity. To have slice grow its size, we just need to use append function.

Dynamic Arrays

By now you might've gotten a gist of slices. But the secret to the dynamic size of slice lies in the built-in function append. It is used to add elements at the end of a slice.

package main
import "fmt"

func main(){
    arr := []int{10, 20}
    fmt.Println(arr)       // prints [10 20]
    fmt.Printf("Len is %v & Cap is %v \n", len(arr), cap(arr))
    // prints Len is 2 & Cap is 2 

    arr = append(arr, 30)  // appends 30 to arr
    fmt.Println(arr)      // prints [10 20 30]

    // prints Len is 3 & Cap is 3
    fmt.Printf("Len is %v & Cap is %v \n", len(arr), cap(arr))
}
  • append() takes the slice and the element to add, and returns the slice with the element appended to it.

Now, let's see how we could achieve the append functionality by ourselves.

package main 
import "fmt"

func addElement(slice []int, ele int) []int {
    length := len(slice)
    if length == cap(slice) {
        // slice is already full so create a new one
        newSlice := make([]int, length, 2*length)
        // copy all the elements to new slice
        for i, _ := range slice {
            newSlice[i] = slice[i]    
        }
        // now change the slice header to new slice
        slice = newSlice
    }

    // not full, so assign the new element
    slice = slice[0:length+1]  
    slice[length] = ele
    return slice 
}
func main(){
    // slice with 2 elements, the len and cap will be 2 
    arr := []int{10, 20}   
    arr = addElement(arr, 30)
    fmt.Println(arr)    // [10 20 30]
}

In the code above, the function addElement() is first checking if the total capacity is full, if not just assign the element and return the slice. If capacity is full, create a slice with double the size of the original slice(to accommodate more elements) and copy all the elements to the new slice, and change the slice header of slice to newSlice we created. Now slice will be pointing to a new array with new capacity.

This is what happens under the hood for append function. And this is how dynamic sizes are achieved.

Summary

To summarize what we've learnt, the following points will provide a glance

  • Slice is not a data structure of its own.

  • When we create a slice, under the hood an array is created hidden from programmers.

  • When we slice an array, effectively we are referencing that array, changing the slice content will also reflect on the actual array.

  • We use the make() function to create dynamic arrays.

  • append() function is used to add elements to slice.