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:
- Arrays and Slices: Used to store collections of values of the same type.
- Maps: Used to store data as key-value pairs.
- Structs: Used to group related values, potentially of different types, into a single custom data type.
- 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.
-
firsthas the type[2]int(an array of 2 integers). -
secondhas 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
- The length of an array is part of its type in Go.
- Go automatically assigns zero values to elements that aren't explicitly initialised.
- You can initialise an array with fewer values than its declared length.
- The ... notation allows Go to infer the array's length.
- 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
.lengthproperty. - 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:
- We declare an array called
firstcontaining three integers. - We create another variable called
secondand assignfirstto it. - Go copies all the elements of
firstintosecond. - We modify the element at index
0ofsecond, changing its value from10to90. - When we print both arrays, we see that
firstremains unchanged whilesecondcontains 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
1is included (value:20). - Index
2is included (value:30). - Index
3is included (value:40). - Index
4is 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()andcap()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
3to4. - The capacity remained
5with 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] = 28changednumbers[1]. -
first[2] = 29changednumbers[2], which is also visible throughsecond[0]. -
second[2] = 45changednumbers[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
-
[]intmeans we are creating a slice of integers. -
2is the length. -
5is 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...
