Operators, Control Flow and Scope
Module 1: Go Language Intro & Dev Environment | Lesson: m01-l05 | Level: Beginner After this lesson you will be able to: use Go's distinctive control-flow syntax, understand variable scope and shadowing, and write idiomatic Go control-flow code.
Learning Objectives
- Use Go's arithmetic, comparison, logical and bitwise operators correctly, and understand their precedence rules.
- Skillfully write control flow using
ifinitialization statements, the four forms offor, and the features ofswitch. - Explain the concepts of variable scope and shadowing, and avoid common shadowing pitfalls.
- Recognize and fix compile errors or logic bugs caused by scope or operator precedence.
Core Concepts
1.1 Go Operators and Precedence
Go's operators fall into arithmetic, comparison, logical, bitwise and assignment operators. Understanding operator precedence is essential for writing unambiguous expressions.
1. Arithmetic and comparison operators
These include +, -, *, /, % and ==, !=, <, <=, >, >=. They are mostly the same as in other languages.
2. Logical operators
These include && (logical and) and || (logical or). Go supports short-circuit evaluation:
- a && b: if a is false, b is not evaluated.
- a || b: if a is true, b is not evaluated.
This is very useful for guarding against nil pointer dereference or invalid-argument checks.
3. Bitwise operators
Go supports bit operations on integer types:
- & (bitwise AND): a & b
- | (bitwise OR): a | b
- ^ (bitwise XOR / complement): a ^ b is XOR; ^a is the unary complement.
- &^ (AND NOT): a &^ b clears from a the bits that are 1 in b. This is a Go-specific operator, often used for masking.
- <<, >> (left shift / right shift)
4. Precedence Go's operator precedence, from highest to lowest, is as follows (operators of equal precedence associate left to right, except for right shift):
| Precedence | Operators | Meaning |
|---|---|---|
| 5 | * / % << >> & &^ |
multiplicative / shift / bitwise AND |
| 4 | + - | ^ |
additive / bitwise OR / bitwise XOR |
| 3 | == != < <= > >= |
comparison |
| 2 | && |
logical AND |
| 1 | || |
logical OR |
Note: the unary operators +, -, !, ^, *, &, <- have the highest precedence (level 6). ++ and -- are statements rather than operators and have no precedence.
1.2 if with an Initialization Statement
Go's if statement supports an optional initialization statement that runs before the if is evaluated and whose scope is limited to the if block. This helps reduce variable pollution and makes the code more compact.
if err := doSomething(); err != nil {
// err is only valid in this scope
log.Fatal(err)
}
// err is not visible here
1.3 The Four Forms of for
Go has only one loop construct: for. Through different forms it can emulate the while, do-while and foreach of other languages.
- Standard loop (C style)
go for init; condition; post { } // for example: for i := 0; i < 5; i++ { } - Condition-only loop (while style)
go for condition { } // for example: for i < 5 { i++ } - Infinite loop
go for { } // usually used together with break - for range loop
Used to iterate over arrays, slices, strings, maps, channels, and (since Go 1.22) integers.
go for index, value := range slice { } - Emulating do-while
Go has no direct
do-while, but you can emulate it like this:go for { // loop body if !condition { break } }
Go 1.22 change: starting with Go 1.22 (in effect based on the go directive in the module's go.mod), for loops (both the three-clause form and for range) create a fresh loop variable on each iteration. This means capturing the loop variable inside a goroutine no longer needs extra closure tricks; each goroutine gets its own copy of the variable.
1.4 switch without break and Type Switches
Go's switch statement is very powerful:
1. No break needed: each case automatically falls through to the end after it finishes, unless you use fallthrough.
2. fallthrough: unconditionally executes the statement body of the next case. It easily obscures intent, so use it only when you genuinely need fallthrough.
3. Type compatibility check: the type of the expression after switch must be compatible (or convertible) with the types of the case values.
4. Type switch: used to inspect the dynamic type of an interface.
go
switch x.(type) {
case int:
// ...
case string:
// ...
}
1.5 Scope and Variable Shadowing
Scope determines where an identifier (variable, function name, etc.) is visible in the code. Go's scopes include:
- Universe scope: predeclared identifiers (such as int, nil, true).
- Package scope: identifiers declared outside functions, under the package declaration; visible to all files in the same package.
- File scope: package names brought in by import, visible only in the current file.
- Block scope: a block enclosed by {}, such as the inside of if, for or switch.
Shadowing: when an inner scope declares a variable with the same name as one in an outer scope, the inner variable "shadows" the outer one. Inside the inner scope, referring to that name resolves to the inner variable. This can cause bugs, because the outer variable's value is not actually modified.
Example Code
Example 1: Bitwise Operators and for range Basics
This example demonstrates bitwise operators (including &^ and the unary ^), logical short-circuiting, and the basic use of for range.
package main
import "fmt"
func main() {
a := uint8(12) // 00001100
b := uint8(10) // 00001010
// Bitwise demo
// ^a is the unary complement; for uint8 it is 255 - 12 = 243 (11110011)
fmt.Printf("^a = %08b (%d)\n", ^a, ^a)
// a &^ b is AND NOT, clearing the bits set in b: 00001100 &^ 00001010 = 00000100
fmt.Printf("a &^ b = %08b (%d)\n", a&^b, a&^b)
// Logical short-circuit demo
// If a > 10 were false, a < 20 would not be evaluated.
if a > 10 && a < 20 {
fmt.Println("Short-circuit OR worked")
} else {
fmt.Println("Condition failed")
}
// for range demo
slice := []int{1, 2, 3}
for i, v := range slice {
fmt.Printf("Index: %d, Value: %d\n", i, v)
}
}
Expected output:
^a = 11110011 (243)
a &^ b = 00000100 (4)
Condition failed
Index: 0, Value: 1
Index: 1, Value: 2
Index: 2, Value: 3
Example 2: switch Features and Scope Shadowing
This example demonstrates expression matching in switch, the if initialization statement, and the potential problems caused by variable shadowing.
package main
import "fmt"
func main() {
// 1. switch expression matching
score := 85
switch {
case score >= 90:
fmt.Println("A")
case score >= 80:
fmt.Println("B")
default:
fmt.Println("C")
}
// 2. if initialization statement and scope
// val is only visible inside the if block
if val := 10; val > 5 {
fmt.Println("val inside if:", val)
}
// fmt.Println(val) // error: val is undefined
// 3. A variable shadowing example
err := outerError()
fmt.Println("Outer err:", err)
// An inner err with the same name shadows the outer one
if innerErr := innerError(); innerErr != nil {
fmt.Println("Inner err:", innerErr)
// The err here is innerErr, not the outer err
}
// The outer err is still "outer error"
fmt.Println("Outer err after if:", err)
}
func outerError() error {
return fmt.Errorf("outer error")
}
func innerError() error {
return fmt.Errorf("inner error")
}
Expected output:
B
val inside if: 10
Outer err: outer error
Inner err: inner error
Outer err after if: outer error
Common Mistakes & Pitfalls
- Wrong assumption:
^is always bitwise XOR - Why:
^is both a unary operator (bitwise complement) and a binary operator (bitwise XOR). In the expression^a & b, because unary operators have the highest precedence, it is equivalent to(^a) & b, not^(a & b). -
Correct approach: make your intent explicit and use parentheses when needed. For example,
^(a & b)means AND first, then complement. -
Wrong code: switch case type mismatch
- Why: in
switch num,numis anint, but the cases write boolean expressions (such asnum > 0). Go requires the values aftercaseto be type-comparable with theswitchexpression. -
Correct approach: if you need boolean expressions, use
switch { case num > 0: ... }(equivalent toswitch true). -
Wrong code: variable shadowing causes a logic error
- Why: declaring a variable with the same name in an inner scope means the outer variable is not updated.
- Correct approach: avoid redeclaring a same-named variable in an inner scope, or use a different variable name. For example:
go var err error if err = doSomething(); err != nil { // Use assignment, not a short variable declaration }
Best Practices
- Use
ifinitialization statements: declare temporary variables inifto limit their scope and reduce naming pollution. - Prefer
for range: when iterating over a collection,for rangeis safer and more concise than an index loop. - Use
switchinstead of nestedif-else: when you have multiple discrete value checks,switchreads better and states intent more clearly; in most cases the performance difference is negligible, so do not use it as a selection criterion. - Know the
&^operator: use&^when you need to clear specific bits; it is more readable and less error-prone than& ^. - Go 1.22+ loop variables: if you use Go 1.22 or later, you need not worry about capturing loop variables in goroutines; if you must support older versions, create a local copy inside the loop body.
Exercises
- Concept question: explain what
fallthroughdoes in Go and what its potential risks are. - Coding exercise: write a function
classifyNumber(n int) stringthat uses aswitchstatement to determine whethernis "positive", "negative" or "zero". -
Advanced/debugging exercise: the code below is meant to print 0 through 4, but often prints nothing or behaves oddly. Find the problem and fix it. ```go package main
import ( "fmt" "sync" )
func main() { var wg sync.WaitGroup for i := 0; i < 5; i++ { wg.Add(1) go func() { fmt.Println(i) wg.Done() }() } } ```
Reference Answers
Reference Answers (click to expand / collapse)
Question 1
fallthrough is a keyword that was discouraged before Go 1.23 and is still retained from Go 1.23 onward but must be used carefully. It unconditionally executes the statement body of the next case, easily obscuring intent; use it only when you genuinely need fallthrough.
Question 2
package main
import "fmt"
func classifyNumber(n int) string {
switch {
case n > 0:
return "positive"
case n < 0:
return "negative"
default:
return "zero"
}
}
func main() {
fmt.Println(classifyNumber(10)) // positive
fmt.Println(classifyNumber(-5)) // negative
fmt.Println(classifyNumber(0)) // zero
}
Question 3
Problem analysis:
1. The given code lacks wg.Wait(), so main exits immediately, terminating the goroutines before they run — which is why it often prints nothing.
2. In Go 1.21 and earlier, the for loop variable i is the same variable across iterations, so all goroutines capture the same address and may eventually print the same value (usually 4). But in Go 1.22+, each iteration creates a new variable, fixing this automatically.
3. To be compatible with older versions and ensure correctness, make a copy of i inside the goroutine.
Fixed code:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
// Make a local copy to avoid the loop-variable capture issue in Go 1.21 and earlier
val := i
go func() {
fmt.Println(val)
wg.Done()
}()
}
// Wait for all goroutines to finish
wg.Wait()
}
Lesson Summary
- You have mastered Go's operator precedence, especially the difference between the bitwise
&^and the unary^. - You can skillfully use
ifinitialization statements, the four forms offor, andfor range. - You understand that
switchneeds nobreak, and when to usefallthrough. - You are clear on variable scope and shadowing, and can avoid common naming-conflict bugs.
Next lesson preview: functions — multiple return values, named return values and variadic parameters.