Search icon CANCEL
Arrow left icon
Explore Products
Best Sellers
New Releases
Books
Videos
Audiobooks
Learning Hub
Conferences
Free Learning
Arrow right icon
Arrow up icon
GO TO TOP
Mastering Go

You're reading from   Mastering Go Leverage Go's expertise for advanced utilities, empowering you to develop professional software

Arrow left icon
Product type Paperback
Published in Mar 2024
Publisher Packt
ISBN-13 9781805127147
Length 736 pages
Edition 4th Edition
Languages
Arrow right icon
Author (1):
Arrow left icon
Mihalis Tsoukalos Mihalis Tsoukalos
Author Profile Icon Mihalis Tsoukalos
Mihalis Tsoukalos
Arrow right icon
View More author details
Toc

Table of Contents (19) Chapters Close

Preface 1. A Quick Introduction to Go 2. Basic Go Data Types FREE CHAPTER 3. Composite Data Types 4. Go Generics 5. Reflection and Interfaces 6. Go Packages and Functions 7. Telling a UNIX System What to Do 8. Go Concurrency 9. Building Web Services 10. Working with TCP/IP and WebSocket 11. Working with REST APIs 12. Code Testing and Profiling 13. Fuzz Testing and Observability 14. Efficiency and Performance 15. Changes in Recent Go Versions 16. Other Books You May Enjoy
17. Index
Appendix: The Go Garbage Collector

What you should know about Go

This big section discusses important and essential Go features including variables, controlling program flow, iterations, getting user input, and Go concurrency. We begin by discussing variables, variable declaration, and variable usage.

Defining and using variables

Imagine that you want to perform basic mathematical calculations. In that case, you need to define variables to keep the input, intermediate computations, and results.

Go provides multiple ways to declare new variables to make the variable declaration process more natural and convenient. You can declare a new variable using the var keyword, followed by the variable name, followed by the desired data type (we are going to cover data types in detail in Chapter 2, Basic Go Data Types). If you want, you can follow that declaration with = and an initial value for your variable. If there is an initial value given, you can omit the data type and the compiler will infer it for you.

This brings us to a very important Go rule: if no initial value is given to a variable, the Go compiler will automatically initialize that variable to the zero value of its data type.

There is also the := notation, which can be used instead of a var declaration. := defines a new variable by inferring the data of the value that follows it. The official name for := is short assignment statement, and it is very frequently used in Go, especially for getting the return values from functions and for loops with the range keyword.

The short assignment statement can be used in place of a var declaration with an implicit type. You rarely see the use of var in Go; the var keyword is mostly used for declaring global or local variables without an initial value. The reason for the former is that every statement that exists outside of the code of a function must begin with a keyword, such as func or var.

This means that the short assignment statement cannot be used outside of a function environment because it is not permitted there. Last, you might need to use var when you want to be explicit about the data type. For example, when you want the type of a variable to be int8 or int32 instead of int, which is the default.

Constants

There are values, such as the mathematical constant Pi, that cannot change. In that case, we can declare such values as constants using const. Constants are declared just like variables but cannot change once they have been declared.

The supported data types for constants are character, string, Boolean, and all numeric data types. There’s more about Go data types in Chapter 2, Basic Go Data Types.

Global variables

Global variables are variables that are defined outside of a function implementation. Global variables can be accessed from anywhere in a package without the need to explicitly pass them to a function, and they can be changed unless they were defined as constants, using the const keyword.

Although you can declare local variables using either var or :=, only const (when the value of a variable is not going to change) and var work for global variables.

Printing variables

Programs tend to display information, which means that they need to print data or send it somewhere for other software to store or process it. To print data on the screen, Go uses the functionality of the fmt package. If you want Go to take care of the printing, then you might want to use the fmt.Println() function. However, there are times when you want to have full control over how data is going to be printed. In such cases, you might want to use fmt.Printf().

fmt.Printf() is similar to the C printf() function and requires the use of control sequences that specify the data type of the variable that is going to be printed. Additionally, the fmt.Printf() function allows you to format the generated output, which is particularly convenient for floating point values because it allows you to specify the digits that will be displayed in the output (%.2f displays two digits after the decimal point of a floating point value). Lastly, the \n character is used for printing a newline character and, therefore, creating a new line, as fmt.Printf() does not automatically insert a newline—this is not the case with fmt.Println(), which automatically inserts a newline, hence the ln at the end of its name.

The following program illustrates how you can declare new variables, how to use them, and how to print them—type the following code into a plain text file named variables.go:

package main
import (
    "fmt"
    "math"
)
var Global int = 1234
var AnotherGlobal = -5678
func main() {
    var j int
    i := Global + AnotherGlobal
    fmt.Println("Initial j value:", j)
    j = Global
    // math.Abs() requires a float64 parameter
    // so we type cast it appropriately
    k := math.Abs(float64(AnotherGlobal))
    fmt.Printf("Global=%d, i=%d, j=%d k=%.2f.\n", Global, i, j, k)
}

Personally, I prefer to make global variables stand out by either beginning them with an uppercase letter or using all capital letters. As you are going to learn in Chapter 6, Go Packages and Functions, the case of the first character of a variable name has a special meaning in Go and changes its visibility. So this works for the main package only.

This above program contains the following:

  • A global int variable named Global.
  • A second global variable named AnotherGlobal—Go automatically infers its data type from its value, which in this case is an integer.
  • A local variable named j of type int, which, as you will learn in the next chapter, is a special data type. j does not have an initial value, which means that Go automatically assigns the zero value of its data type, which in this case is 0.
  • Another local variable named i—Go infers its data type from its value. As it is the sum of two int values, it is also an int.
  • As math.Abs() requires a float64 parameter, you cannot pass AnotherGlobal to it because AnotherGlobal is an int variable. The float64() type cast converts the value of AnotherGlobal to float64. Note that AnotherGlobal continues to have the int data type.
  • Lastly, fmt.Printf() formats and prints the output.

Running variables.go produces the following output:

Initial j value: 0
Global=1234, i=-4444, j=1234 k=5678.00.

This example demonstrated another important Go rule that was also mentioned previously: Go does not allow implicit data conversions like C. As presented in variables.go, the math.Abs() function that expects (requires) a float64 value cannot work with an int value, even if this particular conversion is straightforward and error-free. The Go compiler refuses to compile such statements. You should convert the int value to a float64 explicitly using float64() for things to work properly.

For conversions that are not straightforward (for example, string to int), there exist specialized functions that allow you to catch issues with the conversion, in the form of an error variable that is returned by the function.

Controlling program flow

So far, we have seen Go variables, but how do we change the flow of a Go program based on the value of a variable or some other condition? Go supports the if/else and switch control structures. Both control structures can be found in most modern programming languages, so if you have already programmed in another programming language, you should already be familiar with both if and switch statements. if statements use no parenthesis to embed the conditions that need to be examined because Go does not use parentheses in general. As expected, if has support for else and else if statements.

To demonstrate the use of if, let us use a very common pattern in Go that is used almost everywhere. This pattern says that if the value of an error variable as returned from a function is nil, then everything is OK with the function execution. Otherwise, there is an error condition somewhere that needs special care. This pattern is usually implemented as follows:

err := anyFunctionCall()
if err != nil {
    // Do something if there is an error
}

err is the variable that holds the error value as returned from a function and != means that the value of the err variable is not equal to nil. You will see similar code multiple times in Go programs.

Lines beginning with // are single-line comments. If you put // in the middle of a line, then everything after // until the end of the line is considered a comment. This rule does not apply if // is inside a string value.

The switch statement has two different forms. In the first form, the switch statement has an expression that is evaluated, whereas in the second form, the switch statement has no expression to evaluate. In that case, expressions are evaluated in each case statement, which increases the flexibility of switch. The main benefit you get from switch is that when used properly, it simplifies complex and hard-to-read if-else blocks.

Both if and switch are illustrated in the following code, which is designed to process user input given as command line arguments—please type it and save it as control.go. For learning purposes, we present the code of control.go in pieces in order to explain it better:

package main
import (
    "fmt"
    "os"
    "strconv"
)

This first part contains the expected preamble with the imported packages. The implementation of the main() function starts next:

func main() {
    if len(os.Args) != 2 {
        fmt.Println("Please provide a command line argument")
        return
    }
    argument := os.Args[1]

This part of the program makes sure that you have a single command line argument to process, which is accessed as os.Args[1], before continuing. We will cover this in more detail later, but you can refer to Figure 1.2 for more information about the os.Args slice:

    // With expression after switch
    switch argument {
    case "0":
        fmt.Println("Zero!")
    case "1":
        fmt.Println("One!")
    case "2", "3", "4":
        fmt.Println("2 or 3 or 4")
        fallthrough
    default:
        fmt.Println("Value:", argument)
    }

Here, you see a switch block with four branches. The first three require exact string matches and the last one matches everything else. The order of the case statements is important because only the first match is executed. The fallthrough keyword tells Go that after this branch is executed, it will continue with the next branch, which in this case is the default branch:

    value, err := strconv.Atoi(argument)
    if err != nil {
        fmt.Println("Cannot convert to int:", argument)
        return
    }

As command line arguments are initialized as string values, we need to convert user input into an integer value using a separate call, which in this case is a call to strconv.Atoi(). If the value of the err variable is nil, then the conversion was successful, and we can continue. Otherwise, an error message is printed onscreen and the program exits.

The following code shows the second form of switch, where the condition is evaluated at each case branch:

    // No expression after switch
    switch {
    case value == 0:
        fmt.Println("Zero!")
    case value > 0:
        fmt.Println("Positive integer")
    case value < 0:
        fmt.Println("Negative integer")
    default:
        fmt.Println("This should not happen:", value)
    }
}

This gives you more flexibility but requires more thinking when reading the code. In this case, the default branch should not be executed, mainly because any valid integer value would be caught by the other three branches. Nevertheless, the default branch is there, which is good practice because it can catch unexpected values.

Running control.go generates the following output:

$ go run control.go 10
Value: 10
Positive integer
$ go run control.go 0
Zero!
Zero!

Each one of the two switch blocks in control.go creates one line of output.

Iterating with for loops and range

This section is all about iterating in Go. Go supports for loops as well as the range keyword to iterate over all the elements of arrays, slices, and (as you will see in Chapter 3, Composite Data Types) maps, without knowing the size of the data structure.

An example of Go simplicity is the fact that Go provides support for the for keyword only, instead of including direct support for while loops. However, depending on how you write a for loop, it can function as a while loop or an infinite loop. Moreover, for loops can implement the functionality of JavaScript’s forEach function when combined with the range keyword.

You need to put curly braces around a for loop even if it contains just a single statement or no statements at all.

You can also create for loops with variables and conditions. A for loop can be exited with a break keyword, and you can skip the current iteration with the continue keyword.

The following program illustrates the use of for on its own and with the range keyword—type it and save it as forLoops.go to execute it afterward:

package main
import "fmt"
func main() {
    // Traditional for loop
    for i := 0; i < 10; i++ {
        fmt.Print(i*i, " ")
    }
    fmt.Println()
}

The previous code illustrates a traditional for loop that uses a local variable named i. This prints the squares of 0, 1, 2, 3, 4, 5, 6, 7, 8, and 9 onscreen. The square of 10 is not computed and printed because it does not satisfy the 10 < 10 condition.

The following code is idiomatic Go and produces the same output as the previous for loop:

    i := 0
    for ok := true; ok; ok = (i != 10) {
        fmt.Print(i*i, " ")
        i++
    }
    fmt.Println()

You might use it, but it is sometimes hard to read, especially for people who are new to Go. The following code shows how a for loop can simulate a while loop, which is not supported directly:

    // For loop used as while loop
    i = 0
    for {
        if i == 10 {
            break
        }
        fmt.Print(i*i, " ")
        i++
    }
    fmt.Println()

The break keyword in the if condition exits the loop early and acts as the loop exit condition. Without an exit condition that is going to be met at some point and the break keyword, the for loop is never going to finish.

Lastly, given a slice, which you can consider as a resizable array, named aSlice, you iterate over all its elements with the help of range, which returns two ordered values: the index of the current element in the slice and its value. If you want to ignore either of these return values, which is not the case here, you can use _ in the place of the value that you want to ignore. If you just need the index, you can leave out the second value from range entirely without using _:

    // This is a slice but range also works with arrays
    aSlice := []int{-1, 2, 1, -1, 2, -2}
    for i, v := range aSlice {
        fmt.Println("index:", i, "value: ", v)
    }

If you run forLoops.go, you get the following output:

$ go run forLoops.go
0 1 4 9 16 25 36 49 64 81
0 1 4 9 16 25 36 49 64 81
0 1 4 9 16 25 36 49 64 81
index: 0 value:  -1
index: 1 value:  2
index: 2 value:  1
index: 3 value:  -1
index: 4 value:  2
index: 5 value:  -2

The previous output illustrates that the first three for loops are equivalent and, therefore, produce the same output. The last six lines show the index and the value of each element found in aSlice.

Now that we know about for loops, let us see how to get user input.

Getting user input

Getting user input is an important part of the majority of programs. This section presents two ways of getting user input, which read from standard input and use the command line arguments of the program.

Reading from standard input

The fmt.Scanln() function can help you read user input while the program is already running and store it to a string variable, which is passed as a pointer to fmt.Scanln(). The fmt package contains additional functions for reading user input from the console (os.Stdin), files, or argument lists.

The fmt.Scanln() function is rarely used to get user input. Usually, user input is read from command line arguments or external files. However, interactive command line applications need fmt.Scanln().

The following code illustrates reading from standard input—type it and save it as input.go:

package main
import (
    "fmt"
)
func main() {
    // Get User Input
    fmt.Printf("Please give me your name: ")
    var name string
    fmt.Scanln(&name)
    fmt.Println("Your name is", name)
}

While waiting for user input, it is good to let the user know what kind of information they have to give, which is the purpose of the fmt.Printf() call. The reason for not using fmt.Println() instead is that fmt.Println() automatically appends a newline character at the end of the output, which is not what we want here.

Executing input.go generates the following kind of output and user interaction:

$ go run input.go
Please give me your name: Mihalis
Your name is Mihalis

Working with command line arguments

Although typing user input when needed might look like a nice idea, this is not usually how real software works. Usually, user input is given in the form of command line arguments to the executable file. By default, command line arguments in Go are stored in the os.Args slice.

The standard Go library also offers the flag package for parsing command line arguments, but there are better and more powerful alternatives.

The figure that follows shows the way command line arguments work in Go, which is the same as in the C programming language. It is important to know that the os.Args slice is properly initialized by Go and is available to the program when referenced. The os.Args slice contains string values:

A picture containing text, screenshot, font, black  Description automatically generated

Figure 1.2: How the os.Args slice works

The first command line argument stored in the os.Args slice is always the file path of the executable. If you use go run, you will get a temporary name and path; otherwise, it will be the path of the executable as given by the user. The remaining command line arguments are what come after the name of the executable—the various command line arguments are automatically separated by space characters unless they are included in double or single quotes; this depends on the OS.

The use of os.Args is illustrated in the code that follows, which is to find the minimum and the maximum numeric values of its input while ignoring invalid input, such as characters and strings. Type the code and save it as cla.go:

package main
import (
    "fmt"
    "os"
    "strconv"
)

As expected, cla.go begins with its preamble. The fmt package is used for printing output, whereas the os package is required because os.Args is a part of it. Lastly, the strconv package contains functions for converting strings to numeric values. Next, we make sure that we have at least one command line argument:

func main() {
    arguments := os.Args
    if len(arguments) == 1 {
        fmt.Println("Need one or more arguments!")
        return
    }

Remember that the first element in os.Args is always the path of the executable file, so os.Args is never totally empty. Next, the program checks for errors in the same way we looked for them in previous examples. You will learn more about errors and error handling in Chapter 2, Basic Go Data Types:

    var min, max float64
    var initialized = 0
    for i := 1; i < len(arguments); i++ {
        n, err := strconv.ParseFloat(arguments[i], 64)
        if err != nil {
            continue
        }

In this case, we use the error variable returned by strconv.ParseFloat() to make sure that the call to strconv.ParseFloat() was successful and there is a valid numeric value to process. Otherwise, we should continue to the next command line argument.

The for loop is used to iterate over all available command line arguments except the first one, which uses an index value of 0. This is another popular technique for working with all command line arguments.

The following code is used to properly initialize the value of the min and max variables after the first valid command line argument is processed:

        if initialized == 0 {
            min = n
            max = n
            initialized = 1
            continue
        }

We are using initialized == 0 to test whether this is the first valid command line argument. If this is the case, we process the first command line argument and initialize the min and max variables to its value.

The next code checks whether the current value is our new minimum or maximum—this is where the logic of the program is implemented:

        if n < min {
            min = n
        }
        if n > max {
            max = n
        }
    }
    fmt.Println("Min:", min)
    fmt.Println("Max:", max)
}

The last part of the program is about printing your findings, which are the minimum and maximum numeric values of all valid command line arguments. The output you get from cla.go depends on its input:

$ go run cla.go a b 2 -1
Min: -1
Max: 2

In this case, a and b are invalid, and the only valid inputs are -1 and 2, which are the minimum value and maximum value, respectively:

$ go run cla.go a 0 b -1.2 10.32
Min: -1.2
Max: 10.32

In this case, a and b are invalid input and, therefore, ignored:

$ go run cla.go
Need one or more arguments!

In the final case, as cla.go has no input to process, it prints a help message. If you execute the program with no valid input values, for example, go run cla.go a b c, then the values of both Min and Max are going to be zero.

The next subsection shows a technique for differentiating between different data types, using error variables.

Using error variables to differentiate between input types

Now, let me show you a technique that uses error variables to differentiate between various kinds of user input. For this technique to work, you should go from more specific cases to more generic ones. If we are talking about numeric values, you should first examine whether a string is a valid integer before examining whether the same string is a floating-point value, because every valid integer is also a valid floating-point value.

The first part of the program, which is saved as process.go, is the following:

package main
import (
    "fmt"
    "os"
    "strconv"
)
func main() {
    arguments := os.Args
    if len(arguments) == 1 {
        fmt.Println("Not enough arguments")
        return
    }

The previous code contains the preamble and the storing of the command line arguments in the arguments variable.

The next part is where we start examining the validity of the input:

    var total, nInts, nFloats int
    invalid := make([]string, 0)
    for _, k := range arguments[1:] {
        // Is it an integer?
        _, err := strconv.Atoi(k)
        if err == nil {
            total++
            nInts++
            continue
        }

First, we create three variables for keeping a count of the total number of valid values examined, the total number of integer values found, and the total number of floating-point values found, respectively. The invalid variable, which is a slice of strings, is used for keeping all non-numeric values.

Once again, we need to iterate over all the command line arguments except the first one, which has an index value of 0, because this is the path of the executable file. We ignore the path of the executable, using arguments[1:] instead of just arguments—selecting a continuous part of a slice is discussed in the next chapter.

The call to strconv.Atoi() determines whether we are processing a valid int value or not. If so, we increase the total and nInts counters:

        // Is it a float
        _, err = strconv.ParseFloat(k, 64)
        if err == nil {
            total++
            nFloats++
            continue
        }

Similarly, if the examined string represents a valid floating-point value, the call to strconv.ParseFloat() is going to be successful, and the program will update the relevant counters. Lastly, if a value is not numeric, it is appended to the invalid slice with a call to append():

        // Then it is invalid
        invalid = append(invalid, k)
    }

The last part of the program is the following:

    fmt.Println("#read:", total, "#ints:", nInts, "#floats:", nFloats)
    if len(invalid) > total {
        fmt.Println("Too much invalid input:", len(invalid))
        for _, s := range invalid {
            fmt.Println(s)
        }
    }
}

Presented here is extra code that warns you when your invalid input is more than the valid one (len(invalid) > total). This is a common practice for keeping unexpected input in applications.

Running process.go produces the following kind of output:

$ go run process.go 1 2 3
#read: 3 #ints: 3 #floats: 0

In this case, we process 1, 2, and 3, which are all valid integer values:

$ go run process.go 1 2.1 a    
#read: 2 #ints: 1 #floats: 1

In this case, we have a valid integer, 1, a floating-point value, 2.1, and an invalid value, a:

$ go run process.go a 1 b
#read: 1 #ints: 1 #floats: 0
Too much invalid input: 2
a
b

If the invalid input is more than the valid one, then process.go prints an extra error message.

The next subsection discusses the concurrency model of Go.

Understanding the Go concurrency model

This section is a quick introduction to the Go concurrency model. The Go concurrency model is implemented using goroutines and channels. A goroutine is the smallest executable Go entity. To create a new goroutine, you have to use the go keyword followed by a predefined function or an anonymous function—both these methods are equivalent as far as Go is concerned.

The go keyword works with functions or anonymous functions only.

A channel in Go is a mechanism that, among other things, allows goroutines to communicate and exchange data. If you are an amateur programmer or are hearing about goroutines and channels for the first time, do not panic. Goroutines and channels, as well as pipelines and sharing data among goroutines, will be explained in much more detail in Chapter 8, Go Concurrency.

Although it is easy to create goroutines, there are other difficulties when dealing with concurrent programming, including goroutine synchronization and sharing data between goroutines—this is a Go mechanism for avoiding side effects by using global state when running goroutines. As main() runs as a goroutine as well, you do not want main() to finish before the other goroutines of the program because once main() exits, the entire program along with any goroutines that have not finished yet will terminate. Although goroutines cannot communicate directly with each other, they can share memory. The good thing is that there are various techniques for the main() function to wait for goroutines to exchange data through channels or, less frequently in Go, use shared memory.

Type the following Go program, which synchronizes goroutines using time.Sleep() calls (this is not the right way to synchronize goroutines—we will discuss the proper way to synchronize goroutines in Chapter 8, Go Concurrency), into your favorite editor, and save it as goRoutines.go:

package main
import (
    "fmt"
    "time"
)
func myPrint(start, finish int) {
    for i := start; i <= finish; i++ {
        fmt.Print(i, " ")
    }
    fmt.Println()
    time.Sleep(100 * time.Microsecond)
}
func main() {
    for i := 0; i < 4; i++ {
        go myPrint(i, 5)
    }
    time.Sleep(time.Second)
}

The preceding naively implemented example creates four goroutines and prints some values on the screen using the myPrint() function—the go keyword is used for creating the goroutines. Running goRoutines.go generates the following output:

$ go run goRoutines.go
2 3 4 5
0 4 1 2 3 1 2 3 4 4 5
5
3 4 5
5

However, if you run it multiple times, you will most likely get a different output each time:

1 2 3 4 5 
4 2 5 3 4 5 
3 0 1 2 3 4 5 
4 5

This happens because goroutines are initialized in a random order and start running in a random order. The Go scheduler is responsible for the execution of goroutines, just like the OS scheduler is responsible for the execution of the OS threads. Chapter 8, Go Concurrency, discusses Go concurrency in more detail and presents the solution to that randomness issue with the use of a sync.WaitGroup variable—however, keep in mind that Go concurrency is everywhere, which is the main reason for including this section here. Therefore, as some error messages generated by the compiler discuss goroutines, you should not think that these goroutines were created by you.

The next section shows a practical example that involves developing a Go version of the which(1) utility, which searches for an executable file in the PATH environment value of the current user.

lock icon The rest of the chapter is locked
Register for a free Packt account to unlock a world of extra content!
A free Packt account unlocks extra newsletters, articles, discounted offers, and much more. Start advancing your knowledge today.
Unlock this book and the full library FREE for 7 days
Get unlimited access to 7000+ expert-authored eBooks and videos courses covering every tech area you can think of
Renews at €18.99/month. Cancel anytime