4. Data Structures

Bad programmers worry about the code. Good programmers worry about data structures and their relationships. — Linus Torvalds

In the last last few chapters we have seen some of the primitive data data types. We also introduced few other advanced data types without going to the details. In this chapter, we are going to look into more data structures.

4.1. Primitive Data Types

Advanced data structures are built on top of primitive data types. This section is going to cover the primitive data types in Go.

4.1.1. Zero Value

In the Quick Start chapter, you have learned various ways to declare a variable. When you declare a variable using the var statement without assigning a value, a default Zero value will be assigned for certain types. The Zero value is 0 for integers and floats, empty string for strings, and false for boolean. To demonstrate this, here is an example:

package main

import "fmt"

func main() {
    var name string
    var age int
    var tall bool
    var weight float64
    fmt.Printf("%#v, %#v, %#v, %#v\n", name, age, tall, weight)
}

This is the output:

"", 0, false, 0

4.1.2. Variable

In the quick start chapter, we have discussed about variables and its usage. The variable declared outside the function (package level) can access anywhere within the same package.

Here is an example:

package main

import (
    "fmt"
)

var name string
var country string = "India"

func main() {
    name = "Jack"
    fmt.Println("Name:", name)
    fmt.Println("Country:", country)
}

In the above example, the name and country are two package level variables. As we have seen above the name gets zero value, where as value for country variable is explicitly initialized.

If the variable has been defined using the := syntax, and the user wants to change the value of that variable, they need to use = instead of := syntax.

If you run the below program, it’s going to throw an error:

package main

import (
    "fmt"
)

func main() {
    age := 25
    age := 35
    fmt.Println(age)
}
$ go run update.go
# command-line-arguments
./update.go:9:6: no new variables on left side of :=

The above can be fixed like this:

package main

import (
    "fmt"
)

func main() {
    age := 25
    age = 35
    fmt.Println(age)
}

Now you should see the output:

$ go run update.go
35

Using the reflect package, you can identify the type of a variable:

package main

import (
       "fmt"
       "reflect"
)

func main() {
     var pi = 3.41
     fmt.Println("type:", reflect.TypeOf(pi))
}

Using one or two letter variable names inside a function is common practice. If the variable name is multi-word, use lower camelCase (initial letter lower and subsequent words capitalized) for unexported variables. If the variable is an exported one, use upper CamelCase (all the words capitalized). If the variable name contains any abbreviations like ID, use capital letters. Here are few examples: pi, w, r, ErrorCode, nodeToDaemonPods, DB, InstanceID.

4.1.2.1. Unused variables and imports

If you declare a variable inside a function, use that variable somewhere in the same function where it is declared. Otherwise, you are going to get a compile error. Whereas a global variable declared but unused is not going to throw compile time error.

Any package that is getting imported should find a place to use. Unused import also throws compile time error.

4.1.3. Boolean Type

A boolean type represents a pair of truth values. The truth values are denoted by the constants true and false. These are the three logical operators that can be used with boolean values:

  • && – Logical AND

  • || – Logical OR

  • ! – Logical NOT

Here is an example:

 1package main
 2
 3import "fmt"
 4
 5func main() {
 6     yes := true
 7     no := false
 8     fmt.Println(yes && no)
 9     fmt.Println(yes || no)
10     fmt.Println(!yes)
11     fmt.Println(!no)
12}

The output of the above logical operators are like this:

$ go run logical.go
false
true
false
true

4.1.4. Numeric Types

The numeric type includes both integer types and floating-point types. The allowed values of numeric types are same across all the CPU architectures.

These are the unsigned integers:

  • uint8 – the set of all unsigned 8-bit integers (0 to 255)

  • uint16 – the set of all unsigned 16-bit integers (0 to 65535)

  • uint32 – the set of all unsigned 32-bit integers (0 to 4294967295)

  • uint64 – the set of all unsigned 64-bit integers (0 to 18446744073709551615)

These are the signed integers:

  • int8 – the set of all signed 8-bit integers (-128 to 127)

  • int16 – the set of all signed 16-bit integers (-32768 to 32767)

  • int32 – the set of all signed 32-bit integers (-2147483648 to 2147483647)

  • int64 – the set of all signed 64-bit integers (-9223372036854775808 to 9223372036854775807)

These are the two floating-point numbers:

  • float32 – the set of all IEEE-754 32-bit floating-point numbers

  • float64 – the set of all IEEE-754 64-bit floating-point numbers

These are the two complex numbers:

  • complex64 – the set of all complex numbers with float32 real and imaginary parts

  • complex128 – the set of all complex numbers with float64 real and imaginary parts

These are the two commonly used used aliases:

  • byte – alias for uint8

  • rune – alias for int32

4.1.5. String Type

A string type is another most import primitive data type. String type represents string values.

4.2. Constants

A constant is an unchanging value. Constants are declared like variables, but with the const keyword. Constants can be character, string, boolean, or numeric values. Constants cannot be declared using the := syntax.

In Go, const is a keyword introducing a name for a scalar value such as 2 or 3.14159 or “scrumptious”. Such values, named or otherwise, are called constants in Go. Constants can also be created by expressions built from constants, such as 2+3 or 2+3i or math.Pi/2 or (“go”+”pher”). Constants can be declared are at package level or function level.

This is how to declare constants:

package main

import (
    "fmt"
)

const Freezing = true
const Pi = 3.14
const Name = "Tom"

func main() {
    fmt.Println(Pi, Freezing, Name)
}

You can also use the factored style declaration:

package main

import (
    "fmt"
)

const (
      Freezing = true
      Pi = 3.14
      Name = "Tom"
)

func main() {
    fmt.Println(Pi, Freezing, Name)
}

const (
      Freezing = true
      Pi = 3.14
      Name = "Tom"
)

Compiler throws an error if the constant is tried to assign a new value:

package main

import (
    "fmt"
)

func main() {
    const Pi = 3.14
    Pi = 6.86
    fmt.Println(Pi)
}

The above program throws an error like this:

$ go run constants.go
constants:9:5: cannot assign to Pi

4.2.1. iota

The iota keyword is used to define constants of incrementing numbers. This simplify defining many constants. The values of iota is reset to 0 whenever the reserved word const appears. The value increments by one after each line.

Consider this example:

// Token represents a lexical token.
type Token int

const (
    // Illegal represents an illegal/invalid character
    Illegal Token = iota

    // Whitespace represents a white space
    // (" ", \t, \r, \n) character
    Whitespace

    // EOF represents end of file
    EOF

    // MarkerID represents '\id' or '\id1' marker
    MarkerID

    // MarkerIde represents '\ide' marker
    MarkerIde
)

In the above example, the Token is custom type defined using the primitive int type. The constants are defined using the factored syntax (many constants within parenthesis). There are comments for each constant values. Each constant value is be incremented starting from 0. In the above example, Illegal is 0, Whitespace is 1, EOF is 2 and so on.

The iota can be used with expressions. The expression will be repeated. Here is a good example taken from Effective Go (https://go.dev/doc/effective_go#constants):

type ByteSize float64

const (
    // ignore first value (0) by assigning to blank identifier
    _           = iota
    KB ByteSize = 1 << (10 * iota)
    MB
    GB
    TB
    PB
    EB
    ZB
    YB
)

Using _ (blank identifier) you can ignore a value, but iota increments the value. This can be used to skip certain values. As you can see in the above example, you can use an expression with iota.

Iota is reset to 0 whenever the const keyword appears in the source code. This means that if you have multiple const declarations in a single file, iota will start at 0 for each declaration. Iota can only be used in const declarations. It cannot be used in other types of declarations, such as var declarations. The value of iota is only available within the const declaration in which it is used. It cannot be used outside of that declaration.

4.2.1.1. Blank Identifier

Sometimes you may need to ignore the value returned by a function. Go provides a special identifier called blank identifier to ignore any types of values. In Go, underscore _ is the blank identifier.

Here is an example usage of blank identifier where the second value returned by the function is discarded.

x, _ := someFunc()

Blank identifier can be used as import alias to invoke init function without using the package.

import (
       "database/sql"

       _ "github.com/lib/pq"
)

In the above example, the pq package has some code which need to be invoked to initialize the database driver provided by that package. And the exported functions within the above package is supposed to be not used.

We have already seen another example where blank identifier if used with iota to ignore certain constants.

4.3. Arrays

An array is an ordered container type with a fixed number of data. In fact, the arrays are the foundation where slice is built. We will study about slices in the next section. Most of the time, you can use slice instead of an array.

The number of values in the array is called the length of that array. The array type [n]T is an array of n values of type T. Here are two example arrays:

colors := [3]string{"Red", "Green", "Blue"}
heights := [4]int{153, 146, 167, 170}

In the above example, the length of first array is 3 and the array values are string data. The second array contains int values. An array’s length is part of its type, so arrays cannot be re-sized. So, if the length is different for two arrays, those are distinct incompatible types. The built-in len function gives the length of array.

Array values can be accessed using the index syntax, so the expression s[n] accesses the nth element, starting from zero.

An array values can be read like this:

colors := [3]string{"Red", "Green", "Blue"}
i := colors[1]
fmt.Println(i)

Similarly array values can be set using index syntax. Here is an example:

colors := [3]string{"Red", "Green", "Blue"}
colors[1] = "Yellow"

Arrays need not be initialized explicitly. The zero value of an array is a usable array with all elements zeroed.

var colors [3]string
colors[1] = "Yellow"

In this example, the values of colors will be empty strings (zero value). Later we can assign values using the index syntax.

There is a way to declare array literal without specifying the length. When using this syntax variant, the compiler will count and set the array length.

colors := [...]string{"Red", "Green", "Blue"}

In the chapter on control structures, we have seen how to use For loop for iterating over slices. In the same way, you can iterate over array.

Consider this complete example:

 1package main
 2
 3import "fmt"
 4
 5func main() {
 6    colors := [3]string{"Red", "Green", "Blue"}
 7    fmt.Println("Length:", len(colors))
 8    for i, v := range colors {
 9        fmt.Println(i, v)
10    }
11}

If you save the above program in a file named colors.go and run it, you will get output like this:

$ go run colors.go
Length: 3
0 Red
1 Green
2 Blue

In the above program, a string array is declared and initialized with three string values. In the 7th line, the length is printed and it gives 3. The range clause gives index and value, where the index starts from zero.

4.4. Slices

Slice is one of most important data structure in Go. Slice is more flexible than an array. It is possible to add and remove values from a slice. There will be a length for slice at any time. Though the length vary dynamically as the content value increase or decrease.

The number of values in the slice is called the length of that slice. The slice type []T is a slice of type T. Here are two example slices:

colors := []string{"Red", "Green", "Blue"}
heights := []int{153, 146, 167, 170}

The first one is a slice of strings and the second slice is a slice of integers. The syntax is similar to array except the length of slice is not explicitly specified. You can use built-in len function to see the length of slice.

Slice values can be accessed using the index syntax, so the expression s[n] accesses the nth element, starting from zero.

A slice values can be read like this:

colors := []string{"Red", "Green", "Blue"}
i := colors[1]
fmt.Println(i)

Similary slice values can be set using index syntax. Here is an example:

colors := []string{"Red", "Green", "Blue"}
colors[1] = "Yellow"

Slices should be initialized with a length more than zero to access or set values. In the above examples, we used slice literal syntax for that. If you define a slice using var statement without providing default values, the slice will be have a special zero value called nil.

Consider this complete example:

1package main
2
3import "fmt"
4
5func main() {
6    var v []string
7    fmt.Printf("%#v, %#v\n", v, v == nil)
8    // Output: []string(nil), true
9}

In the above example, the value of slice v is nil. Since the slice is nil, values cannot be accessed or set using the index. These operations are going to raise runtime error (index out of range).

Sometimes it may not be possible to initialize a slice with some value using the literal slice syntax given above. Go provides a built-in function named make to initialize a slice with a given length and zero values for all items. For example, if you want a slice with 3 items, the syntax is like this:

colors := make([]string, 3)

In the above example, a slice will be initialized with 3 empty strings as the items. Now it is possible to set and get values using the index as given below:

colors[0] = "Red"
colors[1] = "Green"
colors[2] = "Blue"
i := colors[1]
fmt.Println(i)

If you try to set value at 3rd index (colors[3]), it’s going to raise runtime error with a message like this: “index out of range”. Go has a built-in function named append to add additional values. The append function will increase the length of the slice.

Consider this example:

 1package main
 2
 3import (
 4    "fmt"
 5)
 6
 7func main() {
 8    v := make([]string, 3)
 9    fmt.Printf("%v\n", len(v))
10    v = append(v, "Yellow")
11    fmt.Printf("%v\n", len(v))
12}

In the above example, the slice length is increased by one after append. It is possible to add more values using append. See this example:

 1package main
 2
 3import (
 4    "fmt"
 5)
 6
 7func main() {
 8    v := make([]string, 3)
 9    fmt.Printf("%v\n", len(v))
10    v = append(v, "Yellow", "Black")
11    fmt.Printf("%v\n", len(v))
12}

The above example append two values. Though you can provide any number of values to append.

You can use the “…” operator to expand a slice. This can be used to append one slice to another slice. See this example:

 1package main
 2
 3import (
 4    "fmt"
 5)
 6
 7func main() {
 8    v := make([]string, 3)
 9    fmt.Printf("%v\n", len(v))
10    a := []string{"Yellow", "Black"}
11    v = append(v, a...)
12    fmt.Printf("%v\n", len(v))
13}

In the above example, the first slice is appended by all items in another slice.

4.4.1. Slice Append Optimization

If you append too many values to a slice using a for loop, there is one optimization related that you need to be aware.

Consider this example:

 1package main
 2
 3import (
 4    "fmt"
 5)
 6
 7func main() {
 8    v := make([]string, 0)
 9    for i := 0; i < 9000000; i++ {
10        v = append(v, "Yellow")
11    }
12    fmt.Printf("%v\n", len(v))
13}

If you run the above program, it’s going to take few seconds to execute. To explain this, some understanding of internal structure of slice is required. Slice is implemented as a struct and an array within. The elements in the slice will be stored in the underlying array. As you know, the length of array is part of the array type. So, when appending an item to a slice the a new array will be created. To optimize, the append function actually created an array with double length.

In the above example, the underlying array must be changed many times. This is the reason why it’s taking few seconds to execute. The length of underlying array is called the capacity of the slice. Go provides a way to initialize the underlying array with a particular length. The make function has a fourth argument to specify the capacity.

In the above example, you can specify the capacity like this:

v := make([]string, 0, 9000000)

If you make this change and run the program again, you can see that it run much faster than the earlier code. The reason for faster code is that the slice capacity had already set with maximum required length.

4.5. Maps

Map is another important data structure in Go. We have briefly discussed about maps in the Quick Start chapter. As you know, map is an implementation of hash table. The hash table is available in many very high level languages. The data in map is organized like key value pairs.

A variable of map can be declared like this:

var fruits map[string]int

To make use that variable, it needs to be initialized using make function.

fruits = make(map[string]int)

You can also initialize using the := syntax:

fruits := map[string]int{}

or with var keywod:

var fruits = map[string]int{}

You can initialize map with values like this:

var fruits = map[string]int{
      "Apple":  45,
      "Mango":  24,
      "Orange": 34,
  }

After initializing, you can add new key value pairs like this:

fruits["Grape"] = 15

If you try to add values to maps without initializing, you will get an error like this:

panic: assignment to entry in nil map

Here is an example that’s going to produce panic error:

package main

func main() {
    var m map[string]int
    m["k"] = 7
}

To access a value corresponding to a key, you can use this syntax:

mangoCount := fruits["Mango"]

Here is an example:

package main

import "fmt"

func main() {
  var fruits = map[string]int{
      "Apple":  45,
      "Mango":  24,
      "Orange": 34,
  }
  fmt.Println(fruits["Apple"])
}

If the key doesn’t exist, a zero value will be returned. For example, in the below example, value of pineappleCount is going be 0.

package main

import "fmt"

func main() {
  var fruits = map[string]int{
      "Apple":  45,
      "Mango":  24,
      "Orange": 34,
  }
  pineappleCount := fruits["Pineapple"]
  fmt.Println(pineappleCount)
}

If you need to check if the key exist, the above syntax can be modified to return two values. The first one would be the actual value or zero value and the second one would be a boolean indicating if the key exists.

package main

import "fmt"

func main() {
  var fruits = map[string]int{
      "Apple":  45,
      "Mango":  24,
      "Orange": 34,
  }
  _, ok := fruits["Pineapple"]
  fmt.Println(ok)
}

In the above program, the first returned value is ignored using a blank identifier. And the value of ok variable is false. Normally, you can use if condition to check if the key exists like this:

package main

import "fmt"

func main() {
    var fruits = map[string]int{
        "Apple":  45,
        "Mango":  24,
        "Orange": 34,
    }
    if _, ok := fruits["Pineapple"]; ok {
        fmt.Println("Key exists.")
    } else {
        fmt.Println("Key doesn't exist.")
    }
}

To see the number of key/value pairs, you can use the built-in len function.

package main

import "fmt"

func main() {
    var fruits = map[string]int{
        "Apple":  45,
        "Mango":  24,
        "Orange": 34,
    }
    fmt.Println(len(fruits))
}

The above program should print 3 as the number of items in the map.

To remove an item from the map, use the built-in delete function.

package main

import "fmt"

func main() {
    var fruits = map[string]int{
        "Apple":  45,
        "Mango":  24,
        "Orange": 34,
    }
    delete(fruits, "Orange")
    fmt.Println(len(fruits))
}

The above program should print 2 as the number of items in the map after deleting one item.

4.6. Custom Data Types

Apart from the built-in data types, you can create your own custom data types. The type keyword can be used to create custom types. Here is an example.

package main

import "fmt"

type age int

func main() {
     a := age(2)
     fmt.Println(a)
     fmt.Printf("Type: %T\n", a)
}

If you run the above program, the output will be like this:

$ go run age.go
2
Type: age

4.6.1. Structs

Struct is a composite type with multiple fields of different types within the struct. For example, if you want to represent a person with name and age, the struct type will be helpful. The Person struct definition will look like this:

type Person struct {
    Name string
    Age  int
}

As you can see above, the Person struct is defined using type and struct keywords. Within the curly brace, attributes with other types are defined. If you avoid attributes, it will become an empty struct.

Here is an example empty struct:

type Empty struct {
}

Alternatively, the curly brace can be in the same line.

type Empty struct {}

A struct can be initialized various ways. Using a var statement:

var p1 Person
p1.Name = "Tom"
p1.Age = 10

You can give a literal form with all attribute values:

p2 := Person{"Polly", 50}

You can also use named attributes. In the case of named attributes, if you miss any values, the default zero value will be initialized.

p3 := Person{Name: "Huck"}
p4 := Person{Age: 10}

In the next, we are going to learn about funtions and methods. That chapter expands the discussion about custom types bevior changes through funtions associated with custom types called strcuts.

It is possible to embed structs inside other structs. Here is an example:

type Person struct {
     Name string
}

type Member struct {
     Person
     ID int
}

4.7. Pointers

When you are passing a variable as an argument to a function, Go creates a copy of the value and send it. In some situations, creating a copy will be expensive when the size of object is large. Another scenario where pass by value is not feasible is when you need to modify the original object inside the function. In the case of pass by value, you can modify it as you are getting a new object every time. Go supports another way to pass a reference to the original value using the memory location or address of the object.

To get address of a variable, you can use & as a prefix for the variable. Here in an example usage:

a := 7
fmt.Printf("%v\n", &a)

To get the value back from the address, you can use * as a prefix for the variable. Here in an example usage:

a := 7
b := &a
fmt.Printf("%v\n", *b)

Here is a complete example:

 1package main
 2
 3import (
 4    "fmt"
 5)
 6
 7func value(a int) {
 8    fmt.Printf("%v\n", &a)
 9}
10
11func pointer(a *int) {
12    fmt.Printf("%v\n", a)
13}
14
15func main() {
16    a := 4
17    fmt.Printf("%v\n", &a)
18    value(a)
19    pointer(&a)
20}

A typical output will be like this:

0xc42000a340
0xc42000a348
0xc42000a340

As you can see above, the second output is different from the first and third. This is because a value is passed instead of a pointer. And so when we are printing the address, it’s printing the address of the new variable.

In the functions chapter, the section about methods (section [sec:methods]) explains the pointer receiver.

4.7.1. new

The built-in function new can be used to allocate memory. It allocates zero values and returns the address of the given data type.

Here is an example:

name := new(string)

In this example, a string pointer value is allocated with zero value, in this case empty string, and assigned to a variable.

This above example is same as this:

var name *string
name = new(string)

In this one string pointer variable is declared, but it’s not allocated with zeror value. It will have nil value and so it cannot be dereferenced. If you try to reference, without allocating using the new function, you will get an error like this:

panic: runtime error: invalid memory address or nil pointer
dereference

Here is another example using a custom type defined using a primitive type:

type Temperature float64
name := new(Temperature)

4.8. Exercises

Exercise 1: Create a custom type for circle using float64 and define Area and Perimeter.

Solution:

 1package main
 2
 3import "fmt"
 4
 5type Circle float64
 6
 7func (r Circle) Area() float64 {
 8     return float64(3.14 * r * r)
 9}
10
11func (r Circle) Perimeter() float64 {
12     return float64(2 * 3.14 * r)
13}
14
15func main() {
16     c := Circle(4.0)
17     fmt.Println(c.Area())
18     fmt.Println(c.Perimeter())
19}

The custom Circle type is created using the built-in float64 type. It would be better if the circle is defined using a struct. Using struct helps to change the structure later with additional attributes. The struct will look like this:

type Circle struct {
    Radius float64
}

Exercise 2: Create a slice of structs where the struct represents a person with name and age.

Solution:

 1package main
 2
 3import "fmt"
 4
 5type Person struct {
 6     Name string
 7     Age int
 8}
 9
10func main() {
11     persons := []Person{
12             Person{Name: "Huck", Age: 11},
13             Person{Name: "Tom", Age: 10},
14             Person{Name: "Polly", Age: 52},
15     }
16     fmt.Println(persons)
17}

4.8.1. Additional Exercises

Answers to these additional exercises are given in the Appendix A.

Problem 1: Write a program to record temperatures for different locations and check if it’s freezing for a given place.

Problem 2: Create a map of world nations and details. The key could be the country name and value could be an object with details including capital, currency, and population.

4.9. Summary

This chapter introduced various data structures in Go. These data structures help organize data in a way that makes it easier to use and understand. The chapter started with a section about zero values, which are values that are assigned to variables when they are declared but not initialized. Then, constants were explained in detail, including the iota keyword, which can be used to define incrementing constants. Next, the chapter briefly explained arrays, which are data structures that can store a fixed number of elements of the same type. Then, slices were explained, which are more flexible data structures that can store a variable number of elements of the same type. Slices are built on top of arrays, and they inherit many of the same properties. Next, the chapter looked at how to define custom data types using existing primitive types. Custom data types can be used to group together related data and make it easier to work with. The struct was introduced as a way to define custom data types. Structs can be used to store a collection of related data items, such as the name, age, and address of a person. Pointers were also covered. Pointers are variables that store the address of another variable. Pointers can be used to access the value of another variable indirectly. The next chapter will explain more about functions and methods.