3. Composite Types
Arrays
Arrays are hardly ever used in Go, due to them being very rigid - they are Static Arrays.
Creating an Array
All the elements of the array must be of the type that’s specified:
var x [3]int
This creates an array x of 3 ints. Since no values were specified, all of them are initialised to the zero value of an int, which is 0 - [0, 0, 0]
var y = [3]int{10, 20, 30}
This creates an array of 3 specified int values - [10, 20, 30]
var z = [12]int{1, 5:4, 6, 10: 100, 15}
This create a “sparse” array - most of the elements are 0 and we specify the indices we want. This will create [1, 0, 0, 0, 0, 4, 6, 0, 0, 0, 100, 15]
You can also create an array without passing the number in, and it will be inferred instead:
var y = [...]int{10, 20, 30} // same as the y above
Comparisons
And we can use ==
and !=
to compare arrays
var x = [...]int{1, 2, 3}
var y = [3]int{1, 2, 3}
x == y // true
Matrices
You can simulate multi-dimensional arrays as well. It doesn’t have true matrix support but this will work by creating an array of length 2 whose type is an array of ints of length 3:
var x [2][3]int
Reading & Writing
Like most languages, you read an array by passing in the index:
var x = [3]int{1, 2, 3}
fmt.Println(x[0]) // 1
x[0] = 10
fm.Println(x[0]) // 10
You can’t write past the end of an array of use a negative index, and an out-of-bounds read or write will compile but will cause a panic
which will be discussed later.
Getting the length of an array
You can use the function len
to get the length:
len(x)
Slices
Unless you know the exact length of an array ahead of time, you’ll use a slice. For example, some of the cryptographic functions in the standard library return arrays because the size of the checksums are defined as part of the algorithm
The main reason why Arrays exist in GO is to provide the backing store for slices, which are one of the most useful features of Go.
Creating a Slice
We don’t have to declare the length in the type:
var x = []int{10, 20, 30}
var y = []int{1, 5:4, 6, 10:100, 15}
var z = [][]int
Note that this is not […] as that will create an Array x = [10, 20, 30] y = [1, 0, 0, 0, 0, 4, 6, 0, 0, 0, 100, 15] z is a matrix
var x []int
If we don’t pass in any value, x is assigned the zero value for a slice, which is nil
- this is slightly different than null
seen in other languages as nil
is an ********identifier************ that represents the lack of a value for some types.
Like untyped numeric constants, nil
has no type so it can be assigned or compared against values of different types. A nil
slice contains nothing.
Comparisons
A slice is the first type we’ve seen that IS NOT COMPARABLE.
If we try to use ==
to see if two slices are comparable, it will throw a compile-time error. The only comparison we can make is with nil
var x []int
fmt.Println(x == nil) // true
Reading, Writing & Length
Just like an array, reading is done with the index & similar to arrays, you can’t read out of bounds or pass in a negative index.
x[10]
and getting the length uses the len()
function:
len(x) // will be 0 for unitialized slices
Append
We can grow slices with the append function:
var x []int
x = append(x, 10)
var y = []int{5, 7, 9}
y = append(y, 10)
y = append(y, 14, 15, 17)
One slice is appended onto another using the ...
operator to expand the source slice into individual values.
y := []int{20, 30, 40}
x = append(x, y...)
If we don’t assign a value to the append function, we will get a compile time error.
Capacity
A slice is a sequence of values. Each element in a slice is assigned to consecutive memory locations, which makes it quick to read or write.
- Every slice has a
capacity
which is the number of consecutive memory locations reserved, which can be>=
the current number of values in the slice. - When you add a value to a slice, it increases the length by 1. Once the length is equal to the capacity, the slice will be re-allocated to a new slice with larger capacity. The values in the original slice are copied to the new slice and the new values are added to the end, returning a new slice.
How slices grow under the hood
When a slice grows via the append method, it takes time for the Go runtime to allocate new memory and copy the existing data from the old memory to the new, then garbage collect the old. Go will:
- Double the size of the slice when the capacity is less than 1,024
- Grow by 25% for capacity >1,024
Just as the built-in len
function returns the current length of a slice, the built-in cap
function returns the current capacity of a slice.
var x []int
fmt.Println(x, len(x), cap(x)) // [] 0 0
x = append(x, 1)
fmt.Println(x, len(x), cap(x)) // [1] 1 1
x = append(x, 2)
fmt.Println(x, len(x), cap(x)) // [1, 2] 2 2
x = append(x, 3)
fmt.Println(x, len(x), cap(x)) // [1, 2, 3] 3 4
x = append(x, 4)
fmt.Println(x, len(x), cap(x)) // [1, 2, 3, 4] 4 4
x = append(x, 5)
fmt.Println(x, len(x), cap(x)) // [1, 2, 3, 4, 5] 5 8
Make
While it’s nice that they grow automatically, it’s more efficient if we size them ahead of time if we know that the slice will be fairly large.
x := make([]int, 5) // [0, 0, 0, 0, 0]
This creates an int slice with length of 5 and capacity of 5. Since it has a length of 5, x[0] thorugh x[4] are valid elements and they are all initialised to 0.
One common mistake is to try an populate those values with append:
x := make([]int, 5)
x = append(x, 10)
This 10 is placed at the end of the slice - [0, 0, 0, 0, 0, 10] and we have a length of 6 and capacity of 10 (as this is doubled).
We can also specify an initial capacity:
x := make([]int, 5, 10)
y := make([]int, 0, 10)
This creates an int slice x with a length of 5 and capacity of 10, and an int slice y with zero length and capacity of 10
In the case of y
, we have a non-nil slice with length of 0. Since the length is 0, we can’t directly index into it but we can append values directly with:
y = append(y, 1, 2, 3, 4, 5)
Declaring your slice
- If you are using a slice as a buffer, then specify a non-zero length Slice.
- If you are sure you know the exact size you want, specify the length & index into the slice to set the values.
- In most situations, you can use
make
with a zero length and a specified capacity. This allows you to append to add items to the slice & won’t do the doubling procedure too often.
Slicing Slices
A slice expression
create a slice from a slice.
x := []int{1, 2, 3, 4} // [ 1, 2, 3, 4]
y := x[:2] // [1, 2]
z := x[1:] // [2, 3, 4]
d := x[1:3] // [2, 3]
e := x[:] // [1, 2, 3, 4]
It INCLUDES the starting offset and EXCLUDES the ending offset.
Shared Storage
When you slice from a slice, you are not making a copy of the data. Instead you now have 2 variables sharing memory. This means that changes to an element in a slice affect all slices that share that element.
This causes weird issues with append
so you should never use append with shallow slices, instead use a full slice:
x := []int{1, 2, 3, 4} // [ 1, 2, 3, 4]
y := x[:2:2]
z := x[2:4:4]
Both y
and z
have a capacity of 2 because we limited the capacity of the sub-slices to their lengths. This means that appending additional elements onto y
and z
created new slices that don’t interact with the other slices.
Converting Arrays to Slices
We can slice an array as well. However be aware that taking a slice from an array has the same memory-sharing properties as taking a slice from a slice.
x := [4]int{5, 6, 7, 8}
y := x[:2]
z := x[2:]
x[0] = 10
y[1] = 4
fmt.Println("x:", x)
fmt.Println("y:", y)
fmt.Println("z:", z)
// x: [10 4 7 8] <-- y[1] = 4 modified this
// y: [10 4] <-- x[0] = 10 modified this
// z: [7 8]
Copy
If you want a slice that’s independent from the original, we can use the copy
function
x := []int{1, 2, 3, 4}
y := make([]int, 4)
num := copy(y, x)
fmt.Println(y, num)
// [1 2 3 4] 4
It copies as many values as it can from the source to destination, limited by whichever slice is smaller, and returns the number of elements copied. The capacity doesn’t matter, only the length.
x := []int{1, 2, 3, 4}
y := make([]int, 2)
num = copy(y, x)
This will only copy the first two elements. y is set to [1 2] and num is 2
We can also copy from the middle:
x := []int{1, 2, 3, 4]
y := make([]int, 2)
num = copy(y, x[2:])
y is now [3 4]
It also allows you to copy between two slices that cover overlapping sections of an underlying slice
x := []int{1, 2, 3, 4]
num = copy(x[:3], x[1:])
// [2 3 4 4] <- num is 3
This copies the last three values in x on top of the first three values of x
You can use copy with arrays by taking a slice of the array:
x := []int{1, 2, 3, 4}
d := [4]int{5, 6, 7, 8}
y := make([]int, 2)
copy(y, d[:])
fmt.Println(y)
copy(d[:], x)
fmt.Println(d)
// y = [5 6]
// d = [1 2 3 4]
This copies the first 2 values of d into y, then all the values of x into d
Strings, Runes & Bytes
Under the hood, Go uses bytes to represent a string that don’t have to be in any particular character encoding, however several Go library functions (and the for-range
loop) assume that a string is composed of a sequence of UTF-8 encoded code points.
var s string = "Hello there"
var b byte = s[6]
Like arrays & slices, strings are 0-indexed. b is assigned to the 7th character which is “t”
Substrings
The slice expression notation also works with strings. Again, the first is inclusive and the second is exclusive.
var s string = "Hello there"
var s2 string = s[4:7] // inclusive of 4, excludes the 7th
var s3 string = s[:5] // up to but excluding the 5th
var s4 string = s[6:] // the 6th onwards
s2 = “o t”, s3 = “Hello”, s4 = “there”
Usually, with simple UTF-8 strings, each character will be one byte. However, in certain cases there could be characters that take up multiple bytes, e.g. emojis: 🌞.
var s string = "Hello 🌞"
var s2 string = s[4:7]
fmt.Println(len(s)) // returns 10, not 7 because the Sun emoji takes up 4 bytes
Instead of getting “o 🌞”, we get “o �” because it only copied the first byte of the emoji’s code point which is invalid.
Due to the complicated relationship between runes, strings & bytes, Go has some interesting type conversions:
var a rune = 'x' // 120
var s string = string(a) // x
var b byte = 'y' // 121
var s2 string = string(b) // y
A common bug for new Go developers is converting an
int
into astring
:
var x int = 65
var y = string(x) // "A" not "65".
var s string = "Hello, 🪿"
var bs []byte = []byte(s)
var rs []rune = []rune(s)
fmt.Println(s) // Hello, 🪿
fmt.Println(bs) // [72 101 108 108 111 44 32 240 159 170 191]
fmt.Println(rs) // [72 101 108 108 111 44 32 129727]
Rather than using the slice and index method, you should extract sub-strings and code points from strings using the functions in the strings
and unicode/utf8
packages in the standard library.
Maps
The map type is written as map[keyType]valueType
.
Declaring a map
The Nil map
var nilMap map[string]int
This nilMap is declared to be a map with string keys and int values. The zero value for a map is nil
, which has a length of 0. If you try to real a nil map, you’ll get the zero value for the map’s value type. However, attempting to write to a nil map variable causes a panic.
An empty map literal
totalWins := map[string]int{}
In this case, it has a length of 0 but you can read and write to a map assigned an empty map literal.
teams := map[string][]string {
"Orcas": []string{"Fred", "Ralph", "Bijoue"},
"Lions": []string{"Sarah", "Peter"},
"Kittens": []string{"Waldo", "Raul"}
}
A nonempty map literal.
Make
If you know how many key-value pairs you intend to put in the map, you can use make to cretea a map with a default size:
ages := make(map[int][]string, 10)
This will still have a length of 0, and they can grow past the initially specified size
Similarities with Slices
- Maps automatically grow
- You can use make to create a map with an initial size
- It has a
len()
function - The zero value is
nil
- Maps are not comparable. You can only check if they are equal to
nil
Reading & Writing
totalWins := map[string]int{}
totalWins["Orcas"] = 1
totalWins["Lions"] = 2
fmt.Println(totalWins["Orcas"])
fmt.Println(totalWins["Kittens"])
totalWins["Kittens"]++
fmt.Println(totalWins["Kittens"])
totalWins["Lions"] = 3
fmt.Println(totalWins["Lions"])
Outputs 1, 0, 1, 3
The totalWins of Kittens
defaults to the 0 value of an int, which is 0. You can also use the ++
operator to increment a numeric value for a map key, which is faster to run as well as type than totalWins["Kittens"]= totalWins["Kittens"] + 1
.
Checking for existence of key
We sometimes need to check if a key exists in a map at all, rather than just get the zero value of it given the key.
m := map[string]int{
"hello": 5,
"world": 0,
}
v, ok := m["hello"]
fmt.Println(v, ok) // 5 true
v, ok = m["world"]
fmt.Println(v, ok) // 0 true
v, ok = m["goodbye"]
fmt.Println(v, ok) // 0 false
“ok” is a boolean that shows us whether the key exists in the map
Deleting from Maps
There is a built-in delete
function:
m := map[string]int{
"hello": 5,
"world": 0,
}
delete(m, "hello")
If the key isn’t present or the entire map is nil
, nothing happens. It doesn’t return a value.
Using Maps as Sets
Many languages include a Set
in their standard library. This is a data type that ensures that there is at most one of a value, but doesn’t guarantee that the values are in any particular order.
Go doesn’t provide one out of the box, but we can create one with a map very easily.
intSet := map[int]bool{}
vals := []int{5, 10, 2, 5, 8, 7, 3, 9, 1, 2, 10}
for _, v := range vals {
intSet[v] = true
}
fmt.Println(len(vals), len(intSet))
fmt.Println(intSet[5])
fmt.Println(intSet[500])
if intSet[100] {
fmt.Println("100 is in the set")
}
We want a set of ints so we create a map where the keys are of int type and the values are of **bool** type. We iterate over the values in *vals***** using a *****for-range***** loop & place them in the ***intSet.***
Summary
Method | Code |
---|---|
Construct | m := map[key]value |
Insert | m[k] = v |
Lookup | v = m[k] |
Delete | delete(m, k) |
Iterate | for k, v := range m |
Size | len(m) |
Operations run in O(1) constant time.
Structs
Maps are convenient but they don’t define an API since there’s no way to constrain a map to only allow certain keys. Also, all of the values in a map must be of the same type.
Creating a Struct
type person struct {
name string
age int
pet string
}
var fred person
bob := person{}
A struct is defined with the keyword type
, the name of the struct type and the keyword struct
. Unlike a map, these are not comma-separated.
fred
gets the zero value for the person struct type - a zero value struct has every field set to the field’s zero value.bob
has the same thing as fred. There is no difference between assigning an empty struct literal and not assigning a value at all.
We can either use the struct literal format, where every field in the struct must be specified and the order matters.
julia := person{
"Julia",
40,
"cat",
}
Or you can define it like a map:
beth := person{
age: 30,
name: "Beth",
}
pet is set to the zero value of string, which is “”
Reading & Writing to a struct
We can use the dot notation to read and write to a struct.
bob.name = "Bob"
fmt.Println(beth.name)
Anonymous Structs
You can declare that a variable implements a struct type without first giving the struct type a name.
var person struct {
name string
age int
pet string
}
person.name = "bob"
person.age = 50
person.pet = "dog"
pet := struct {
name string
king string
}{
name: "Fido",
kind: "dog",
}
The types of the variables person
and pet
are anonymous structs. You assign and read fields just like a named struct type, and you initialise an instance of it in the same way.
Why would you ever use this?
- Translating external data into a struct & Translating a struct into external data (like JSON or protocol buffers)
This is called unmarshaling
and marshaling
data which will be covered later when we learn encoding/json
- Writing tests - we’ll use a slice of anonymous structs when writing table-driven tests in chapter 13
Comparing and Converting Structs
Whether or not a struct is comparable depends on the struct’s fields. If the structs are composed of comparable types are comparable and those with slice or map fields are not.
However, the usual ==
and !=
will not work. Given the following struct:
type firstPerson struct {
name string
age int
}
We can use a type conversion to convert an isntance of firstPerson to secondPerson, but we can’t compare firstPerson with thirdPerson due to the different order & we can’t compare with fourthPerson due to different naming. We can’t compare with the fifthPerson due to the extra field.
type secondPerson struct {
name string
age int
}
type thirdPerson struct {
age int
name string
}
type fourthPerson struct {
firstName string
age int
}
type fifthPerson struct {
name string
age int
favoriteColor string
}
However, with anonymous structs, you can compare them without a type conversion (assuming both structs have the same fields).