Comprehending Arrays and Slices in the Go Programming Language

Relia Software

Relia Software

Huy Nguyen

Relia Software

development

An Array is a fixed-size, ordered collection of elements of the same data type. A Slice is a dynamic and flexible view on an underlying array. Read more here!

Comprehending Arrays and Slices in the Go Programming Language

Table of Contents

Arrays and Slices is fundamental to working effectively in the Go programming language. They are key data structures used for organizing and manipulating collections of data. In this explanation, I'll describe what arrays and slices are, how they differ, and when to use one over the other.

>> Read more: Detailed Code Examples of Dependency Inversion Principle in Go

Understanding Golang Arrays

Definition

An array is a fixed-size, ordered collection of elements of the same data type. The size of an array is determined at the time of declaration, and it cannot be changed during the program's execution.

Declaration

In Go, you declare an array like this:

var arr [5]int

This creates an integer array of size 5.

Access

You can access elements of an array using index notation, starting at 0. For example:

arr[0] = 1
element := arr[2]

Size: The size of an array is fixed and cannot be changed.

Understanding Golang Slices

Definition

A slice is a dynamic and flexible view on an underlying array. Slices are like references to an array, and they can grow or shrink as needed. They are more versatile than arrays.

Declaration

Slices are often created by slicing an existing array, or you can create them directly using the make function. For example:

var slice []int        // A slice with no specific length or underlying array.
slice := make([]int, 5) // A slice with a specific length.

Access

Slices are accessed in the same way as arrays, using index notation. Slices can also be sliced further, and you can append new elements to them.

Size

Slices have a dynamic size, and they can grow or shrink by creating a new slice based on the original slice.

>> Read more about the Go programming language:

Comparison Between Arrays and Slices

Here's a comparison of slices and arrays in a tabular format:

AspectSliceArray
Dynamic SizingYes, dynamic and can grow or shrink.No, fixed size at declaration.
Capacity Growth StrategyDoubles capacity as needed.Fixed size; cannot change.
Efficient AppendsYes, amortized O(1) time complexity.No appends; fixed size.
Bounds CheckingBuilt-in bounds checking for safety.No bounds checking; may result in errors.
ResizingAuto-managed by Go's runtime.Not applicable; fixed size.
Pass by ValuePasses a reference (pointer).Passes a copy of the data.
Copying DataCopy-on-write (only if necessary).Not applicable; fixed size.
Initial Declarationmake([]T, len, cap) or slicing.[len]T.
LengthDynamic and changes with append operations.Fixed and set at declaration.
Common Use CasesDynamic collections, slices of arrays.Fixed-size data, more memory-efficient.

Dynamic Size: Arrays have a fixed size, while slices are dynamic and can grow or shrink.

Underlying Array: Slices are built on top of arrays. They reference an underlying array. If you modify a slice, it can affect the array it's based on. Arrays do not have an underlying array concept.

Passing by Reference: Slices are reference types, so when you pass a slice to a function, you're passing a reference to the underlying data. Arrays, on the other hand, are passed by value, which means the entire array is copied when passed to a function.

Flexibility: Slices are more flexible and versatile for most use cases because of their dynamic nature. Arrays are mainly used when you need a fixed-size collection of elements.

>> Read more: Tips For Hiring The Best Golang Developers

In Go, arrays have a fixed size, and their capacity doesn't grow or change. Slices, on the other hand, have dynamic sizing and can grow as elements are appended to them. To compare the behavior of arrays and slices, you can create an array and a slice and observe how they behave differently. Here's an example:

package main

import (
	"fmt"
)

func main() {
	// Create an array with a fixed size of 5
	array := [5]int{1, 2, 3, 4, 5}

	// Create a slice with an initial length of 5 and capacity of 5
	slice := make([]int, 5)

	// Function to print the array and slice properties
	printArrayAndSlice := func(arr [5]int, slc []int) {
		fmt.Printf("Array: %v, Length: %d\\n", arr, len(arr))
		fmt.Printf("Slice: %v, Length: %d, Capacity: %d\\n", slc, len(slc), cap(slc))
	}

	// Print the initial state of the array and slice
	printArrayAndSlice(array, slice)

	// Try to append to the array (which is not possible)
	// Uncommenting this line will result in a compilation error:
	// array[5] = 6

	// Append elements to the slice
	for i := 6; i <= 10; i++ {
		slice = append(slice, i)
		printArrayAndSlice(array, slice)
	}
}
Array: [1 2 3 4 5], Length: 5
Slice: [0 0 0 0 0], Length: 5, Capacity: 5
Array: [1 2 3 4 5], Length: 5
Slice: [0 0 0 0 0 6], Length: 6, Capacity: 10
Array: [1 2 3 4 5], Length: 5
Slice: [0 0 0 0 0 6 7], Length: 7, Capacity: 10
Array: [1 2 3 4 5], Length: 5
Slice: [0 0 0 0 0 6 7 8], Length: 8, Capacity: 10
Array: [1 2 3 4 5], Length: 5
Slice: [0 0 0 0 0 6 7 8 9], Length: 9, Capacity: 10
Array: [1 2 3 4 5], Length: 5
Slice: [0 0 0 0 0 6 7 8 9 10], Length: 10, Capacity: 10

Go's slice resizing strategy involves the following key aspects:

Initial Capacity: When you create a slice using the make function or by slicing an existing array, it's assigned an initial capacity. The capacity is determined by the size of the underlying array. The initial capacity of the slice may be greater than or equal to its length, but it can never be less than the length.

Doubling Capacity: When you append an element to a slice, Go checks if there is enough capacity to accommodate the new element. If the current capacity is insufficient, Go automatically doubles the capacity by creating a new underlying array with double the size of the old array and copying the elements from the old array to the new one. This ensures amortized constant-time append operations.

Copy-On-Write: Slices in Go are designed to minimize the need for copying. When you append an element to a slice, Go creates a new underlying array only if necessary. Otherwise, it appends the element to the existing array. This means that slices share their underlying array until a change is made.

Growing Beyond Doubling: Slices continue to double in capacity as they grow, but it's important to note that the actual resizing behavior may be more complex than simple doubling. Go's runtime may optimize this process by considering factors such as available memory, locality, and other system-specific considerations.

Efficient Appends: Go's slice resizing strategy aims to provide efficient append operations while managing memory effectively. The use of doubling capacity helps ensure that the amortized time complexity for append operations is O(1).

Here's an example that demonstrates how the capacity of a slice changes as elements are appended:

package main

import (
	"fmt"
)

func main() {
	slice := make([]int, 0, 1) // Create an empty slice with an initial capacity of 1

	// Function to print the slice and its length and capacity
	printSlice := func(s []int) {
		log.Printf("Length: %d, Capacity: %d, Data: %v\n", len(s), cap(s), s)
	}

	// Initial state
	printSlice(slice)

	// Append elements to the slice
	for i := 0; i < 10; i++ {
		slice = append(slice, i)
		printSlice(slice)
	}
}
Length: 0, Capacity: 1, Data: []
Length: 1, Capacity: 1, Data: [0]
Length: 2, Capacity: 2, Data: [0 1]
Length: 3, Capacity: 4, Data: [0 1 2]
Length: 4, Capacity: 4, Data: [0 1 2 3]
Length: 5, Capacity: 8, Data: [0 1 2 3 4]
Length: 6, Capacity: 8, Data: [0 1 2 3 4 5]
Length: 7, Capacity: 8, Data: [0 1 2 3 4 5 6]
Length: 8, Capacity: 8, Data: [0 1 2 3 4 5 6 7]
Length: 9, Capacity: 16, Data: [0 1 2 3 4 5 6 7 8]
Length: 10, Capacity: 16, Data: [0 1 2 3 4 5 6 7 8 9]

In this example, we explicitly set the initial capacity of the slice to 1. As elements are appended to the slice, you can observe how the capacity changes, and the slice is dynamically resized to accommodate the new elements. The resizing strategy, involving doubling capacity when needed, helps maintain efficient and predictable performance characteristics for slices in Go.

The code example provided demonstrates the growth of a slice's capacity as elements are appended. Here's a more detailed breakdown of the results you can expect from that code:

package main

import (
    "fmt"
)

func main() {
    slice := make([]int, 0, 1) // Create an empty slice with an initial capacity of 1

    // Function to print the slice and its length and capacity
    printSlice := func(s []int) {
        fmt.Printf("Length: %d, Capacity: %d, Data: %v\\n", len(s), cap(s), s)
    }

    // Initial state
    printSlice(slice)
}

We start with an empty slice slice with an initial capacity of 1. The initial capacity is specified as the second argument to the make function. This means the slice can initially hold one element without needing to allocate a new underlying array.

We use the printSlice function to print the length, capacity, and content of the slice. At this point, the slice is empty, so its length is 0.

    // Append elements to the slice
    for i := 0; i < 10; i++ {
        slice = append(slice, i)
        printSlice(slice)
    }
}

Inside a loop, we append elements to the slice by using the append function. As elements are appended, you'll see the following behavior:

  • Initially, the capacity is 1, so the first element can be added without allocating a new array. The length and capacity both increase to 1, and the data is [0].
  • When you attempt to add the second element, the capacity is exceeded (1 < 2). The slice is automatically resized by creating a new underlying array with double the capacity (2). The elements are copied to the new array, and the old array is released. The length and capacity both increase to 2, and the data is [0, 1].
  • This process continues as elements are appended, doubling the capacity as needed. You'll see the capacity grow to 4, 8, and so on.

As you run the code, you'll observe the growth of the slice's capacity to accommodate the increasing number of elements. Go's automatic resizing strategy ensures that, in practice, the amortized time complexity for append operations remains close to O(1), making slices efficient for dynamic data collections.

Comparison Between Arrays and Slices
Comparison Between Arrays and Slices (Source: Internet)

When to Use Arrays vs. Slices?

  • Use arrays when you know the size of your data in advance and it won't change during the program's execution.
  • Use slices when you need a dynamic collection that can grow or shrink. Slices are more commonly used in Go for handling data structures and collections.

Slices and arrays serve different purposes and are chosen based on your specific needs. Slices are more versatile and are commonly used for dynamic collections of data, whereas arrays are used when a fixed-size, compact data structure is required. Slices provide dynamic sizing, bounds checking, and automatic resizing, making them safer and more flexible for many use cases.

>> You may consider:

Conclusion

In this article, you learnt Go array and slice basics. Multiple exercises showed how arrays are fixed in length while slices are flexible, and how this impacts their situational usage. Hope this will helpul for you! 

>>> Follow and Contact Relia Software for more information!

  • coding
  • golang