Post

Learning a Programming Language pt 2b: Go Language Mini-Course, continued (90 Days of DevOps)

Learning a Programming Language pt 2b: Go Language Mini-Course, continued (90 Days of DevOps)

This blog post is a continuation from the previous post, which works through a popular, publicly-available mini-course covering the Go programming language.

As in the previous post, full credit for the material goes to the course’s creator, TechWorld with Nana; below are simply my own notes and interpretation of their freely available course material, with occasional links added to web references that I found helpful.

Organizing code with Go packages

Go programs are organized into packages, and a program’s execution begins inside the main package. So far our program has consisted of only a main package, and that package has been contained completely within one source file, named main.go .

Creating a multi-file main package

Go packages don’t need to be contained entirely within a single source file. To demonstrate this:

  • We create a new .go file (helper.go), at the top of which we declare that the file is part of the main package.

    As a result, the main package will consist of two files, main.go and helper.go .

  • We move the user input validation function from main.go into our new helper.go file.

New source file: helper.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// helper.go
package main // declares this source file as part of the program's "main" package

// Package imports apply to the specific source file in which they are declared.
// The import below is required because a function from the "strings" package is
//  called elsewhere within this file.
import "strings"

// Below is our user input validation function, which has been moved from main.go
// into this file:
func validateUserBookingInfo(firstName, lastName, email string, quantity, remainingTicketCount uint) (bool, bool, bool, bool) {
    firstNameIsValid := len(firstName) >= 2
    lastNameIsValid := len(lastName) >= 2
    emailIsValid := strings.Contains(email, "@")
    quantityIsValid := (quantity <= remainingTicketCount) && (quantity > 0)

    return firstNameIsValid, lastNameIsValid, emailIsValid, quantityIsValid
}

To compile and run a Go program that is organized across multiple source files, we use the following syntax:

1
$ go run <filename1>, <filename2>, <filename3>, ...   # compiles program using exactly the source files named

or

1
$ go run .          # compiles program using all source files in the current directory / subdirectories

Below, we compile and run our program, which is now organized across two source files in the current directory:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ go run main.go helper.go
***
Welcome to the Go Conference booking application.
We have a total of 50 tickets and 50 are still available.
Get your tickets here to attend!

Enter your first name:
Philip
Enter your last name:
Fry
Enter your email address:
pf
How many tickets are you purchasing?:
50

Please fix these issues identified in your booking info:
--------------------------------------------------------
*Email address must contain the "@" symbol.
--------------------------------------------------------
The booking process will restart.

***
...

As demonstrated, our program was able to call the user info validation function even after that function was moved out of main.go and into helper.go.

This works because:

  • both main.go and helper.go are part of the same package (main), and
  • all necessary source files were included in the go run command.

Creating a multi-package program (main and helper packages)

Next, rather than limiting our program to just a main package, we decide to organize our program into multiple packages:

  • the original main package
  • a new helper package

We create a new subdirectory in our project named helper and move our helper.go source file into that subdirectory. To explicitly separate this code into its own package within our program, some updates are required to helper/helper.go :

  • We change the package statement at the start of the file, from main to our desired package name of helper
  • We capitalize the name of the function declared inside the file. This exports our function, making it accessible to other packages within our program.

    The capitalization is important because our function, now part of the helper package, will still need to be called from somewhere within the main package. Without a capitalized name, our function would not be available to any packages other than the one in which it is declared.

The changes to helper.go are shown below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// helper/helper.go
package helper // changed from "package main"
 
import "strings"

// The function name below is now capitalized, which is how we explicitly
// declare that it should be visible from outside of this new "helper" package.
func ValidateUserBookingInfo(firstName, lastName, email string, quantity, remainingTicketCount uint) (bool, bool, bool, bool) {
    firstNameIsValid := len(firstName) >= 2
    lastNameIsValid := len(lastName) >= 2
    emailIsValid := strings.Contains(email, "@")
    quantityIsValid := (quantity <= remainingTicketCount) && (quantity > 0)
  
    return firstNameIsValid, lastNameIsValid, emailIsValid, quantityIsValid
}
  

We must make some changes to main.go as well, to take into account that the user input validation function it calls, has been moved to its own package:

  • Our imports at the start of main.go must be updated to include the new helper package (using the syntax "<module_name>/<package_name>"). In this syntax, the module name serves as an import path prefix.
1
2
3
4
5
6
7
// main.go
import (
    "booking-app/helper" // provides the user info validation function, which has been moved to a new package named "helper"
    "fmt"
    "strings"
)
...
  • We update any calls made to the user input validation function, to reflect that the function is now part of a different package, and that the function’s name is now capitalized (for export).

    The syntax to call our function from another package is the same as we’ve been using to call functions from the fmt and strings packages: <package_name>.<Function_name>(<arguments>) :

1
2
3
4
5
...
// The user input validation function is now called as
// helper.ValidateUserBookingInfo() instead of validateUserBookingInfo()
firstNameValid, lastNameValid, emailValid, quantityValid := helper.ValidateUserBookingInfo(firstName, lastName, email, quantity, remainingTicketCount)
...

After these changes, the program works just as before. However, now the code has been logically reorganized into two separate packages, main and helper .

We can create new packages within our program as needed, to help make the overall code base more modular and organized.

Scope rules in Go

A variable’s scope determines where it is “visible” in a program.

  • Local variable: accessible only within the block or function where it is declared

  • Package-level variable: accessible across an entire, single Go Package; specified by declaring the variable outside all of the package’s functions

  • Universe (Global)-level variable: usable across packages in a Go module; specified by declaring the variable outside all of a package’s functions AND capitalizing its name

Maps

In the Go programming language, a map is a way of storing key-value pairs. This construct is also known as a hash table.

To declare an empty map, the following syntax is used:

var <map_name> = make(map[<key_datatype>]value_datatype)

or alternatively:

<map_name> := make(map[<key_datatype>]value_datatype)

*Note that unlike some other programming languages, all keys and all values in Go maps (hash tables) must adhere to a single data type each across the entire map.

For example:

1
2
3
// Below, all map indexes must be of type int, and all map values must be of
// type string
example_map := make(map[int]string)

The last time we ran our program, it used bookedFullNames (a slice containing strings), to store users’ full names in series.

Instead of storing only names, we now decide to store a complete set of booking info (first name, last name, email address, and ticket quantity) for each attendee.

We do this by storing a map of data per user. Each user’s map will contain a set of key/value pairs detailing that user’s booking info.

Before using maps:

(Used a slice of strings)

1
2
3
4
5
6
7
...
// Create an empty slice (of strings) to store the full name of each attendee
bookedFullNames := []string{}
...
// Inside our "bookUserTickets" function, add the current attendee's name to slice
bookedFullNamesOut = append(bookedFullNamesIn, firstName+" "+lastName)
...

After using maps:

In the next version of our conference booking program, we incorporate a list of maps, allowing a set of key/value data to be stored per attendee.

For simplicity, we revert back to a single-package format (though we do choose to separate our input validation function into a second source file):

Booking app Example 4

main.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
package main

import (
	"fmt"
	"strconv"
)

const conferenceName string = "Go Conference"
const conferenceTickets uint = 50

// Creates an empty list of maps, scoped to this package
var attendeesInfo = make([]map[string]string, 0)

func main() {
    var remainingTicketCount uint = 50

	for { // infinite loop
        var ticketsAreAvailable bool = remainingTicketCount != 0
        var noTicketsAreAvailable bool = !ticketsAreAvailable

        greetUser(remainingTicketCount, ticketsAreAvailable)

        if noTicketsAreAvailable {
            break // exits current loop (the infinite for loop)
        }

        firstName, lastName, email, quantity := getUserBookingInfo()
        firstNameValid, lastNameValid, emailValid, quantityValid := validateUserBookingInfo(firstName, lastName, email, quantity, remainingTicketCount)

        fullNameValid := firstNameValid && lastNameValid
        allInfoValid := fullNameValid && emailValid && quantityValid

        if !allInfoValid {
            fmt.Printf("\nPlease fix these issues identified in your booking info:\n")
            fmt.Println("--------------------------------------------------------")

            if !fullNameValid {
                fmt.Println("*First and last name must each contain 2 or more characters.")
            }

            if !emailValid {
                fmt.Println("*Email address must contain the \"@\" symbol.")
            }

            if !quantityValid {
                fmt.Printf("*You can book a minimum of 1 and a maximum of %v tickets for this conference.\n", remainingTicketCount)
            }

            fmt.Println("--------------------------------------------------------")
            fmt.Printf("The booking process will restart.\n\n")
            continue // skips back to start of loop (the infinite for loop)
        } else {
            // Book user's tickets and update the remaining ticket count and attendee info
            remainingTicketCount = bookUserTickets(firstName, lastName, email, quantity, remainingTicketCount)
        }
    }
}

func greetUser(remainingTicketCount uint, ticketsAreAvailable bool) {
    fmt.Println("***") // visual separator between transactions
    fmt.Printf("Welcome to the %v booking application.\n", conferenceName)
    fmt.Printf("We have a total of %v tickets and %v are still available.\n", conferenceTickets, remainingTicketCount)

    if ticketsAreAvailable {
        fmt.Println("Get your tickets here to attend!")
    } else {
        fmt.Println("Our conference is fully booked. Please join us next year.")
    }

    fmt.Println()
}

func getUserBookingInfo() (firstName, lastName, email string, quantity uint) {
    fmt.Println("Enter your first name:")
    fmt.Scan(&firstName) // user types input

    fmt.Println("Enter your last name:")
    fmt.Scan(&lastName) // user types input

    fmt.Println("Enter your email address:")
    fmt.Scan(&email) // user types input

    fmt.Println("How many tickets are you purchasing?:")
    fmt.Scan(&quantity) // user types input

    return firstName, lastName, email, quantity
}

func bookUserTickets(firstName, lastName, email string, quantity, remainingTicketCountIn uint) (remainingTicketCountOut uint) {
    // creates a locally-scoped map to store user booking info within this function
    userBookingInfo := make(map[string]string)

    // stores key-value data within the map, based on the arguments passed to
    //  this function
    userBookingInfo["firstName"] = firstName
    userBookingInfo["lastName"] = lastName
    userBookingInfo["email"] = email
    // Note below that the quantity value (which was passed in as a function
    //  argument of type uint) is explicitly converted to a string in order to
    //  conform to the map's declared type of "string" for all values
    userBookingInfo["quantity"] = strconv.FormatUint(uint64(quantity), 10)

    attendeesInfo = append(attendeesInfo, userBookingInfo)
    remainingTicketCountOut = remainingTicketCountIn - quantity

    // provide confirmation to user
    fmt.Println()
    fmt.Printf("Thank you %v %v for booking %v tickets.\nYou will receive a confirmation at %v.\n", userBookingInfo["firstName"], userBookingInfo["lastName"], userBookingInfo["quantity"], userBookingInfo["email"])
    firstNames := getFirstNames()
    printAttendeeFirstNames(firstNames)
    fmt.Println()

    return // returns updated remaining ticket count and attendees list for this
           //  conference
}

func getFirstNames() []string {
    firstNames := []string{}
    for _, v := range attendeesInfo {
        firstNames = append(firstNames, v["firstName"])
    }

    return firstNames
}

func printAttendeeFirstNames(names []string) {
    fmt.Printf("These are our current attendees: %v\n", names)
}

helper.go

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "strings"

func validateUserBookingInfo(firstName, lastName, email string, quantity, remainingTicketCount uint) (bool, bool, bool, bool) {
	firstNameIsValid := len(firstName) >= 2
	lastNameIsValid := len(lastName) >= 2
	emailIsValid := strings.Contains(email, "@")
	quantityIsValid := (quantity <= remainingTicketCount) && (quantity > 0)

	return firstNameIsValid, lastNameIsValid, emailIsValid, quantityIsValid
}

Structs

In Go, a struct is a custom data type we declare in our code. It can be likened to a class in some other popular languages, and it behaves as a collection of fields. Structs are useful for grouping multiple types of data into a single data structure.

To declare a struct, the following syntax is used:

1
2
3
4
5
6
 type struct_name struct {
  member1 datatype;
  member2 datatype;
  member3 datatype;
  ...
}

We decide to use a struct in our program, in order to store a set of booking info (containing multiple types of data) for each of our conference attendees.

Before using structs:

(Used a list of maps)

The previous version of our program was limited to storing data of the string type; that was because maps in Go are limited to one data type each across all keys and across all values.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// main.go
package main

import (
    "fmt"
    "strconv"
)
...
var attendeesInfo = make([]map[string]string, 0)
...
func bookUserTickets(firstName, lastName, email string, quantity, remainingTicketCountIn uint) (remainingTicketCountOut uint) {
    userBookingInfo := make(map[string]string)
    userBookingInfo["firstName"] = firstName
    userBookingInfo["lastName"] = lastName
    userBookingInfo["email"] = email
    userBookingInfo["quantity"] = strconv.FormatUint(uint64(quantity), 10)

    attendeesInfo = append(attendeesInfo, userBookingInfo)
    remainingTicketCountOut = remainingTicketCountIn - quantity

    // provide confirmation to user
    fmt.Println()
    fmt.Printf("Thank you %v %v for booking %v tickets.\nYou will receive a confirmation at %v.\n", userBookingInfo["firstName"], userBookingInfo["lastName"], userBookingInfo["quantity"], userBookingInfo["email"])
    firstNames := getFirstNames()
    printAttendeeFirstNames(firstNames)
    fmt.Println()

    return // returns updated remaining ticket count and attendees list for this
           //  conference
}

func getFirstNames() []string {
    firstNames := []string{}
    for _, v := range attendeesInfo {
        firstNames = append(firstNames, v["firstName"])
    }

    return firstNames
}
...

After using structs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// main.go
package main

import "fmt" // notice that the "strconv" package is no longer needed
...
// What was previously a list of maps is now a list of structs:
var attendeesInfo = make([]UserData, 0)
...
func bookUserTickets(firstName, lastName, email string, quantity, remainingTicketCountIn uint) (remainingTicketCountOut uint) {
    // We create a locally-scoped struct to store user booking info within
    //  this function
    userBookingInfo := UserData{
        firstName: firstName,
        lastName:  lastName,
        email:     email,
        quantity:  quantity, // the value of the "quantity" function argument no longer needs to be converted to string, since our struct supports multiple data types
    }

    attendeesInfo = append(attendeesInfo, userBookingInfo)
    remainingTicketCountOut = remainingTicketCountIn - quantity

    fmt.Println()
    // individual struct fields are referenced using "dot" notation
    fmt.Printf("Thank you %v %v for booking %v tickets.\nYou will receive a confirmation at %v.\n", userBookingInfo.firstName, userBookingInfo.lastName, userBookingInfo.quantity, userBookingInfo.email)
    firstNames := getFirstNames()
    printAttendeeFirstNames(firstNames)
    fmt.Println()

    return
}

func getFirstNames() []string {
    firstNames := []string{}
    for _, v := range attendeesInfo {
        // again, individual struct fields are referenced using "dot" notation
        firstNames = append(firstNames, v.firstName)
    }

    return firstNames
}
...

With these changes in place, the program works the same from an end-user perspective, but behind the scenes:

  • The user’s booking info now contains data of multiple types

    Before this last update to our program, only string data was maintained for each user. Numeric data, i.e. each user’s booked ticket quantity, had to be converted to the string data type. After the update, each user’s booked ticket quantity is stored as a field of type uint within our new UserData struct.

  • The code is more readable and maintainable, thanks to simpler logic

    Thanks to native handling of numeric data within the struct, fewer package inclusions are required from standard library; the above-mentioned data type conversion, which depended on the strconv package, is no longer required.

This blog post ended up growing longer than originally planned, so the remainder of the Go language topics will be covered in the next post instead. That post will cover Goroutines / Concurrency. See you there!

<< Back to 90 Days of DevOps posts

<<< Back to all posts

This post is licensed under CC BY-NC-SA 4.0 by the author.