Introduction: Why Do We Need Data Structures?

So far, we've covered the foundations of Go, including variables, data types, operators, conditional statements, loops, and functions.

These concepts give us the building blocks we need to write Go programs. However, as we begin building more practical applications, we'll encounter a new challenge such as working with large amounts of data.

Think about an application that manages users, processes transactions, stores product information, or tracks customer records.

We won't always be working with a single value at a time. We'll often need to store, organise, access, modify, and manipulate collections of related data. For example:

  • A banking application might need to store multiple customer account balances.
  • An e-commerce application might need to manage a collection of products. Storing each value in a separate variable would quickly become impractical and difficult to manage. This is where data structures come in.

Data structures are ways of organising and storing data in a program so that we can access, manage, and manipulate it efficiently.

Go provides several built-in data structures and mechanisms that help us work with collections of data.

In this module, we'll explore four important concepts:

  1. Arrays and Slices: Used to store collections of values of the same type.
  2. Maps: Used to store data as key-value pairs.
  3. Structs: Used to group related values, potentially of different types, into a single custom data type.
  4. Pointers: Used to store and work with the memory address of another value.

We'll begin with arrays, slices, and maps before moving on to structs and pointers.

Arrays and Slices in Go

In Go, arrays and slices are data structures that consist of an ordered sequence of elements. They are useful when we want to work with multiple related values, as they allow us to keep data that belongs together in a single variable and perform similar operations on multiple values at once.

Coming from a JavaScript background, I'd assume most of us are already familiar with arrays and how they work. However, Go approaches arrays and slices a little differently, so I'd still love to go over them again.

Although both are used to store multiple values of the same data type in a single variable, the major difference between them is their length.

An array has a fixed length, meaning the number of elements it can contain is determined when it is created and cannot be changed.

A slice, on the other hand, is flexible. Its length can change as we add or remove elements, making it more convenient when working with collections of data that may grow or shrink over time.

We'll explore both concepts, how to define and initialise them, how to access and manipulate their elements, and the differences between them.

Defining an Array

An array is a collection of elements of the same data type with a fixed length. The length is part of its type.

In Go, arrays are defined by specifying the size (length) of the array inside square brackets[], followed by the data type of its elements.

Syntax:

var numbers [3]int 

var - Keyword used to declare a variable.
numbers - Variable name.
[3]- Length of the array (number of elements it can hold).
int - Data type of the elements stored in the array.

Important to note:

Once an array has been declared, its length cannot be changed. However, its elements can be modified, and we can also initialise an array with values.
it's important to understand that an array's length is part of its type. This is one of the major differences between arrays in Go and arrays in JavaScript.

var first [2]int var second [3]int 

Although both arrays contain integers, they have different types.

  • first has the type [2]int (an array of 2 integers).
  • second has the type [3]int (an array of 3 integers).

Because their lengths are different, their types are also different.

This means you cannot assign one array to the other or compare them directly as though they were the same type.

Important to note:

  • Array length is part of its type.
  • Arrays of different lengths are different types.
  • Arrays of the same type can be compared, provided their element type is comparable.

Declaring and Initialising Arrays

There are several ways to declare and initialise an array in Go.

1. Declaring an Array Without Specifying Initial Values

We can declare an array without providing any initial values.

var numbers [3]int fmt.Println(numbers) 

Output:

[0 0 0] 

This creates an array with three integer elements and since we haven't provided any initial values, Go automatically initialises each element with the zero value of its respective data type.

For integers (int), the zero value is 0.

This behaviour also applies to other data types.

var names [3]string fmt.Println(names) 

Output:

[ ] 

For strings, the zero value is an empty string (""). When printed as part of an array, the empty strings appear as blank spaces between the brackets.
This is an important characteristic of Go. When variables are declared without explicit initial values, Go automatically assigns them their respective zero values. Arrays follow this rule element by element.

2. Initialising an Array With Explicit Values

We can also provide initial values when declaring an array.

score := [3]int{85, 90, 95} fmt.Println(score) 

Output:

[85 90 95] 

Here, we are declaring and initialising an array called score with three integer values. The values are assigned in order, starting from index 0.

Index Value
0 85
1 90
2 95

The syntax consists of three main parts:

  • The array's length: [3]
  • The data type: int
  • The initial values: {85, 90, 95}

3. Specifying the Length but Providing Fewer Initial Values

Go also allows us to initialise an array with fewer values than its declared length.

numbers := [4]int{10, 20} fmt.Println(numbers) 

Output:

[10 20 0 0] 

Here, we've declared an array called numbers with a length of four, but we've only provided two initial values. What happens?

  • The first element (index 0) receives 10.
  • The second element (index 1) receives 20.
  • The third element (index 2) receives the zero value, 0.
  • The fourth element (index 3) also receives the zero value, 0.

The remaining elements are automatically initialised with the zero value of their data type.Even though we provided only two initial values, the array still has a length of four which does not change because the length was already specified during declaration.

4. Allowing Go to Infer the Array Length

Go provides the ... notation for array literals when we want the compiler to determine the array's length automatically. Instead of manually specifying the number of elements, we allow Go to count the initial values and determine the length.

numbers := [...]int{10, 20, 30, 40} fmt.Println(numbers) 

Output:

[10 20 30 40] 

Here, we've used ... instead of explicitly specifying the array's length.

Go counts the number of initial values and infers the array's type as [4]int.

It’s important to know that the ... notation does not mean that the array has a variable length, it simply tells the compiler to determine the array's length from the number of initial values provided. Once the array has been initialised, its length is fixed.

Go vs JavaScript: Array Initialisation

Coming from a JavaScript background, let's look at how array initialisation differs in both languages.

JavaScript:

const numbers = [10, 20]; numbers.push(30); console.log(numbers); 

Output:

[10, 20, 30] 

In JavaScript, we can initialise an array with two elements and use the push() method to add another element.

The array grows from two elements to three.

Go:

numbers := [4]int{10, 20} fmt.Println(numbers) 

Output:

[10 20 0 0] 

In Go, we've declared an array with a length of four, even though we've only provided two initial values.

The remaining two elements are automatically assigned their zero values.

The main difference is that Go arrays have a fixed length, whereas JavaScript arrays can grow or shrink as elements are added or removed.

To create a Go collection that can grow dynamically, we typically use slices, which we'll explore later.

Lesson Takeaway

  1. The length of an array is part of its type in Go.
  2. Go automatically assigns zero values to elements that aren't explicitly initialised.
  3. You can initialise an array with fewer values than its declared length.
  4. The ... notation allows Go to infer the array's length.
  5. Array elements can be modified, but the array's length cannot be changed.

Finding the Length of an Array Using len()

Go provides a built-in function called len() that allows us to obtain the number of elements in an array or slice. This returns the total number of elements, not the highest index.

numbers := [3]int{20, 30, 40} fmt.Println(len(numbers)) 
3 

Although the array's highest index is 2, its length is 3 because it contains three elements.

Go vs JavaScript: Finding Array Length

In JavaScript, we use the .length property to determine the number of elements in an array.

JavaScript:

const numbers = [10, 20, 30, 40]; console.log(numbers.length); 

Output:

4 

Go:

numbers := [4]int{10, 20, 30, 40} fmt.Println(len(numbers)) 

Output:

4 

The difference is simply how both languages provide access to the length:

  • JavaScript uses the .length property.
  • Go uses the built-in len() function.

Why is len() important?

The len() function is frequently used when working with arrays and slices, particularly when iterating through their elements using loops. It allows us to determine the number of elements we need to work with without manually counting them.

Array Assignments in Go

One of the important differences between arrays in Go and JavaScript is how they behave when assigned to another variable.

In Go, arrays are value types. This means that when we assign an array to another variable, Go copies the entire array, including all its elements.The two variables now hold separate copies of the array meaning modifying one does not affect the other.

first := [3]int{10, 20, 30} second := first second[0] = 90 fmt.Println(first) fmt.Println(second) 

Output:

[10 20 30] [90 20 30] 

Let's break down what's happening:

  1. We declare an array called first containing three integers.
  2. We create another variable called second and assign first to it.
  3. Go copies all the elements of first into second.
  4. We modify the element at index 0 of second, changing its value from 10 to 90.
  5. When we print both arrays, we see that first remains unchanged while second contains the modified value.

This happens because both variables hold separate copies of the array.

Go vs JavaScript: Array Assignment

JavaScript handles array assignment differently. In JavaScript, arrays are reference types. Assigning an array to another variable copies the reference to the same underlying array object, rather than creating an independent copy.

const first = [10, 20, 30]; const second = first; second[0] = 90; console.log(first); console.log(second); 

Output:

[90, 20, 30] [90, 20, 30] 

Notice that modifying second also affects first, This happens because both variables refer to the same array object in memory.

Go JavaScript
Arrays are value types. Arrays are reference types.
Assigning an array copies its elements. Assigning an array copies its reference.
Each variable holds an independent array copy. Both variables can refer to the same array object.
Modifying one copy does not affect the other. Modifying the shared array affects both references.

This value-copying behaviour also applies when passing arrays to functions in Go. When an array is passed as an argument to a function, the function receives a copy of the array.

This means that even if the function modifies an element, the original array outside the function remains unchanged. We'll explore this behaviour further when we get into functions and pointers.

Why Are Arrays Less Common Than Slices in Go?

Although arrays are useful in Go, they are less commonly used than slices in everyday application development. The main reason is their fixed length.
In real-world applications, the amount of data we work with can change frequently. For example:

  • The number of users in an application can increase or decrease.
  • The number of products in an online store can change.
  • The number of transactions processed by a banking application can vary.
  • The number of records returned from an API can change depending on the request.

Arrays are not particularly convenient for these situations because their length cannot be changed after declaration. Slices, on the other hand, provide more flexibility because their length can grow or shrink as needed.

So When Should We Use Arrays?

Arrays are still useful, particularly when we know the exact number of elements we need and that number is not expected to change.

For example:

  • Storing the RGB values of a colour (three integers).
  • Representing a fixed number of coordinates.
  • Working with fixed-size cryptographic keys.
  • Storing a fixed set of measurements. Takeaway
  • Use arrays when a fixed size is an intentional requirement.
  • Use slices for most collections where the number of elements may grow, shrink, or change over time.

Slices in Go

Defining Slices

A slice is a flexible, dynamically sized sequence of elements that describes a section of an underlying array. We can also think of a slice as a dynamic array because, unlike arrays, its length can grow or shrink as needed.
However, it's important to understand that a slice itself is not an underlying array. A slice is a value that describes a portion of an array that it exposes.
A slice provides access to a section of an underlying array, and multiple slices can refer to the same underlying array, this means that different slices can share the same underlying data. Changes made through one slice can affect what another slice sees if they refer to the same elements.

Slice Syntax

collection[start:end] 
  • collection: The array or slice we want to extract elements from.
  • start: The index where the slice begins. This index is inclusive.
  • end: The index where the slice stops. This index is exclusive. The start index is included in the resulting slice, while the end index is excluded.
numbers := [5]int{10, 20, 30, 40, 50} 

Now, let's create a slice called middle from this array:

middle := numbers[1:4] fmt.Println(middle) 

Output:

[20 30 40] 

Our original array contains five elements so when we write:

middle := numbers[1:4] 

We're extracting a section of the original array, starting at index 1 and stopping before index 4.
This means:

  • Index 1 is included (value: 20).
  • Index 2 is included (value: 30).
  • Index 3 is included (value: 40).
  • Index 4 is excluded (value: 50).

The resulting slice contains [20 30 40].

Declaring and Initialising Slices

There are several ways to declare and initialise slices in Go. Let's explore them.

1. Declaring a Slice Without Initialising It:

We can declare a slice without providing any initial values.

var numbers []int 

This declares a slice of integers but unlike arrays, there is no fixed length included in the declaration.
However, there is an important distinction between declaring a slice and allowing Go to infer the length of an array. Although the declarations might look similar, they behave differently.

A newly declared slice without an initial value is a nil slice. A nil slice has:

  • Length of 0.
  • Capacity of 0.
  • No underlying array currently associated with it. We can verify this using the len() and cap() functions.
var numbers []int fmt.Println(numbers) fmt.Println(len(numbers)) fmt.Println(cap(numbers)) 

Output:

[] 0 0 

Even though the slice has no elements, we can still add elements to it using the append() function.

var numbers []int numbers = append(numbers, 10, 20) fmt.Println(numbers) 

Output:

[10 20] 

This works because append() returns a slice value containing the added elements, which we assign back to numbers. It’s also important to not that a nil slice is not the same as an array containing zero values.

var numbers [3]int fmt.Println(numbers) 

Output:

[0 0 0] 

This array contains three elements, even though all three elements have the zero value of int. However:

var numbers []int fmt.Println(numbers) 

Output:

[] 

This slice contains no elements. The difference is that an array has a fixed number of elements, while a nil slice has a length and capacity of zero.

2. Initialising a Slice Using a Slice Literal

Another way to create a slice is by using a slice literal. A slice literal looks similar to an array literal, except that we omit the length inside the square brackets.

numbers := []int{10, 20, 30} fmt.Println(numbers) 

Output:

[10 20 30] 

This creates a slice containing three integers but lrt's notice the difference

// Array numbers := [3]int{10, 20, 30} // Slice numbers := []int{10, 20, 30} 

For an array, we specify the length inside the brackets while for a slice, we leave the brackets empty. The type of our slice is []int, not [3]int.
We can also initialise slices containing other data types.

String slice:

names := []string{"John", "Mary", "Peter"} 

Boolean slice:

status := []bool{true, false, true} 

Floating-point slice:

prices := []float64{10.5, 20.5, 30.5} 

Just like arrays, all elements in a slice must have the same data type.

3. Creating a Slice Using make()

Go provides a built-in function called make() that allows us to create and initialise slices with a specified length and optional capacity.
syntax:

make([]T, length) 

Where:

  • make: The built-in function used to create the slice.
  • []T: Specifies the slice's data type.
  • length: Specifies the initial number of elements in the slice.
numbers := make([]int, 3) fmt.Println(numbers) 

Output:

[0 0 0] 

This creates a slice of integers with a length of three with all three elements initially assigned the zero value of int, which is 0. We can then assign values to individual elements using their indices.

numbers := make([]int, 3) numbers[0] = 10 numbers[1] = 20 numbers[2] = 30 fmt.Println(numbers) 

Output:

[10 20 30] 

The make() function is useful when we know how many elements we want to work with from the beginning, but we don't necessarily have their actual values yet.

4. Creating a Slice With a Specified Capacity

We can also provide a capacity when creating a slice using make().

syntax:

make([]T, length, capacity) 
  • []T: Specifies the slice's data type.
  • length: Specifies the number of elements the slice currently contains.
  • capacity: Specifies the maximum number of elements the slice can hold before needing a larger underlying array.
numbers := make([]int, 3, 5) fmt.Println(numbers) fmt.Println(len(numbers)) fmt.Println(cap(numbers)) 

Output:

[0 0 0] 3 5 

This creates a slice of integers with Length: 3, Capacity: 5, with the slice currently containing three elements, all initialised to zero. However, its underlying array has room for five elements.

Understanding Length vs Capacity

This is an important distinction when working with slices.

Length (len) refers to the number of elements currently contained in the slice.

Capacity (cap) refers to the number of elements between the beginning of the slice and the end of its underlying array.

numbers := make([]int, 3, 4) 

The slice has three elements, but its capacity is four, it's important to note that capacity does not mean the slice already contains five elements, It currently contains only three elements.
To access an element directly using its index, that index must be within the slice's current length, not merely within its capacity.

numbers[3] = 40 

This would cause an index out of range panic because the slice's current length is three, and its valid indices are 0, 1, and 2. To add a fourth element, we use `append()

`go
numbers := make([]int, 3, 4)

numbers[0] = 10
numbers[1] = 20
numbers[2] = 30
numbers = append(numbers, 40)
fmt.Println(numbers)
fmt.Println(len(numbers))
fmt.Println(cap(numbers))
`

Output:

`plaintext
[10 20 30 40]
4
4

`
Notice what happened, Initially the slice had a length of 3 and capacity of 5.

  • We used append() to add another element.
  • The length increased from 3 to 4.
  • The capacity remained 5 with enough room to still grow.

This is how we can add elements to a slice without directly accessing an index outside its current length.

Shared Underlying Arrays

When you create a slice from an array, the slice does not create an independent copy of the selected elements. Instead, it refers to the relevant portion of the original array. Let's look at an example:
`go
numbers := [5]int{10, 20, 30, 40, 50}

middle := numbers[1:4]

fmt.Println(numbers) // [10 20 30 40 50]
fmt.Println(middle) // [20 30 40]
`

The original array still contains all five elements:

Index 0 1 2 3 4
Value 10 20 30 40 50

The slice middle starts at index 1 and ends before index 4. Therefore, it contains the values at indexes 1, 2 and 3: 20, 30, 40.
What happens when we modify the slice?
We can modify an element through the slice:

`go
middle[0] = 99

fmt.Println(numbers) // [10 99 30 40 50]
fmt.Println(middle) // [99 30 40]
`

Notice that changing middle[0] also changes numbers[1].

This happens because middle[0] refers to the same underlying array element as numbers[1]. The slice is not an independent copy.

Multiple Slices Can Share One Underlying Array

More than one slice can refer to different portions of the same underlying array. If those portions overlap, changes made through one slice can be visible through the other.

`go
numbers := [5]int{10, 20, 30, 40, 50}

first := numbers[0:3]
second := numbers[2:5]

fmt.Println(first) // [10 20 30]
fmt.Println(second) // [30 40 50]
`

Both slices include the element at index 2, which has the value 30, Let's modify the elements through the slices:

`go
first[1] = 28
first[2] = 29
second[2] = 45

fmt.Println(numbers) // [10 28 29 40 45]
fmt.Println(first) // [10 28 29]
fmt.Println(second) // [29 40 45]
`

  • first[1] = 28 changed numbers[1].
  • first[2] = 29 changed numbers[2], which is also visible through second[0].
  • second[2] = 45 changed numbers[4].

Even though first and second are distinct slice values, they share the same underlying array. It’s important to note that When multiple slices share an underlying array, modifying an element through one slice can affect the other slices if they refer to that same element.

You can read more about this in the official Go blog article,

Slice Length and Capacity

Every slice has two important properties:

Length (len): The length is the number of elements currently accessible through a slice. You can get it using Go's built-in len() function.

Capacity (cap): The capacity is the number of elements from the slice's starting position to the end of its underlying array. You can get it using Go's built-in cap() function.

Capacity is always greater than or equal to length.

The difference is:

  • len() tells you how many elements are currently part of the slice.
  • cap() tells you how much space is available in the underlying array from the slice's starting position.

go
numbers := make([]int, 2, 5)
fmt.Println(numbers) // [0 0]
fmt.Println(len(numbers)) // 2
fmt.Println(cap(numbers)) // 5

  • []int means we are creating a slice of integers.
  • 2 is the length.
  • 5 is the capacity.

The slice currently has two elements, both initialised to their zero value (0) and although its capacity is five, only two elements are currently accessible through the slice. We can still assign values to the existing elements

`go
numbers[0] = 10
numbers[1] = 20

fmt.Println(numbers) // [10 20]
`

Both assignments are valid because indexes 0 and 1 are within the current length of the slice.
However, this would cause a runtime panic:

javascript
numbers[2] = 30

Why?
Because the slice's length is currently 2, meaning its valid indexes are 0 and 1 and even though the capacity is 5, index 2 is not yet part of the slice's accessible elements.

Capacity does not give you permission to access elements beyond the current length.

To make the slice longer, we need to use append().

go
numbers = append(numbers, 30)
fmt.Println(numbers) // [10 20 30]
fmt.Println(len(numbers)) // 3
fmt.Print...