Golang control flow statements

In high-level programming languages, a control-flow statement is a class of statements used to control the flow of program execution, hereafter referred to as control statements. They can execute corresponding blocks of code according to conditions or loops, or jump to specified locations to execute code.

Common control statements include:

  • Conditional statements: execute different blocks of code according to conditions, such as if statements, switch statements, etc.
  • Loop statements: Repeatedly execute the corresponding block of code according to the condition, such as for statement, while statement, etc.
  • Jump statement: Jump to the specified position to execute the code, such as break, goto statement.
  • Exception handling statements: Handle exceptions that occur during program operation, such as try-catch statements, throw statements, etc.

Control statements are an important means of implementing program logic in programming languages, and they help programmers to implement complex algorithms and logic. The types and usage of control statements supported by different programming languages may vary, but they all serve a similar basic purpose, namely to control the flow of program execution.

The number of control statements in Go is one of the smallest in the mainstream, with the main ones being if, for and switch. goto, defer and panic/recover statements are of course also classified as control statements, and the latter are all important tools for implementing program logic in Go. But these latter statements are not the focus of this article. In this article I will focus on Go’s if, for and switch statements.

1. if statements

Let’s start by looking at the if statement.

The if statement is used to execute a block of code based on a condition and is the most commonly used control statement in the Go language.

The basic syntax of the if statement is as follows:

1
2
3
4
5
6
7
if condition {
    // code block
} else if condition {
    // code block
} else {
    // code block
}

I’ll share the following 3 main points about if statements.

1.1 Implicit code blocks

Let’s look at the following code:

1
2
3
4
5
6
7
8
func bar() {
    if a := 1; false {
    } else if b := 2; false {
    } else if c := 3; false {
    } else {
        println(a, b, c)
    }
}

After reading this code, do you think it will compile properly? The Go compiler tells us that it will compile and run correctly. But many people will question why the variable a declared in the first if statement, the variable b in the second if and the variable c in the third if are all accessible in the final else statement.

To answer this question, it is necessary to clarify the implicit code block and scope rules of the if statement. The above code is equivalent to the following code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func bar() {
    { // An implicit block of code equivalent to the first if
        a := 1 // The scope of variable a starts here
        if false {

        } else {
            { // An implicit block of code equivalent to the first else if
                b := 2 // The scope of variable b starts here
                if false {

                } else {
                    { // An implicit code block equivalent to the second else if
                        c := 3 // The scope of variable c starts here
                        if false {

                        } else {
                            println(a, b, c)
                        }
                        // The scope of variable c ends here
                    }
                }
                // The scope of variable b ends here
            }
        }
        // The scope of variable a ends here
    }
}

With this expanded code, we can clearly see that the final else statement in the first code is essentially an innermost else, and that the scope of the variables a, b and c can be covered by the else.

1.2 Using self-use variables

When using if statements in Go, developers often struggle with which of the following forms to use:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
if a, ok := foo(); a < 10 && ok{ // Self-use variables using if expressions

} 

vs.

a, ok := foo()
if a < 10 && ok {

}

The first is recommended here, i.e. “self-using variables with if expressions”, rather than defining temporary variables outside of if. This is because in addition to the advantages of brevity and slightly better readability, the former also has the advantage of placing a in an if-implicit code block, which limits the scope of the variable a to a minimum, thus avoiding conflicts caused by variables being named the same in different code sections. In turn, this makes the code implementation clearer and easier to understand.

There is another point in the above code that may confuse beginners, and that is the operator precedence of a < 10 && ok, whether it is (a < 10) && ok or a < (10 && ok). To avoid confusion for subsequent readers of the code, it is recommended to use parentheses to clarify the order of computation when evaluating the value.

1.3 The happy path principle

One of the conventions of using if statements in Go is to follow the happy path principle. By happy path, we mean that code is made more readable and maintainable by minimising indentation and avoiding excessive nesting of if statements or else-if statements. Following the happy path principle makes your code easier to read and understand, and the flow of execution becomes clearer.

The happy path means that code should be left-aligned as much as possible, with minimal nesting, as shown in the following diagram:

happy path

In coding practice, there are several techniques to satisfy the happy path:

  • Reduce the use of else, else if;
  • Avoiding the use of nested if statements;
  • Quick returns. Use return in the body of the if statement to return from the function, rather than continuing with subsequent processing.

2. for statements

The for statement is second only to the if statement in terms of frequency of use for control flow statements. Here are some of the features of Go’s support for looping statements.

2.1 Only one for loop

Go believes in “only one way to do something”, and I wonder if this is the original reason why Go only offers one form of for statement (as opposed to other mainstream programming languages that offer while, loop, do…while, etc.).

Some typical uses of Go’s classic for statement are as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
//The most conventional for loop
for i := 0; i < 10; i++ {
    fmt.Println(i)
}

// Simulating a while loop
i := 0
for i < 10 {
    fmt.Println(i)
    i++
}

// Infinite loop
for {
    // do something
}

2.2 for range is not optional

It would be inaccurate to say that go only has a for statement. go also has a for range variant. However, this for range variant is not optional. Some traversals cannot be completed without a for range, for example:

1
2
3
4
5
6
7
8
// Iterate over map
for k, v := range aMap {
}

// Iterate over the characters in a string (not byte traversal)
for i, r := range s {
  // rune
}

2.3 Continue and break with and without a label

In Go, it is possible to use continue and break statements with a label in a for loop, as well as continue and break statements without a label as we normally know them. However, the differences between them should be borne in mind:

  • continue and break statements without labels

    The continue and break statements without a label can only be used in the current for loop statement, and their scope is limited to the inside of the current loop body. When the continue statement is executed, it skips the current loop and goes directly to the next loop; when the break statement is executed, it ends the current loop and jumps directly out of the loop body.

  • continue and break statements with labels

    The continue and break statements with a label can be used in multi-layered nested for loops that jump out of the specified loop body. When the continue statement with a label is executed, it skips the current loop in the specified loop body and goes directly to the next loop; when the break statement with a label is executed, it ends the specified loop body and jumps directly out of the loop.

    The following is an example of using a break statement with a label:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    
    package main
    
    import "fmt"
    
    func main() {
        outerLoop:
        for i := 1; i <= 3; i++ {
            for j := 1; j <= 3; j++ {
                if i == 2 && j == 2 {
                    // Jump out of the specified loop body
                    fmt.Println("Jumping out of the outer loop")
                    break outerLoop
                }
                fmt.Printf("i=%d, j=%d\n", i, j)
            }
        }
    }
    

    In this example, we have used a break statement with a label to jump out of the outer loop, thus avoiding the need to continue executing the outer loop. If we had used a break statement without a label, only the inner loop would have been jumped out, not the outer loop.

2.4 Pitfalls

Although Go has only one form of for statement, there are a number of possible ‘pitfalls’. Here is a list of some typical pitfalls.

  • Loop variable reuse

    Have a look at the following code:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
    func main() {
        var m = []int{1, 2, 3, 4, 5}  
    
        for i, v := range m {
            go func() {
                time.Sleep(time.Second * 3)
                fmt.Println(i, v)
            }()
        }
    
        time.Sleep(time.Second * 10)
    }
    

    What is your expected output? What is the actual output? Run it in the go playground and get the following:

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

    Why does it output this result? You’ll see if I make an equivalent transformation of the above code:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    
    func main() {
        var m = []int{1, 2, 3, 4, 5}  
    
        {
        i, v := 0, 0
            for i, v = range m {
                go func() {
                    time.Sleep(time.Second * 3)
                    fmt.Println(i, v)
                }()
            }
        }
    
        time.Sleep(time.Second * 10)
    }
    

    We see that instead of redeclaring the variables i, v each time we loop, we only define one copy throughout the loop. This is why all goroutines output “4 5”, and the Go team is working on an optimisation for this problem, which may be “fixed” naturally in a later version of Go.

  • Copy in range expression

    Let’s look at another piece of code:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    
    func main() {
        var a = [5]int{1, 2, 3, 4, 5}
        var r [5]int
    
        fmt.Println("original a =", a)
    
        for i, v := range a {
            if i == 0 {
                a[1] = 12
                a[2] = 13
            }
            r[i] = v
        }
    
        fmt.Println("after for range loop, r =", r)
        fmt.Println("after for range loop, a =", a)
    }
    

    In your expectation, the output of the above program would look like this:

    1
    2
    3
    
    original a = [1 2 3 4 5]
    after for range loop, r = [1 12 13 4 5]
    after for range loop, a = [1 12 13 4 5]
    

    But actually run it and you’ll see that the real output looks like this:

    1
    2
    3
    4
    5
    6
    7
    
    for i, v := range a { //a is a copy of the value of a
        if i == 0 {
            a[1] = 12
            a[2] = 13
        }
        r[i] = v
    }
    

    After this transformation, we know that the for range traverses a copy of a and that changes to a will not affect subsequent traversals.

    Therefore, be very careful when using arrays and slices as the set of containers to be traversed after range.

  • break does not jump out of the for code block

    There is also a pitfall to be aware of when using for in conjunction with switch statements, take a look at the following code:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    
    func main() {
        var sl = []int{5, 19, 6, 3, 8, 12}
        var firstEven int = -1
    
        // find first even number of the interger slice
        for i := 0; i < len(sl); i++ {
            switch sl[i] % 2 {
            case 0:
                firstEven = sl[i]
                break
            case 1:
                // do nothing
            }
        }
        println(firstEven)
    }
    

    Executing this code, the output is 12, which does not match the first even 6 we expected. What is the reason for this? From the fact that the output is 12, it seems that the break did not jump out of the for loop, causing it to continue to the end.

    Remember: the Go specification specifies that a break statement without a label breaks execution and jumps out of the innermost for, switch or select within the same function where the break statement is located. This is the reason why the program did not execute as we expected.

3. switch statement

Finally, let’s talk about the switch statement. In Go, the switch statement is also a common control flow statement that can execute different blocks of code depending on different conditions.

1
2
3
4
5
6
7
8
switch expression {
case value1:
    // Execute code block 1
case value2:
    // Execute code block 2
default:
    // Execute the default code block
}

Since Go switch execution semantics do not execute the next case block by default, the above switch statement is equivalent to a multiple if-else statement, but is easier to understand and more readable than multiple layers of if else. In such cases, we recommend using switch instead of multiple if-else statements.

3.1 The order of case statements

A switch statement will usually have a number of expressions, and the order in which these expressions are evaluated is clearly defined, i.e. starting with the switch expression, and then the individual case statements are evaluated in a top-to-bottom, left-to-right order. Remembering this order will help you to analyse the semantics of the execution of switch statements.

3.2 Flexibility of the switch case

The Go switch statement has many optimisations in terms of syntax semantics compared to its ancestor, the C Switch statement, resulting in more flexibility, the main ones being:

  • support for comparison of any value, unlike C which can only use int or enumerations

  • case code blocks support lists of expressions

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    
    package main
    
    import "fmt"
    
    func main() {
        num := 3
        switch num {
        case 1, 3, 5: // Support for using expression lists
            fmt.Println("Odd number")
        case 2, 4, 6:
            fmt.Println("Even number")
        default:
            fmt.Println("Other")
        }
    }
    
  • The next case statement will not be executed by default

    That execution semantics in C of executing the next case statement by default causes us to need to use break to jump out of the switch in each case, Go fixes this semantics, see the following example:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    
    package main
    
    import "fmt"
    
    func main() {
        num := 2
        switch num {
        case 1:
            fmt.Println("First case block")
        case 2:
            fmt.Println("Second case block")
        case 3:
            fmt.Println("Third case block")
        }
    }
    

    This example will only output the “Second case block” and will not execute the code in case 3.

    To explicitly tell the next case block to be executed, you would need to use fallthrough, and obviously Go uses the common execution logic as the default semantics, i.e. jumping out after each case; C does the opposite.

3.3 type switch

Not available in other languages, or unique to Go, type switch is a special syntax for interface type expressions, and the syntax format is relatively fixed.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
var x interface{} = 3
switch i := x.(type) {
case nil:
    // The type of x is nil
    println(i) // Output the dynamically typed values stored in x
case int:
    // The type of x is int
case string:
    // The type of x is string
default:
    // Type of x is other type
}

If the dynamically typed values stored in the interface variables are not required, they can also be simplified as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
var x interface{} = 3
switch x.(type) {
case nil:
    //The type of x is nil
case int:
    // The type of x is int
case string:
    // The type of x is string
default:
    // Type of x is other type
}

4. Summary

The Go control flow statements are not as extensive as they could be, but they are sufficient to help developers implement different types of program logic. When writing code, you need to choose the right control statements for your specific needs and be careful to follow the conventions and specifications for using the various control statements.

5. Ref

  • https://tonybai.com/2023/05/27/control-flow-statement-in-go/