4. Blocks, Shadows and Control Structures
Blocks
Go lets you declare variables in lots of places. You can declare them outside of functions, as the parameters to functions and as local variables within functions.
- Each place where a declaration occurs is a “block”.
- Variables, constants, types and functions declared outside of any functions are placed in the “package block”.
- Imported files are in the “file block”.
- Within a function, every set of braces
({})
defines another block.
Shadowing Variables
func main() {
x := 10
if x > 5 {
fmt.Println(x) // 10
x := 5
fmt.Println(x) // 5
}
fmt.Println(x) // 10
}
A shadowing variable
is a variable that has the same name as a variable in a containing block. For as long as the shadowing variable exists, you can’t access the shadowed variable (the parent).
This is why it’s dangerous to use :=
all the time as it’s very easy to accidentally created shadowing variables
You can also shadow package imports:
func main() {
x := 10
fmt.Println(x)
fmt := "a string"
fmt.Println(fmt)
}
This will cause an error fmt.Println undefined (type string has no field or method Println)
.
Detecting Shadowed Variables
Given the subtle bugs that this can introduce, it;’s a good idea to ensure you don’t have any shadowed variables introduced in your programs. Neither go vet
nor golanci-lint
will detect shadowing, but you can add shadowing detection to your build process by installing the shadow linter on your machine:
go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@latest
If you are building with a Makefile, considering including a shadow in the vet task:
vet:
go vet ./...
shadow ./...
.PHONY:vet
When you run make vet against the previous code, you'll see the shadow variable is now detected: declaration of "x" shadow declaration at line 6
The Universe Block
Go is a small language with only 25 keywords, but built-in types (int
, string
, …), constants (true
and false
), functions (make
or close
) aren’t in that list. Neither is nil
.
Rather than make them keywords, they are considered predeclared identifiers
- they are defined in the universe block, which is the block that contains all blocks. Similarly, they can be shadowed in the other scopes:
fmt.Println(true) // true
true := 10
fmt.Println(true) // 10
Not even ‘shadow’ detects shadowing of universe block identifiers - you should NEVER do this.
Statements
If/Else Statement
It works like most other languages, however there are no parentheses around the condition and we can also define variables within the braces of an if
or else
statement will only be accessible within that block.
n := rand.Intn(10)
if n == 0 {
fmt.Println("That's too low")
} else if n > 5 {
fmt.Println("That's too high")
} else {
fmt.Println("That's just right", n)
}
We can also write this like:
if n := rand.Intn(10); n == 0 {
fmt.Println("That's too low")
} else if n == 1 {
fmt.Println("That's too high")
} else {
fmt.Println("That's just right", n)
}
fmt.Println(n) // this causes a compilation error (undefined: n)
For loop
There are four ways to use a for loop
in Go
- A C-style,
complete for
- A
condition-only for
- An
infinite for
for-range
The Complete for
Statement
for i := 0; i < 10; i++ {
fmt.Println(i)
}
This is similar to how other languages write for loops, and this will print 0→9 inclusive.
The Condition-Only for
Statement
i := 1
for i < 100 {
fmt.Println(i)
i = i * 2
}
This will print 1,2,4,8,16,…
The Infinite for
Statement
for {
fmt.Println("Hello")
}
This prints Hello an infinite amount of times. We can get out of these with a break
or continue
statement.
i := 1
for {
fmt.Println(i)
i = i * 2
if i > 10 {
break
}
}
We can use continue
to skip the current iteration and go to the next one. We can write a simple FizzBuzz calculator like so:
for i := 1; i <= 100; i++ {
if i%3 == 0 && i%5 == 0 {
fmt.Println("FizzBuzz")
} else if i%3 == 0 {
fmt.Println("Fizz")
} else if i%5 == 0 {
fmt.Println("Buzz")
} else {
fmt.Println(i)
}
}
Or we could write it with continue
statements, which is arguably more readable:
for i := 1; i <= 100; i++ {
if i%3 == 0 && i%5 == 0 {
fmt.Println("FizzBuzz")
continue
}
if i%3 == 0 {
fmt.Println("Fizz")
continue
}
if i%5 == 0 {
fmt.Println("Buzz")
continue
}
fmt.Println(i)
}
The for-range
Statement
We can iterate over elements in strings, arrays, slices and maps with a for-range loop
evenVals := []int{2, 4, 6, 8, 10, 12}
for i, v := range evenVals {
fmt.Println(i, v)
}
// 0 2
// 1 4
// 2 6
// ...
If we don’t need the key in a for-range loop, we can use the _
to “ignore” it
evenVals := []int{2, 4, 6, 8, 10, 12}
for _, v := range evenVals {
fmt.Println(v)
}
If you want the key and not the value, you can just leave off the second variable:
uniqueNames := map[string]bool{"Fred": true, "Raul": true, "John": true, "Jane": true}
for k := range uniqueNames {
fmt.Println("Key: ", k)
}
Iterating over maps
We have to note that something odd happens when we iterate over maps:
n := map[string]int {
"a": 1,
"c": 3,
"b": 2,
}
for i:= 0; i < 3; i++ {
fmt.Println("Loop", i)
for k, v := range n {
fmt.Println(k, v)
}
}
When you build and run the program, the output varies but here is one possibility:
Loop 0
c 3
b 2
a 1
Loop 1
a 1
c 3
b2
Loop 2
b 2
a 1
c 3
The order of the keys and values varies, and this is actually a security feature. In earlier Go versions, the iteration order for keys in a map was usually (not always) the same if you inserted the same items into a map. This caused two issues:
- People would write code that assumed the order was fixed which would break at times
- If maps always hash items to the exact same values and you knew a server was storing some user data in a map, you could slow down a server with an attack called ****Hash DoS******** by sending it specially crafted data where all of the keys hash to the same bucket.
To prevent these issues, the Go team made two changes to the map implementation.
- They modified the hash algorithm to include a random number that’s generated every time a map variable is created.
- They made the order of a
for-range
iteration over a map vary a bit each time the map is loop over, making it far harder to implement a ****Hash DoS******** attack.
Iterating over Strings
samples := []string{"word", "duck 🪿!"}
for _, sample := range samples {
// loop over the word:
for i, r := range sample {
fmt.Println(i, r, string(r))
}
}
The output where we iterate over “hello” has no surprises
0 119 w
1 111 o
2 114 r
3 100 d
But when we loop over duck with an emoji, we see that it skips from 5 to 9:
0 100 d
1 117 u
2 99 c
3 107 k
4 32
5 129727 🪿
9 33 !
This is because we are iterating over the runes, not the bytes.
- Whenever a for-range loop encounters a multi-byte rune in a string, it converts the UTF-8 representation into a single 32-bit number and assigns it to the value.
- The offset is incremented by the number of bytes in the rune.
The for-range
Value is a Copy
You should note that every time the for-range
loop iterates over your compound your compound type, the copies the value from the compound type to the value variable. Modifying the value variable will not modify the value in the compound type.
nums := []int{1, 2, 3, 4, 5}
for _, v := range nums {
v *= 2
}
fmt.Println(nums) // [1 2 3 4 5]
Labelling for
Statements
By default, the break
and continue
keywords apply to the for loop that directly contains them. When we have nested for loops and we want to exit the outer loop, we can use a label.
func main() {
samples := []string{"hello", "word_l_word"}
outer:
for _, sample := range samples {
for i, r := range sample {
fmt.Println(i, r, string(r))
if r == 'l' {
continue outer
}
}
}
}
This returns:
0 104 h
1 101 e
2 108 l // continue here
0 119 w
1 111 o
2 114 r
3 100 d
4 95 _
5 108 l // continue here
Switch Statements
While other languages have very limited switch statements, Go has some slightly different behaviours.
words := []string{"Hello", "Word", "Octopus", "Hello Again"}
for _, word := range words {
switch size := len(word); size {
case 1, 2, 3, 4:
fmt.Println(word, " is a short word!")
case 5:
wordLen := len(word)
fmt.Println(word, "is exactly the right length:", wordLen)
case 6, 7, 8, 9:
default:
fmt.Println(word, "is a long word!")
}
}
// Hello is exactly the right length: 5
// Word is a short word!
// Hello Again is a long word!
- Like the other statements, we don’t put parenthesis around the value being compared.
- We can also declare variables that are scoped to all of the branches of the switch statement (e.g.
size
in the above. - All of the case clauses (and the optional default clause) are contained in a set of braces.
- You can have multiple lanes inside a case clause and they are all considered to be part of the same block.
- We can declare variables inside a switch statement (i.e.
wordLen
) and it’s scoped inside that block and only that block. - You don’t have to put a break statement at the end of every block, cases in switch statements don’t fall through like in most languages. 🎉
- An empty case (like 6,7,8,9) means nothing happens - so the word “octopus” will not even show up.
Confusing break
Statements
In the example here, we put a break statement on the switch statement when the number reaches 7 - but it doesn’t break out of the loop here.
for i := 0; i < 10; i++ {
switch {
case i%2 == 0:
fmt.Println(i, "is even")
case i%3 == 0:
fmt.Println(i, "is divisible by 3 but not 2")
case i%7 == 0:
fmt.Println("exit the loop!")
break
default:
fmt.Println(i, "is boring")
}
}
// 0 is even
// 1 is boring
// 2 is even
// 3 is divisible by 3 but not 2
// 4 is even
// 5 is boring
// 6 is even
// exit the loop!
// 8 is even
// 9 is divisible by 3 but not 2
We have to use a label to break out of the loop here
meow:
for i := 0; i < 10; i++ {
switch {
case i%2 == 0:
fmt.Println(i, "is even")
case i%3 == 0:
fmt.Println(i, "is divisible by 3 but not 2")
case i%7 == 0:
fmt.Println("exit the loop!")
break meow
default:
fmt.Println(i, "is boring")
}
}
// 0 is even
// 1 is boring
// 2 is even
// 3 is divisible by 3 but not 2
// 4 is even
// 5 is boring
// 6 is even
// exit the loop!
Here we use the label meow
and break out of the meow
loop and it works as intended.
Blank Switches
We can compare with a blank switch:
words := []string{"hi", "salutations", "hello"}
for _, word := range words {
switch wordLen := len(word); {
case wordLen < 5:
fmt.Println(word, "is a short word!")
case wordLen > 10:
fmt.Println(word, "is a long word!")
default:
fmt.Println(word, "is exactly the right length.")
}
}
// hi is a short word!
// salutations is a long word!
// hello is exactly the right length.
Just like a regular switch statement, you can include a short variable declaration as part of your blank switch. But unlike a regular switch, you can write logical tests for your cases:
switch {
case a == 2:
fmt.Println("a is 2")
case a == 3:
fmt.Println("a is 3")
default:
fmt.Println("a is ", a)
}
Can be written in a simpler way (not a blank switch):
switch a {
case 2:
fmt.Println("a is 2")
case 3:
fmt.Println("a is 3")
default:
fmt.Println("a is ", a)
}
Choosing Between if
and switch
There isn’t a lot of difference between a series of if/else statements and a blank switch statement, they both allow a series of comparisons. So when should you use them?
A switch
statement implies there is a relationship between the values or comparisons in each case.
switch n := rand.Intn(10); {
case n == 0:
fmt.Println("That's too low")
case n > 5:
fmt.Println("That's too big:", n)
default:
fmt.Println("That's a good number:", n)
}
This is arguably more readable, but moth methods are acceptable.
Note: it is not idiomatic not do unrelated comparisons in a blank switch statement.
goto
Statement
Traditionally goto
statements were considered dangerous because it could jump to nearly anywhere in the program - in or out of a loop, skip over variable definitions or into the middle of a set of statements in an if statement. This made it difficult to understand what a goto-using program actually did.
- For the majority of cases, you should avoid using
goto
, but it has some uses. Agoto
statement specifies a labeled line of code and execution jumps to it. - However, you can’t jump anywhere - Go forbids jumps that skip over variable declarations and jumps that go into an inner or parallel block.
func main() {
a := 10
goto skip
b := 20
skip:
c := 30
fmt.Println(a, b, c)
if c > a {
goto inner
}
if a < b {
inner:
fmt.Println("a is less than b")
}
}
Go will fail to build the above code with the following errors:
goto skip jumps over declaration of b at ./prog.go:8:4
goto inner jumps into block starting at ./prog.go:15:11
So what could we use goto
for? Labeled break
and continue
statements allow you to jump out of deeply nested loops or skip iteration.
func main() {
a := rand.Intn(10)
for a < 100 {
if a%5 == 0 {
goto done
}
a = a*2 + 1
}
fmt.Println("do something when the loop completes normally")
done:
fmt.Println("do complicated stuff no matter why we left the loop")
fmt.println(a)
}
// do complicated stuff no matter why we left the loop
// 55
This shows that in a simple case, there is some logic that we don’t want to run in the middle of the function, but we do want to run the end of the function.
A real world example would be in the floatBits
method in atof.go in the strconv
package in the standard library. The method ends with this code:
overflow:
// ±Inf
mant = 0
exp = 1<<flt.expbits - 1 + flt.bias
overflow = true
out:
// Assemble bits.
bits := mant & (uint64(1)<<flt.mantbits - 1)
bits |= uint64((exp-flt.bias)&(1<<flt.expbits-1)) << flt.mantbits
if d.neg {
bits |= 1 << flt.mantbits << flt.expbits
}
return bits, overflow
}
Before these lines, there are several condition checks. Some require the code after the overflow
label to run, while other conditions require skipping that code and going directly to out
.
Depending on the condition, there are goto
statements that jump to overflow
or out
. You could probably come up with a way to avoid the goto
statements, but they all make the code harder to understand.