Level 0 Go Tutorial
A note on Go
Go is a language developed by Google. It's a fast, compiled language thats geared towards concurrent workflows and web applications.
For this reason, we use it for the backend of our ground control station.
1 "Hello World" Program
Let's start by making sure that you have a working Go development environment set up to run a Hello World program.
1.1 Installing Go
To compile Go source code, you will need to download the Go compiler and it's associated utilities.
There are two ways we recommend installing Go.
- Binary Distribution
- via a Go version manager
To download the binary distribution of Go, download the version for your operating system and architecture from here and follow the installation instructions here.
A Go version manager can help you manage your Go installation and switch between multiple Go versions.
Here is one that we recommend, but you're free to use your own.
If you use g, make sure to install a version of Go.
You can install the latest like so:
Or a specific version:
Once Go is installed, verify with the following command:
1.1.1 Installing Go Editor and Tools
You're free to use the text editor or IDE of your choice. However, you might want tools such as autocompletion and syntax highlighting to help you during development. We have a few recommendations when it comes to working with Go.
VS Code
VS Code is a text editor developed by Microsoft that is a popular choice among many developers. While it doesn't come with built-in support for many languages, it can be extended with community based extensions from the VS Code Marketplace. It is a great choice if you want a single editor no matter what language you are working with.
While, VS Code itself doesn't come built with Go support, it can easily be obtained via an extension.
First install VS Code from here.
Open VS Code and click on the Extension Marketplace Button on the left sidebar:
Search for Go and install the first one from the "Go team at Google".
Once it's installed, you should have syntax highlighting, autocompletion and much more all setup.
Goland
Goland is a full IDE developed by Jetbrains with first class support for Go. If you've used other Jetbrains products such as Intellij IDEA for Java, you may already be comfortable with using Goland.
Goland is a paid IDE. Pricing is available here and you can download it from here.
However, you may be eligible for a free copy of Goland if you register using your UCSD email address. More information about this program can be found here.
Vim/Neovim
Vim and Neovim are lightweight, terminal-based text editors with. Similarly to VS Code they have no built-in Go tools. Getting Vim/Neovim setup for Go is a bit more involved than installing an extension for VS Code. So if you want something that just works as soon as possible, go with VS Code.
You can add Go support with various extensions. This extension provides Go support for Vim. For neovim, you can setup a language server that supports Go. This is one extension that will get you up and running relatively quickly. There are also distributions of Neovim that come built in with Go support. LunarVim is one of those distributions that comes built in with Go support.
1.2 Running a Go Program
As mentioned, Go is a compiled language so we can build an executable file that will run natively for our specific architecture and operating system.
Here's a Go "Hello World" program. It'll just be presented here and will be explained in just a second. Create a file called main.go
with the following contents:
Once the file has been created and saved, enter your terminal and run the following command to build the executable file:
You should now see the executable file that was created. Run ls
to verify.
You can run the binary via your shell as so:
If you got to this stage, you have successfully set up a development environment for Go.
There is another way you can run your Go programs. It involves the go run
command.
go run
will compile and run the program defined in the specified file. If you try to run go run main.go
, the "Hello World" program should run just the same.
1.3 Explaining Hello World
Now that we have a working "Hello World" program, let's figure out how it works.
If we go line by line in the program, the first line looks like this:
This is where we define the package that our project belongs to. Packages are how we organize components of our project. We can group together functions, types, constants, and more. We can also group together multiple Go source code files into a single package.
The main
package contains the entrypoint of our program.
The next line of code looks like this:
This is an import statement that imports the fmt
package. The fmt
package includes handy functions that help us with text formatting and outputting text to the terminal. The fmt
package is provided by the Go standard library. We can import a package from the standard library, from a 3rd party library, or our own code. By importing the package, we can access it's functionality from our program.
The next line looks like this:
This is the start of where our main
function is declared. This line specifically is the function header (also called function signature). main
is a special function which is the entrypoint of our program. When we run an exectable, whatever is in main
gets executed first. If you've worked in languages such as Java you might be familiar with declaring a main method with the signature of public static void main(String[] args)
. The main
function in Go works just the same and must be declared for any Go program.
If you're not familiar with functions from another language, they are blocks of code that perform a task. They are defined somewhere and can be reused multiple times. They can take zero or more inputs and produce an output. We'll get to talking about how functions are defined in Go later on.
Inside the body of our main function, we have this line:
This is the real meat and potatoes of the "Hello World program". We are calling the Println
function that is defined within the fmt
package. Println
will take in any number of inputs, also known as arguments, and display them to the terminal. In this case, we only provide a single argument: "Hello World!"
.
This argument, "Hello World!"
, is a type of data called a string. If you're familiar with strings from another language, you should already be familiar with how they work in Go. If not, strings are types of data that allow us to store text. Strings are just a sequence of characters. When repsresenting a string in Go, we wrap it in double quotes. Here we have created the string with the value of Hello World!
and passed it as an argument to our call to fmt.Println
.
The last line in our program is a closing curly brace to signify the end of the declaration of the main
function.
And with that, we've seen how a simple Go program works.
2 Go Basics
2.1 Variables
Variables can be thought of as containers that can store some peice of data. We can reference the underlying data of a variable using it's variable name.
Variables can store an assortment of data types. We'll get to talking about most of them in the next section.
2.1.1 Declaration
In Go, there are two main ways of declaring variables.
The first is a bit more verbose and requires that you specify the type of the variable in question.
In this case, we declared a variable namedx
, of type int
(integer) with a value of 5
.
You can verify this by printing the variable out after declaration.
We also technically don't need to specify the value itself. We could also declare x
like so:
x
have?". If we don't specify a value, the variable takes on the zero value associated with it's type. For some types, like int
, the zero value is obvious. In this case, the zero value is just the numerical value of 0. For other types, it might not be as obvious so we'll talk about that later. In general, you should try to declare your variables with concrete values wherever possible. It can avoid any potential ambiguity and make your code easier to work with.
Once again, you could verify by printing out the value of the variable.
The other syntax of declaring a variable is a bit more succinct and doesn't require the type of the variable to be specified. Instead, the compiler will infer what the type of the variable should be based on the assignment.
The syntax looks like this:
We use the:=
operator to accomplish this. This is sometimes referred to as the walrus operator.
2.1.2 Assignment
Now that we've seen a few ways of declaring variables, let's talk about updating the value of a variable.
One can do so with the =
operator.
While these two lines might seem very similar, it is important to understand the slight difference. In other languages, you just use the = operator to both declare and update variables. This will be familiar to you if you have programmed in a language like Java, C, or Python. However, in Go, you are allowed to declare variables without explicitly declaring their type. Therefore, in order to be more explicit that you are defining a new variable x with an initial value of 5, rather than trying to update a variable x that already exists, you have to use the walrus operator instead of the normal assignment operator. It is mainly a stylistic choice, i.e. the language could have been implemented without the walrus operator because the compiler would be able to tell if you were declaring a new variable rather than updating an old one, but this syntactical distinction forces you the programmer to understand the difference, which can help prevent errors. You will probably forget to do this at first, but eventually it will become second nature.
2.1.3 Unused Variables
One quirk of Go that might be quite annoying at first is the matter of unused variables. If you have a variable which is not used after declaration, the program will not compile.
Take this simple block of code:
To resolve this compiler error, you can either use the variable, comment it out, ignore the variable, or delete the line.
Comments in Go require two forward slashes. Any code after that point will be ignored by the compiler.You can also ignore the variable by renaming it to _
(called the blank identifier).
2.2 Data Types
2.2.1 Integers
Integers are any whole number. Go has several ways of representing integers.
Signed
The int
type is one way that Go represents signed integers. Signed integers allow us to store both negative and positive integers.
Go has various signed integer types that allow us to represent integers of different sizes.
Type | Range | |
---|---|---|
int8 |
-128 to 127 | -27 to 27-1 |
int16 |
-32,768 to 32,767 | -215 to 215-1 |
int32 |
-2,147,483,648 to 2,147,483,647 | -231 to 231-1 |
int64 |
-9,223,372,036,854,775,808 to 9,223,372,036,854,775,807 |
-263 to 263-1 |
int |
Platform dependent. Same as int32 for 32-bit systems and int64 for 64-bit systems. |
Unsigned
Go represents unsigned integers using the uint
type. Unsigned integers only allow us to store non-positive integers.
Type | Range | |
---|---|---|
uint8 |
0 to 255 | 0 to 27-1 |
uint16 |
0 to 65,535 | 0 to 215-1 |
uint32 |
0 to 4,294,967,295 | 0 to 231-1 |
uint64 |
0 to 18,446,744,073,709,551,615 | 0 to 263-1 |
uint |
Platform dependent. Same as uint32 for 32-bit systems and uint64 for 64-bit systems. |
Operators
You can apply several arithemtic operations to integers in Go.
If the result of dividing an integer is a non-whole number, the decimal component will be chopped off (or truncated) and the result will be only the integer component.
There is also another operator, %
, called the modulo operator. The modulo operator will return the remainder of division between two numbers.
2.2.2 Floats
Go has the float32
and float64
types which allows us to store non-whole numbers with a decimal point.
Most integer arithmetic operations apply to floats (except for modulo).
2.2.3 Booleans
Booleans allow us to store either true
or false
. Go provides us the bool
type to represent this data.
We can negate boolean values with the !
operator.
Boolean values can also show up when evaluating comparison statements. Go has the following comparison operators: ==
, !=
, <
, >
, >=
, and <=
.
2.2.4 Strings
As mentioned previously, strings allow us to store a sequence of characters.
We define strings as text within double quotes.
You can also apply some operators to strings.To check for equality between two strings:
You can also use the +
to concatenate two strings together.
2.2.5 Printf detour
While this isn't a data type, we can use our knowledge of some simple data types to explain how string formatting works.
Go includes a function in the fmt
package called Printf
which is useful for formatting how variables are printed out alongisde text and other values.
Let's say I wanted to print out the current temperature and I had the temperature stored in a variable. We can do so with the Printf
function.
Let's figure out what's going on here.
As mentioned with Println
, we provide arguments to this function call. In this case, the first argument has different behavior than all the other ones. The first argument outlines the format specifier which determines how the output string should look like.
Here we define our format specifier as "The temperature is %d degrees today"
where we have our message, this weird %d
thing, and the end of our message.
The %d
is one example of a formatting verb. This one specifically prints out a base 10 integer. Check out here for a complete list.
Here's a few to note:
-
%s
for strings -
%f
for floats -
%v
where Go will infer the verb from the type
The rest of the arguments determine what values should go in the spots where we placed our verbs. The next argument will match up with the first verb, the argument after that will be placed in the spot of the second verb and so on.
Here's an example with two verbs:
temp := 66
rainfall := 3.2
fmt.Printf("The temperature is going to be %d degrees and it will rain %f inches", temp, rainfall)
Play around with Printf
and try to print out some other data types using the other types of verbs.
Another function that makes use of the same string formatting directive is fmt.Sprintf
. Unlike fmt.Printf
, it doesn't print the result out but creates a string with the result.
2.2.6 Arrays
Arrays in Go represent fixed-size collections of values. The number of values must be known at compile time. For a more flexible, growable collection see Slices.
Here we declare an array of integers with 5 elements:
Here's an array of 2 strings:Notice how the number inside the square braces represents the number of elements. Right after that we specify the type of elements we want to store. Notice that arrays must have elements of the same type. There is more you can do with arrays but we'll go over them when we discuss Slices
2.2.7 Slices
Slices are collections of values where the number of elements can be resized. Similarly to arrays, they must have elements that are all of the same type.
Here's a slice of strings:
Notice how we don't need to specify a size for the slice.The main difference is that we can now grow our slice at runtime. We can do so with the built in append function.
append
will not change the underlying slice, but will return a new slice that includes the new elements. We'll get to talking more about returning in the section on Functions .
If we want to create a new slice, there are a few ways of doing so.
Like you saw just now, we can declare a slice of literal values. To create an empty slice, we can just declare no literals:
We can also create a new slice with the built in make
function. make
takes three arguments: type, capacity and length.
Here's we can create an empty slice.
The type in this case is just[]string
which represents a slice of strings.
The length is how many elements the slice should contain.
The capacity is how large the underlying array is. Let's quickly talk about how slices work under the hood. Beneath every slice, there is a fixed-sized array that stores all the values. This array is abstracted away from the programmer and you usually don't have to think much of it. When the slices grows beyond the size of the underlying array, the data is automatically copied to a larger underlying array. If you've worked with Java, this is how the Java ArrayList is also implemented. So, when we specify the capacity, we specify how large the underlying array should be. If you know that your slice will need quite a bit of memory, specifying capacity can be useful to avoid more memory allocations later in the program.
While length and capacity sound similar, they are not always the same value. If you keep growing a slice, the capacity might be increased by a large factor to avoid having to copy the data over many times. In this case, the slice has a capacity larger than the number of elements.
The make
function can also be called without the capacity option like so:
There are two built in functions that allow us to find the length and capacity of slices: len
and cap
.
Here's how they work:
x := make([]string, 5, 10)
length := len(x)
capacity := cap(x)
fmt.Printf("slice x with length %d and capacity %d\n", length, capacity)
Now that we know how to create slices and add new elements to them, let's actually access the values stored in them.
One way of doing so is with index operations.
Similar to other languages, we can fetch the value at a specific index (position) in the slice. Go is 0-indexed, meaning that we start counting the index from 0. So the first element has an index of 0, the second has an index of 1 and so on.
The notation for indexing is square brackets with the index number inside them.
x := []int{23, 64}
first := x[0]
fmt.Printf("first element %d\n", first)
second := x[1]
fmt.Printf("second element %d\n", second)
With slices, we can also retrieve ranges of items. If we place a colon between two index numbers, we can get a slice of elements that are within those indices. The notation looks like [a:b]
where a
is the start of the sub-slice (inclusive) and b
is the end (exclusive).
This example will print out the second and third elements.
We can also get elements starting from the start of the slice or all elements until the end of the slice. This is done by omiting one of the indices. So the notation would be [:n]
for the first n
elements and [n:]
for all the elements starting from n
.
Check out this article for more information on slices and how they work.
2.2.8 Maps
Maps are another type built into Go. They allow us to store key, value pairs where all the keys are unique from each other. If you've worked with a hash map in another language, they work the same as in Go.
We can create a map using the built in make
function.
Notice the type signature of the map (map[string]int
). The type inside the square braces represents the type of the keys. The type after is the type of the values. This means that we just created a map with string
keys and int
values.
Every key is associated with a single value. We can assign a new key value pair like so:
We can retrieve the value associated with a key using similar notation.
If the value doesn't exist, the zero-value of the map's values are returned. So in this case, if we tried to get a value of type int
with a key that doesn't exist, 0
will be returned.
To check if a key is within a map, you can use the following notation:
Try running this code without inserting the"soda"
key and run it after adding the key. You'll notice that the ok
variable returns a boolean that tells us if the key was in the map. This uses something in Go called multiple return values that we'll talk about soon.
2.3 Functions
We've already seen the main
function in Go. So let's now look at how functions generally work.
If we're making our own function we must declare it. We need to define a function signature and a function body. The signature will define the function name, what inputs a function should take, and what it should return. The function body contains the actual code of the function.
Let's define a simple function to add two numbers together and analyze it.
We start by declaring our function and giving it the name add
. We can name our function whatever we want (as long as it doesn't conflict with our other functions or any language keywords).
The next part is wrapped in a pair of parenthesis. This is where we define the parameters to our function. These are the inputs to our function. We can define any number of parameters. For each parameter, we must give it a name and a type. Here, we have defined two parameters. One is named x
and the other is named y
. Both are defined as having type int
.
The next part of the signature is the return type. The return type determines what type the output of the function should have. Here we have determined that our function should return an int
. This makes sense since the result of adding two integers is a single integer.
Next, we have opening curly braces that signify the start of our function body. Inside the function body, we implement our adding functionality. We compute our addition and store it in a sum
variable. Then we return the sum we computed using the return
keyword. The return keyword allows us to specify what value the function should output. The return value must match the return type we specified in the function signature. One thing to note is that when we reach a return
statement, the function will stop and return the return value to whoever called it.
When it comes to return types, we don't have to only return a single value. We can return zero or more values.
Here's a function with no return values:
Here's a function with more than one return value (in this case, two integers):
func div(dividend int, divisor int) (int, int) {
quotient := dividend / divisor
remainder := dividend % divisor
return quotient, remainder
}
Now that we know how functions are declared, let's use them.
As mentioned when talking about fmt.Println
, the way to call a function is to specify it's name followed by parenthesis. We can specify any number of arguments inside the parenthesis.
Let's say we wanted to call our hello
function earlier. This function has 0 arguments and 0 return values.
Let's call our add
function with 2 arguments and 1 return value.
Let's call our div
function with 2 arguments and 2 return values. div
returns both the quotient and the remainder when divig two integers. Note that we can destructure the two return values using a comma notation.
Let's say we didn't care about the remainder, but still wanted the quotient. We can ignore the remainder by using the _
symbol (called the blank identifier).
2.4 Structs
Structs are another way of collecting data. Unlike slices and arrays, they don't need to contain values of the same type. We also can't retrieve values by index. Instead we refer to values of a struct with a given name.
Here's how a struct type is defined:
Here we've declared that any kind of food
struct should have name and price fields.
Let's create an instance of this food
struct to represent some actual food item.
Structs can also have functions defined on them called methods. These methods can access struct fields and can only be called from an instance of a struct. These are similar to how methods work for classes in Java.
If we wanted to call this function we would use this syntax:
2.4 Control Flow
2.4.1 If-Else
Just like in most other languages, Go has if statements. In Go, you don't need to specify parenthesis around the condition. The condition can be any boolean or any expression that evaluates to a boolean.
if true {
fmt.Println("this is true")
}
if 2 < 3 {
fmt.Println("two is less than three")
}
if !(1 == 2) {
fmt.Println("two does not equal one")
}
We can also specify code to run if the condition in the if statement evaluates to false
.
if 3 > 2 {
fmt.Println("three is greater than two")
} else {
fmt.Println("three is not greater than two")
}
You can also specify multiple conditions to check against. Whichever condition evalutes to true
first will have it's block evaluated. If none of the conditions are true, the else block is evaluated.
num := 5
if num < 2 {
fmt.Println("num less than 2")
} else if num < 10 {
fmt.Println("num greater than 2 and less than 10")
} else {
fmt.Println("num greater than 10")
}
2.4.2 Loops
Loops are useful for repeating the same operations over and over again. Go has only one type of loop for
. Other languages might have other types like for-each
, while
, while-do
. However, for
is flexible and can do anything other loops can.
The most basic type of for
loop is one without a condition:
This loop will keep looping infinitely and will never terminate. Let's define some loops with a condition.
This loop will continue running until the condition is false. In this case, the condition evaluates to false after a single iteration of the loop.
We can also write standard C-style for-loops like so:
This loop will loop ten times. We define our iterator variable to keep track of which iteration we're on (in this case i
). We define a terminating condition, so when this condition is false the loop will end (i < 10
). We then define how the iterator variable should change on every iteration (i++). This ++
syntax means that we should increment the value of i
by 1.
This loop could be rewritten with the previously mentioned style:
If we want to stop a loop before it's terminating condition is true, we can use the break
statement.
for i := 0; i < 10; i++ {
if i == 5 {
fmt.Printf("i is %d, leaving loop now\n", i)
break
}
fmt.Println(i)
}
We can also skip the rest of the loop body and just move onto the next iteration with the continue
statement.
for i := 0; i < 10; i++ {
if i % 2 == 0 {
fmt.Println("skipping even numbers")
continue
}
fmt.Println(i)
}
One more useful application of for-loops is the ability to iterate over slices and maps.
Here's an example of us printing out every value in a slice.
We use the range statement to iterate through the values of the slice. Notice how we have access to the index our iteration is currently on (the variablei
in this case). We also have the value at the current index (val
in this case).
This type of iteration might be useful if we wanted to compute the sum of all numbers in a slice.
x := []int{1, 2, 3}
sum := 0
for _, val := range x {
sum = sum + val
}
fmt.Printf("sum is %d\n", sum)
We can also iterate through the keys and values of a map.
x := make(map[string]int)
x["one"] = 1
x["two"] = 2
x["three"] = 3
for key, val := range x {
fmt.Printf("key %s: val %d\n", key, val)
}
2.4.3 Error Handling
While error handling isn't it's own type of control flow (like if-else or for), it's ubiquitious in all Go programs that it deserves to be focused on.
When a function is executing, it may run into issues and not be able to complete it's task. In these cases, we want to exit the function early and report the error to the caller. The standard Go way of doing so is to simply return the error have the caller check it.
Go provides an error type to indicate if an error has occured. At a high level, an error value is just a container for some error message.
One way of creating an error is with the fmt.Errorf
function.
Let's say we wanted to create a square root function and we want to consider it an error if a negative number is given as an argument.
We can check if the input is negative and return an error if so.
func sqrt(x int) (int, error) {
if x < 0 {
return 0, fmt.Errorf("cannot get square root of negative number")
}
// implementation
// ...
return result, nil
}
Notice how the return type changed from int
to (int, error)
. This is using the multiple return values idea that we talked about earlier. The first number is the result of the square root operation.
If x
is negative, we return an error. Notice how we still have to provide a value to return for the result. The convention in Go is to ignore the first value if an error is present in the second value. This will make more sense when we look at handling errors from the caller's perspective.
Let's say we want to use our square root function. We would call it like so:
Ok now we have a result integer and error. What should we do with them?
We need to check the value of the error. This will tell us if it's safe to read the value of result.
The convention for checking an error in Go is to check if it is equal to nil
or not. nil
is just Go's way of representing a value of nothing. This is analogous to null
in languages like Java and None
in Python.
Checking if an error is not nil is as simple as an if-statement:
So if we wanted to check the error of sqrt
we could write something like this:
result, err := sqrt(-4)
if err != nil {
fmt.Printf("unable to calculate square root; %s\n", err.Error())
return
}
fmt.Printf("square root is %d\n", result)
// more computation ...
Notice that inside the if statement we handle the case where an error is present (where the error is not nil). This allows us to log the error and exit early (return statement). It makes sense to exit early after seeing the error since we cannot continue executing our program with an invalid square root result. Since we exit early, the rest of the code can safely use result
and assume that it is a valid value. Notice how we can just print out the value of result
in the last line. We already handled any potential error and now are left with a valid result.
Generally exiting early on an error is a good practice with regards to code style. Let's add a call to another function and look at how our code would look if we didn't exit early.
Instead of exiting early, let's place the error handling in the else
block and the good path in the initial if
block.
result, err := sqrt(-4)
if err == nil {
fmt.Printf("square root is %d\n", result)
newResult, err := div(result, 2)
if err == nil {
fmt.Printf("division is %d\n", newResult)
} else {
fmt.Printf("unable to calculate division; %s\n", err.Error())
}
} else {
fmt.Printf("unable to calculate square root; %s\n", err.Error())
}
While this code is functionally fine, it means that any future computation we want to do with our results will require us to add another level of nesting to our code. This increase in nesting can make our code harder to read and follow. While this example is quite straightforward, you can imagine this getting out of hand with larger, more complex functions.
Let's see how this code would work if we used early exiting.
result, err := sqrt(-4)
if err != nil {
fmt.Printf("unable to calculate square root; %s\n", err.Error())
return
}
fmt.Printf("square root is %d\n", result)
newResult, err := div(result, 2)
if err != nil {
fmt.Printf("unable to calculate division; %s\n", err.Error())
return
}
fmt.Printf("division is %d\n", newResult)
This code is easier to read since we can easily see where function goes wrong and we hit an error. It's easier to spot since the scope for error handling deviates from the rest of the code. Also, the main flow of the program where we don't hit an error is in the top-most scope. This means that we can clearly follow how the main path of execution should look like.
3 Hangman
Now that we're familiar with some of the fundamentals of Go, let's make a simple Hangman game.
Let's start off with an empty main
function.
3.1 User input
One of the things we need for a hangman game is to read in guesses from the player. We can do this by reading in user input text.
The fmt
package provides functions like Scanln
and Scanf
which can read in user input until they hit the enter key.
We will be using Scanln
to read in some user input.
package main
import (
"fmt"
"os"
)
func main() {
var input string
_, err := fmt.Scanln(&input)
if err != nil {
fmt.Printf("invalid user input; %s\n", err)
os.Exit(1)
}
fmt.Println(input)
}
Let's break down how this works.
We declare an empty string variable where our input data will be placed. We callfmt.Scanln
to read all input until the Enter key is pressed. We pass it an argument that looks like &input
. We haven't really talked about what the &
sign means. We won't really need it for the rest of this tutorial but let's quickly go over it right now. The &
symbol allows us to get a pointer to a variable. All variables are just some data stored in memory. Every spot in memory has an address that allows the program to access it. Getting a pointer to a variable is just getting the address where that variable is stored. Here, we are telling Scanln
to put any user input in the memory address where our input
variable is located at.
You'll also notice that fmt.Scanln
returns two values. The first is the number of items successfully scanned. The second is an error value that tells us if something went wrong. You can read more about Scanln
, Scan
, and Scanf
on the official Go doc page.
Scanln
. Read the section on error handling to understand how the Go convention works.
Inside the if block, we print an error message and exit the program.
Finally, we print out the input we received. Try running this program, enter some input, hit the enter key, and see what gets printed out.Let's add some prompts to instruct our user on what to do.
package main
import (
"fmt"
"os"
)
func main() {
fmt.Println("Welcome to Hangman!")
fmt.Printf("Make a guess: ")
var input string
_, err := fmt.Scanln(&input)
if err != nil {
fmt.Printf("invalid user input; %s\n", err)
os.Exit(1)
}
fmt.Printf("You guessed: %s\n", input)
}
3.2 Repeating Input
Let's think about how our hangman game should work. We will have some word that the user must guess. The user will them try to guess the word via one of two ways:
- Guess one character at a time until they've run out of guesses
- Guess the entire word outright
Let's try to handle the second case and only check if the user has guessed the entire word correctly.
The first thing we need to do is to keep asking for new inputs. To keep asking for new inputs, we can wrap our input code in a for
loop.
package main
import (
"fmt"
"os"
)
func main() {
fmt.Println("Welcome to Hangman!")
for {
fmt.Printf("Make a guess: ")
var input string
_, err := fmt.Scanln(&input)
if err != nil {
fmt.Printf("invalid user input; %s\n", err)
os.Exit(1)
}
fmt.Printf("You guessed: %s\n", input)
}
}
If you run this code, you should be able to keep making guesses. However, the program will never end (unless you kill it with Ctrl-C). Let's add some condition to break out of the loop. One such condition would be to guess the correct word. If we guess the correct word, the game is over and we should exit.
package main
import (
"fmt"
"os"
)
func main() {
wordToGuess := "hello"
fmt.Println("Welcome to Hangman!")
for {
fmt.Printf("Make a guess: ")
var input string
_, err := fmt.Scanln(&input)
if err != nil {
fmt.Printf("invalid user input; %s\n", err)
os.Exit(1)
}
if input == wordToGuess {
fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
return
}
}
}
Let's also add another condition for the loop to terminate: a maximum number of guesses. If the user goes over the maximum number of guesses, the program should quit.
package main
import (
"fmt"
"os"
)
func main() {
wordToGuess := "hello"
guessesLeft := 5
fmt.Println("Welcome to Hangman!")
for guessesLeft > 0 {
fmt.Printf("Make a guess (%d left): ", guessesLeft)
var input string
_, err := fmt.Scanln(&input)
if err != nil {
fmt.Printf("invalid user input; %s\n", err)
os.Exit(1)
}
guessesLeft--
if input == wordToGuess {
fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
return
}
}
fmt.Println("You ran out of guesses and lost!")
}
Here we've declared guessesLeft
to start off as 5. On every loop iteration, we will check that we have any guesses left with the condition guessesLeft > 0
. Inside the loop block, we will decrement the number of guesses left with the line guessesLeft--
. The --
is the post-decrement operator that will decrement an integer by 1 and update it's value.
Now we only have 5 guesses to guess the correct word. If we run out of guesses, guessesLeft
will be 0 and we will stop iterating through the loop. We will then end up outside the loop block where we print the message "You ran out of guesses and lost!"
.
3.3 Keep track of guesses
Now let's handle the other case that we mentioned above.
- Guess one character at a time until they've run out of guesses
To know if we have guessed the entire word, we need to keep track of which guesses we've made before. You can think of it as a history of guesses.
There are many ways of doing this. We could create a slice and have it grow with every new guess we make. This could work and we could loop through the slice to check if we've guessed a certain character yet. However, this might not be the most ideal solution.
There are two properties which might be desirable when choosing a data structure to store these guesses:
- Uniqueness: We don't want to store duplicate guesses more than once.
- Fast lookups: We want to quickly check a given character.
Luckily for us, there is a built in data structure that satsifies both of these constraints: the Go map
type.
As mentioned in the section on maps, they will store key-value pairs where each key is unique. We can store a unique set of guesses if we make them the keys of our map.
Maps also give us fast lookups. We won't spend too much on this since it requires explaining things such as time complexity. However, you should know that looking up a key in a map is a quick operation. It is much faster than iterating through a slice and checking for equality on each element of the slice.
With that said, let's set up our map. We already mentioned that the key should represent each of the user's guesses. The question that remains is: what should the map values represent?
Well, there's really nothing else we need to store. We want to store multiple unique guesses, and that's it. So in this case, it makes sense to put a placeholder for the values of the map. We can choose any type as the placeholder since we're never going to be accessing it. I'll choose a bool
.
This is one of the quirks of Go when compared to other languages. Other languages (such as Python and C++) have built in set types. Go doesn't have such types since the map
type can already perform the same functionality. You may see this style of thinking with other parts of the language. For such trivial things, the language designers feel that the user can quickly reimplement them instead of adding more features to the language's core libraries. For example, there is no Contains
function to check if a value is within a slice or there was no min
and max
function up until Go 1.21. If you wanted to avoid this extra bool
map value, you could create a Set
struct that hides the bool
value from the programmer using the Set
. The Set
would have an underlying map
and the Set
would have methods that would update and check for for element membership within that map
. If you want to try to make something like this, go for it! For the purposes of this tutorial we'll just deal with the awkard bool
.
Let's place this map declaration at the top of our main function.
Let's also add in some code to populate the map with every new guess.
package main
import (
"fmt"
"os"
)
func main() {
wordToGuess := "hello"
guessesLeft := 5
prevGuesses := make(map[string]bool)
fmt.Println("Welcome to Hangman!")
for guessesLeft > 0 {
fmt.Printf("Make a guess (%d left): ", guessesLeft)
var input string
_, err := fmt.Scanln(&input)
if err != nil {
fmt.Printf("invalid user input; %s\n", err)
os.Exit(1)
}
guessesLeft--
if input == wordToGuess {
fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
return
}
if len(input) > 1 {
fmt.Println("You did not guess the word correctly! Try again")
continue
}
prevGuesses[input] = true
}
fmt.Println("You ran out of guesses and lost!")
}
Notice that we also added a new check. If the user didn't guess the word outright and tried to type out more than a single character, we print a message and tell them to retry. This ensures that we only place single character guesses into the map.
We also insert the guess into the map and give the key a value of true
. If you recall, this bool
is just a placeholder and it's value doesn't matter. We could've inserted a value of false
and the behavior would've been the same.
Let's add another check to print a message if the user has already guessed a certain character. We can check if an element is in a map using the syntax we saw earlier.
package main
import (
"fmt"
"os"
)
func main() {
wordToGuess := "hello"
guessesLeft := 5
prevGuesses := make(map[string]bool)
fmt.Println("Welcome to Hangman!")
for guessesLeft > 0 {
fmt.Printf("Make a guess (%d left): ", guessesLeft)
var input string
_, err := fmt.Scanln(&input)
if err != nil {
fmt.Printf("invalid user input; %s\n", err)
os.Exit(1)
}
guessesLeft--
if input == wordToGuess {
fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
return
}
if len(input) > 1 {
fmt.Println("You did not guess the word correctly! Try again")
continue
}
_, ok := prevGuesses[input]
if ok {
fmt.Printf("You already guessed %s! Try again\n", input)
continue
}
prevGuesses[input] = true
}
fmt.Println("You ran out of guesses and lost!")
}
Let's also change the program to not punish the user for guessing duplicate characters. Let's only decrement the number of guesses left when they guess an incorrect word or guess a new character.
package main
import (
"fmt"
"os"
)
func main() {
wordToGuess := "hello"
guessesLeft := 5
prevGuesses := make(map[string]bool)
fmt.Println("Welcome to Hangman!")
for guessesLeft > 0 {
fmt.Printf("Make a guess (%d left): ", guessesLeft)
var input string
_, err := fmt.Scanln(&input)
if err != nil {
fmt.Printf("invalid user input; %s\n", err)
os.Exit(1)
}
if input == wordToGuess {
fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
return
}
if len(input) > 1 {
guessesLeft--
fmt.Println("You did not guess the word correctly! Try again")
continue
}
_, ok := prevGuesses[input]
if ok {
fmt.Printf("You already guessed %s! Try again\n", input)
continue
}
guessesLeft--
prevGuesses[input] = true
}
fmt.Println("You ran out of guesses and lost!")
}
Ok now let's check if the user has guessed the entire word yet. After they input a new character we should check if they've guessed every character. Let's make a new function to describe this functionality.
You can define this function right below our main function. Let's call it guessedEntireWord
Now we need to think about what inputs and outputs this function should have.
We know the output should be a bool
. A true
value would tell us that the entire word has been guessed and the game should end. false
would mean that the word hasn't been guessed yet and that the game should continue.
The inputs should be the word we're trying to guess and the guesses we've previously made.
We can make our function look something like this:
Inside this function we need to look at every letter of the word and see if we've guessed it before. If one of the letters of the word doesn't appear in our previous guesses, we know that we haven't guessed the entire word and can return false
.
We can use the Go for
loop to iterate through characters of a string (similar to how we iterated through elements of a slice with range
over here)
func guessedEntireWord(word string, guesses map[string]bool) bool {
for _, char := range word {
_, ok := guesses[char]
if !ok {
return false
}
}
return true
}
Ok so this code is iterating through every character in the word we're trying to guess. For each character, we check to see if we've guessed it before. If we haven't guessed the character before, we exit out of the function and return false
. If all the characters of the word have been guessed, we then return true
.
Let's call our newly made function inside our main program and print out a message accordingly.
package main
import (
"fmt"
"os"
)
func main() {
wordToGuess := "hello"
guessesLeft := 5
prevGuesses := make(map[string]bool)
fmt.Println("Welcome to Hangman!")
for guessesLeft > 0 {
fmt.Printf("Make a guess (%d left): ", guessesLeft)
var input string
_, err := fmt.Scanln(&input)
if err != nil {
fmt.Printf("invalid user input; %s\n", err)
os.Exit(1)
}
if input == wordToGuess {
fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
return
}
if len(input) > 1 {
guessesLeft--
fmt.Println("You did not guess the word correctly! Try again")
continue
}
_, ok := prevGuesses[input]
if ok {
fmt.Printf("You already guessed %s! Try again\n", input)
continue
}
guessesLeft--
prevGuesses[input] = true
if guessedEntireWord(wordToGuess, prevGuesses) {
fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
return
}
}
fmt.Println("You ran out of guesses and lost!")
}
func guessedEntireWord(word string, guesses map[string]bool) bool {
for _, char := range word {
_, ok := guesses[char]
if !ok {
return false
}
}
return true
}
If you try to compile your program with this code, you will notice that it won't compile. You should get a compile error like this.
This error arises from the fact that when we loop through the characters of a string
with range
, we don't get a string
type back. Go has another type that we didn't go over called rune
. A rune
is just a single character. Since we get back a rune
from looping over the string
, we can't use this rune
as the key when looking up in a map
of type map[string]bool
. Our map expects keys to be of type string
.
We have two options for how to handle this:
- Convert our
rune
to astring
for the key lookup - Change the type of our map to use a
rune
as it's key type
Here is the modification for option 1:
func guessedEntireWord(word string, guesses map[string]bool) bool {
for _, char := range word {
_, ok := guesses[string(char)]
if !ok {
return false
}
}
return true
}
For the purposes of this tutorial, we'll only go over option 1. Option 2 is certainly something you could implement and it would make more sense since we only want to store single character guesses.
Let's also create another function which will tell us how many times the character guessed appears in the word. This is useful so we can decrement the number of guesses only when the user guesses a character incorrectly.
Given a word and a guessed character, we need to check how many times a character appears in a word. We can create a counter and increment it whenever we see an equivalent character.
func getNumOccurences(word string, guess string) int {
numTimes := 0
for _, char := range word {
if string(char) == guess {
numTimes++
}
}
return numTimes
}
Let's add it to our program:
package main
import (
"fmt"
"os"
)
func main() {
wordToGuess := "hello"
guessesLeft := 5
prevGuesses := make(map[string]bool)
fmt.Println("Welcome to Hangman!")
for guessesLeft > 0 {
fmt.Printf("Make a guess (%d left): ", guessesLeft)
var input string
_, err := fmt.Scanln(&input)
if err != nil {
fmt.Printf("invalid user input; %s\n", err)
os.Exit(1)
}
if input == wordToGuess {
fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
return
}
if len(input) > 1 {
guessesLeft--
fmt.Println("You did not guess the word correctly! Try again")
continue
}
_, ok := prevGuesses[input]
if ok {
fmt.Printf("You already guessed %s! Try again\n", input)
continue
}
prevGuesses[input] = true
if guessedEntireWord(wordToGuess, prevGuesses) {
fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
return
}
if getNumOccurences(wordToGuess, input) < 1 {
guessesLeft--
fmt.Printf("Incorrect. %s is not in the word\n", input)
}
}
fmt.Println("You ran out of guesses and lost!")
}
func guessedEntireWord(word string, guesses map[string]bool) bool {
for _, char := range word {
_, ok := guesses[char]
if !ok {
return false
}
}
return true
}
func getNumOccurences(word string, guess string) int {
numTimes := 0
for _, char := range word {
if string(char) == guess {
numTimes++
}
}
return numTimes
}
Ok now we should have a working Hangman game. Try to type the letters of "hello" one by one.
Now, we can focus on making our game prettier and a few more quality of life features.
3.4 Printing out game state
Let's add a print out on every turn to tell the user which characters they've guessed already.
Let's say we're trying to guess hello
and we've guessed "h"
and "l"
. It should look something like this:
Let's break down this problem. We want to print out a bunch of characters. The characters should be either _
or the actual letter of the word depending on if we've guessed it before.
This should look similar to our guessedEntireWord
function. We can loop through each of the characters of the word to guess. If we've already guessed a character, we can print it out. If a character hasn't been guessed, print _
in it's place.
For this function, we won't print out a new character on every iteration but will collect them into a single string that gets printed out at the end.
func printGameState(word string, guesses map[string]bool) {
gameState := ""
for _, char := range word {
_, ok := guesses[string(char)]
if ok {
gameState = gameState + string(char)
} else {
gameState = gameState + "_"
}
gameState = gameState + " "
}
fmt.Println(gameState)
}
If you remember, the +
operator is defined on strings to do concatenation. So we concatenate our previous games state with our new character either _
or a character from the word. We then re-assign our gameState
to reflect this updated value.
We also concatenate a space between every character for formatting reasons.
Ok now let's call this function at the end of every turn. Our program should look something like this:
package main
import (
"fmt"
"os"
)
func main() {
wordToGuess := "hello"
guessesLeft := 5
prevGuesses := make(map[string]bool)
fmt.Println("Welcome to Hangman!")
for guessesLeft > 0 {
fmt.Printf("Make a guess (%d left): ", guessesLeft)
var input string
_, err := fmt.Scanln(&input)
if err != nil {
fmt.Printf("invalid user input; %s\n", err)
os.Exit(1)
}
if input == wordToGuess {
fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
return
}
if len(input) > 1 {
guessesLeft--
fmt.Println("You did not guess the word correctly! Try again")
continue
}
_, ok := prevGuesses[input]
if ok {
fmt.Printf("You already guessed %s! Try again\n", input)
continue
}
prevGuesses[input] = true
if guessedEntireWord(wordToGuess, prevGuesses) {
fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
return
}
if getNumOccurences(wordToGuess, input) < 1 {
guessesLeft--
fmt.Printf("Incorrect. %s is not in the word\n", input)
}
printGameState(wordToGuess, prevGuesses)
}
fmt.Println("You ran out of guesses and lost!")
}
func guessedEntireWord(word string, guesses map[string]bool) bool {
for _, char := range word {
_, ok := guesses[string(char)]
if !ok {
return false
}
}
return true
}
func getNumOccurences(word string, guess string) int {
numTimes := 0
for _, char := range word {
if string(char) == guess {
numTimes++
}
}
return numTimes
}
func printGameState(word string, guesses map[string]bool) {
gameState := ""
for _, char := range word {
_, ok := guesses[string(char)]
if ok {
gameState = gameState + string(char)
} else {
gameState = gameState + "_"
}
gameState = gameState + " "
}
fmt.Println(gameState)
}
Try to play the game now. You should see the spots of the word getting filled in as you keep guessingcorrectly.
3.5 Randomize words
Ok let's make our game a bit more versatile. Instead of only guessing hello
over and over again, let's have our program choose a random word from a word bank.
Let's define a word bank of multiple words as a slice.
Now let's choose one of these words at random. To select one of the words we can select a random index and use that index to access one of the slice elements. This means that we need a random integer between 0 and the number of words we have.
Here's the code for generating a random number from 0
to len(words)
. We won't touch on exactly how this works. Just know that our randomness depends on the seed. If our seed is the same between two calls to generate a random number, the numbers will be the same. This is why we set our seed to something that will change between each call (such as the current timestamp).
words := []string{"hello", "suspicious", "tuas"}
rand.Seed(time.Now().Unix())
wordToGuess := words[rand.Intn(len(words))]
There's a lot going on here. Let's break it down.
We setup as our seed to be the current unix timestamp, as we mentioned (note that we need to import the "time"
package). We then get our random index (from 0 to len(words) exclusive). The random index is then used to index into the words
slice and get a word string.
Let's substitute our hardcoded wordToGuess := "hello"
with this new randomization:
package main
import (
"fmt"
"math/rand"
"os"
"time"
)
func main() {
words := []string{"hello", "suspicious", "tuas"}
rand.Seed(time.Now().Unix())
wordToGuess := words[rand.Intn(len(words))]
guessesLeft := 5
prevGuesses := make(map[string]bool)
fmt.Println("Welcome to Hangman!")
for guessesLeft > 0 {
fmt.Printf("Make a guess (%d left): ", guessesLeft)
var input string
_, err := fmt.Scanln(&input)
if err != nil {
fmt.Printf("invalid user input; %s\n", err)
os.Exit(1)
}
if input == wordToGuess {
fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
return
}
if len(input) > 1 {
fmt.Println("You did not guess the word correctly! Try again")
continue
}
_, ok := prevGuesses[input]
if ok {
fmt.Printf("You already guessed %s! Try again\n", input)
continue
}
prevGuesses[input] = true
if guessedEntireWord(wordToGuess, prevGuesses) {
fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
return
}
if getNumOccurences(wordToGuess, input) < 1 {
guessesLeft--
fmt.Printf("Incorrect. %s is not in the word\n", input)
}
printGameState(wordToGuess, prevGuesses)
}
fmt.Println("You ran out of guesses and lost!")
}
func guessedEntireWord(word string, guesses map[string]bool) bool {
for _, char := range word {
_, ok := guesses[string(char)]
if !ok {
return false
}
}
return true
}
func printGameState(word string, guesses map[string]bool) {
gameState := ""
for _, char := range word {
_, ok := guesses[string(char)]
if ok {
gameState = gameState + string(char)
} else {
gameState = gameState + "_"
}
gameState = gameState + " "
}
fmt.Println(gameState)
}
func getNumOccurences(word string, guess string) int {
numTimes := 0
for _, char := range word {
if string(char) == guess {
numTimes++
}
}
return numTimes
}
There is one problem with this version of the program: we might have a word longer than our maximum number of guesses of 5. There are many ways we can handle this. For the purpose of this tutorial, I'll give the user one guess for each character in the word.
words := []string{"hello", "suspicious", "tuas"}
rand.Seed(time.Now().Unix())
wordToGuess := words[rand.Intn(len(words))]
guessesLeft := len(wordToGuess)
prevGuesses := make(map[string]bool)
...
With that, we have the final version of our game:
package main
import (
"fmt"
"math/rand"
"os"
"time"
)
func main() {
words := []string{"hello", "suspicious", "tuas"}
rand.Seed(time.Now().Unix())
wordToGuess := words[rand.Intn(len(words))]
guessesLeft := len(wordToGuess)
prevGuesses := make(map[string]bool)
fmt.Println("Welcome to Hangman!")
for guessesLeft > 0 {
fmt.Printf("Make a guess (%d left): ", guessesLeft)
var input string
_, err := fmt.Scanln(&input)
if err != nil {
fmt.Printf("invalid user input; %s\n", err)
os.Exit(1)
}
if input == wordToGuess {
fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
return
}
if len(input) > 1 {
fmt.Println("You did not guess the word correctly! Try again")
continue
}
_, ok := prevGuesses[input]
if ok {
fmt.Printf("You already guessed %s! Try again\n", input)
continue
}
prevGuesses[input] = true
if guessedEntireWord(wordToGuess, prevGuesses) {
fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
return
}
if getNumOccurences(wordToGuess, input) < 1 {
guessesLeft--
fmt.Printf("Incorrect. %s is not in the word\n", input)
}
printGameState(wordToGuess, prevGuesses)
}
fmt.Println("You ran out of guesses and lost!")
}
func guessedEntireWord(word string, guesses map[string]bool) bool {
for _, char := range word {
_, ok := guesses[string(char)]
if !ok {
return false
}
}
return true
}
func printGameState(word string, guesses map[string]bool) {
gameState := ""
for _, char := range word {
_, ok := guesses[string(char)]
if ok {
gameState = gameState + string(char)
} else {
gameState = gameState + "_"
}
gameState = gameState + " "
}
fmt.Println(gameState)
}
func getNumOccurences(word string, guess string) int {
numTimes := 0
for _, char := range word {
if string(char) == guess {
numTimes++
}
}
return numTimes
}
Of course, there are many ways you could improve this game. But this should be a good exercise for you to get a tour of Go types, control flow and idioms.