Go-08 流程控制与函数
流程控制与函数是 Go 程序的骨架。Go 在语法上做了大量减法:只有一种循环关键字 for,if/switch 不需要括号,switch 默认不穿透。而在 Go 1.22 中,for 循环变量的语义发生了一次重大变更,直接修复了困扰 Go 程序员十年之久的"闭包捕获循环变量"经典 bug。本章会把这些点逐一讲透,并配合大量可运行代码(Go 1.22+)。
1. if 与 for:Go 的极简流程控制
1.1 if:无括号、可带初始化语句
Go 的 if 有三个显著特点:条件表达式不加括号、{ 必须与 if 同行、条件必须是布尔类型(不能像 C 那样用整数)。
package main
import "fmt"
func main() {
score := 85
// 基本写法:无括号,条件必须是 bool
if score >= 60 {
fmt.Println("及格")
} else if score >= 80 {
// 注意:这里永远走不到,因为上面 >=60 先命中
fmt.Println("优秀")
} else {
fmt.Println("不及格")
}
}
if 带初始化语句是 Go 的一个高频惯用法。可以在 if 条件前声明一个只在 if/else 作用域内可见的变量,常用于错误处理:
// err 的作用域被限制在 if-else 块内,出了块就不可见
if v, ok := m["key"]; ok {
fmt.Println("找到值:", v)
} else {
// v 和 ok 在 else 分支里同样可见
fmt.Println("未找到,ok =", ok)
}
// 最经典的错误处理写法
if err := doSomething(); err != nil {
return err
}
// 此处 err 已经不可见,避免了变量污染外层作用域
惯用建议:能用带初始化的
if就用,可以把变量作用域收得最紧,减少命名冲突和误用。
1.2 for:Go 唯一的循环
Go 没有 while、do-while,只有 for。但 for 有多种写法,覆盖所有循环场景。
// 写法一:完整的三段式(初始化; 条件; 后置)
for i := 0; i < 5; i++ {
fmt.Println(i)
}
// 写法二:只有条件,等价于其他语言的 while
n := 10
for n > 0 {
n--
}
// 写法三:无限循环,等价于 while(true)
for {
// 通常配合 break / return 退出
break
}
// 写法四:只有后置或只有初始化也合法(分号不能省)
for i := 0; ; i++ {
if i > 3 {
break
}
}
三种写法可以用一张表概括:
| 写法 | 形式 | 等价于其他语言 |
|---|---|---|
| 三段式 | for init; cond; post {} |
for |
| 单条件 | for cond {} |
while |
| 无条件 | for {} |
while(true) |
| range | for k, v := range x {} |
foreach |
2. for-range 详解:最容易踩坑的地方
for-range 可以遍历数组、slice、map、string、channel,甚至整数(Go 1.22 新增)。它简洁,但也藏着几个经典陷阱。
2.1 range 各类型的行为
// 遍历 slice:第一个是索引,第二个是元素的【拷贝】
s := []int{10, 20, 30}
for i, v := range s {
fmt.Printf("下标 %d 值 %d\n", i, v)
}
// 只要索引
for i := range s {
_ = i
}
// 遍历 map:无序!每次运行顺序可能不同
m := map[string]int{"a": 1, "b": 2}
for k, v := range m {
fmt.Println(k, v)
}
// 遍历 string:i 是【字节索引】,v 是 rune(Unicode 码点)
for i, r := range "Go语言" {
fmt.Printf("字节位置 %d 字符 %c\n", i, r)
}
// 输出字节位置:0(G) 1(o) 2(语,占3字节) 5(言)
// 遍历 channel:不断接收直到 channel 关闭
ch := make(chan int, 3)
ch <- 1; ch <- 2; ch <- 3
close(ch)
for v := range ch {
fmt.Println(v)
}
// Go 1.22 新增:range 整数,i 从 0 到 n-1
for i := range 5 {
fmt.Print(i, " ") // 0 1 2 3 4
}
从编译降级看 range 的本质
for-range 是语法糖,编译器会把它降级成普通 for 循环。看懂降级后的伪代码,前面几个陷阱就一目了然:
// slice / array:遍历前先取好长度,之后增删元素不改变遍历次数
len_temp := len(range)
for index_temp := 0; index_temp < len_temp; index_temp++ {
value_temp = range_temp[index_temp] // 元素被【拷贝】给 value
index, value = index_temp, value_temp
// 原循环体
}
// map:用运行时迭代器,next 直接反映底层变化 → 遍历中增删【会】影响次数
for mapiterinit(t, m, &hiter); hiter.key != nil; mapiternext(&hiter) { ... }
// string:按 UTF-8 解码,value 是 rune;单字节走快路径,多字节 decoderune
// channel:for { v, ok := <-ch; if !ok { break }; ... },直到 channel 关闭
由此可直接解释两个高频问题:
- slice/array/string 遍历前已把长度存进
len_temp,所以循环中append新增元素不会改变遍历次数(但删元素可能读到已改动的数据,别在遍历时删)。 - map 没有预取长度,用的是运行时迭代器,所以遍历中新增/删除会影响遍历次数(新增的 key 可能被遍历到,也可能不被,行为未定义)。
2.2 陷阱一:range 的值是拷贝
range 拿到的元素 v 是副本,修改 v 不会影响原始容器。
type Point struct{ X, Y int }
pts := []Point{{1, 1}, {2, 2}}
// 错误:改的是副本,原 slice 不变
for _, p := range pts {
p.X = 100
}
fmt.Println(pts) // [{1 1} {2 2}] —— 没变!
// 正确:通过索引直接改原元素
for i := range pts {
pts[i].X = 100
}
fmt.Println(pts) // [{100 1} {100 2}]
对大结构体尤其要注意,v 的拷贝还会带来性能开销(见第 10 节优化)。
2.3 陷阱二:range 表达式只求值一次
range 后面的表达式在循环开始前只求值一次。对 slice 来说,遍历过程中即使 append,循环次数也是开始时的长度。
s := []int{1, 2, 3}
for i, v := range s {
if i == 0 {
s = append(s, 99) // 追加不影响本次循环次数
}
fmt.Println(i, v)
}
// 只输出 3 次:0 1 / 1 2 / 2 3,追加的 99 不会被遍历
2.4 重头戏:Go 1.22 循环变量语义变更
这是 Go 历史上最重要的语义变更之一。在 Go 1.22 之前,for 循环变量在整个循环中只有一个实例,每次迭代复用同一块内存;从 Go 1.22 开始,循环变量在每次迭代都是全新的实例。
这个变更修复了一个坑了无数人的经典 bug——闭包捕获循环变量:
// 经典 bug 代码
funcs := make([]func(), 0, 3)
for i := 0; i < 3; i++ {
funcs = append(funcs, func() {
fmt.Print(i, " ")
})
}
for _, f := range funcs {
f()
}
| Go 版本 | 输出 | 原因 |
|---|---|---|
| 1.21 及之前 | 3 3 3 |
三个闭包捕获同一个 i,循环结束时 i==3 |
| 1.22 及之后 | 0 1 2 |
每次迭代 i 都是新变量,闭包各自捕获不同的 i |
同样的道理也适用于 for-range 和把循环变量地址存起来的场景:
// 另一个经典坑:goroutine 捕获循环变量
for _, v := range []string{"a", "b", "c"} {
go func() {
fmt.Println(v) // 1.21 前很可能都打印 "c";1.22 后正确打印 a/b/c
}()
}
1.22 之前的传统修复手法(现在仍需了解,因为老代码里到处都是):
// 修复法一:循环内用同名局部变量"遮蔽"
for i := 0; i < 3; i++ {
i := i // 关键:每次迭代创建新的 i
funcs = append(funcs, func() { fmt.Print(i) })
}
// 修复法二:通过参数传值
for i := 0; i < 3; i++ {
go func(i int) { fmt.Print(i) }(i)
}
底层原理:变更前,编译器为循环变量分配一块内存,
post语句在同一块内存上自增;变更后,编译器在每次迭代结束时把变量值拷贝到下一次迭代的新变量,逻辑上等价于每轮i := i。因此闭包捕获到的是各自独立的变量。注意:只有真正逃逸(被闭包捕获或取地址)的循环变量才会分配新实例,普通循环变量编译器仍会优化到寄存器,性能几乎无差别。
这个变更由 go.mod 中的 go 1.22(或更高)指令控制。如果 go.mod 声明的版本低于 1.22,即使用新编译器编译,仍是旧语义——保证了向后兼容。
3. switch:比想象中强大
Go 的 switch 默认不穿透(不需要写 break),且能力远超 C 系语言。
3.1 表达式 switch 与 case 多值
day := 6
switch day {
case 1, 2, 3, 4, 5: // case 可以匹配多个值
fmt.Println("工作日")
case 6, 0:
fmt.Println("周末")
default:
fmt.Println("非法")
}
3.2 无条件 switch:优雅替代 if-else 链
switch 后可以不跟表达式,此时相当于 switch true,每个 case 写布尔条件。这是替代冗长 if-else if 的惯用法:
score := 85
switch {
case score >= 90:
fmt.Println("A")
case score >= 80:
fmt.Println("B") // 命中,输出 B
case score >= 60:
fmt.Println("C")
default:
fmt.Println("D")
}
3.3 fallthrough:显式穿透
如果确实需要穿透到下一个 case,用 fallthrough。注意:它无条件执行下一个 case 的代码块,不再判断下一个 case 的条件。
switch n := 1; n {
case 1:
fmt.Println("一")
fallthrough // 强制进入下一个 case,不判断 case 2 的条件
case 2:
fmt.Println("二")
// 没有 fallthrough,到此结束
case 3:
fmt.Println("三")
}
// 输出:一 二(不会输出三)
3.4 类型 switch:断言接口的动态类型
配合接口使用,判断底层的具体类型。v := i.(type) 只能用在 switch 中。
func describe(i interface{}) {
switch v := i.(type) {
case int:
fmt.Printf("int: %d\n", v) // v 的类型是 int
case string:
fmt.Printf("string 长度 %d\n", len(v)) // v 的类型是 string
case nil:
fmt.Println("nil")
case []byte, []rune: // 多类型时,v 的类型是接口本身
fmt.Println("字节或符文切片")
default:
fmt.Printf("未知类型 %T\n", v)
}
}
switch 也支持初始化语句:switch x := f(); x { ... },作用域规则同 if。
4. goto / break / continue 与标签
break 和 continue 默认只作用于最内层循环。要跳出多重循环,需要用标签(label)。
// 用标签跳出双重循环
Outer:
for i := 0; i < 3; i++ {
for j := 0; j < 3; j++ {
if i*j > 2 {
break Outer // 直接跳出外层 for,而非仅内层
}
fmt.Println(i, j)
}
}
// continue 也可以带标签:跳到外层循环的下一次迭代
Loop:
for i := 0; i < 3; i++ {
for j := 0; j < 3; j++ {
if j == 1 {
continue Loop // 直接进入外层 i++
}
fmt.Println(i, j)
}
}
goto 也存在,但只能在同一函数内跳转,且不能跳过变量声明。实际业务代码中极少用,偶见于统一的错误清理逻辑:
func process() error {
if err := step1(); err != nil {
goto cleanup
}
return nil
cleanup:
fmt.Println("清理资源")
return fmt.Errorf("处理失败")
}
惯用建议:优先用标签
break/continue解决多重循环控制,goto能不用就不用。
5. 函数基础
5.1 多返回值
Go 函数原生支持多返回值,这是 (结果, error) 错误处理范式的基石。
// 返回商和余数
func divmod(a, b int) (int, int) {
return a / b, a % b
}
// 惯用的 (值, error) 模式
func parse(s string) (int, error) {
if s == "" {
return 0, fmt.Errorf("空字符串")
}
return len(s), nil
}
func main() {
q, r := divmod(17, 5)
fmt.Println(q, r) // 3 2
// 不需要的返回值用 _ 丢弃
n, _ := parse("hello")
fmt.Println(n)
}
5.2 命名返回值及其陷阱
返回值可以命名,函数体内它们是初始化为零值的局部变量,return 可以裸写(naked return)。
// 命名返回值:sum 和 count 自动声明并初始化为 0
func stat(nums []int) (sum, count int) {
for _, n := range nums {
sum += n
count++
}
return // 裸返回,等价于 return sum, count
}
命名返回值的好处是自文档化、便于配合 defer 修改返回值(见 5 章/下章)。但也有陷阱:
// 陷阱:裸返回 + 中途逻辑,容易看错返回的到底是什么
func risky() (result int) {
result = 1
if true {
result = 2
}
return // 到底返回几?需要读完整个函数体,可读性差
}
取舍建议:短函数、需要
defer改返回值时用命名返回值;长函数避免裸返回,显式return x更清晰。
5.3 可变参数 ...
用 ...T 声明可变参数,函数内它就是一个 []T。
func sum(nums ...int) int {
total := 0
for _, n := range nums { // nums 是 []int
total += n
}
return total
}
func main() {
fmt.Println(sum(1, 2, 3)) // 直接传多个
fmt.Println(sum()) // 传 0 个也合法,nums 为空 slice
s := []int{1, 2, 3, 4}
fmt.Println(sum(s...)) // 用 ... 把 slice 展开传入
}
注意:可变参数必须是最后一个参数。fmt.Printf(format string, args ...interface{}) 就是这个模式。
5.4 参数值传递的本质
Go 中所有参数都是值传递——传递的是实参的一份拷贝。理解这点是理解 slice/map “看起来像引用传递"的关键。
func modify(x int) { x = 100 } // 改的是拷贝,外部不变
func grow(s []int) { s = append(s, 9) } // append 可能重新分配,外部看不到
func fill(s []int) { s[0] = 100 } // 改元素能生效,因为拷贝的 slice 头指向同一底层数组
func main() {
a := 1
modify(a)
fmt.Println(a) // 1
s := []int{1, 2, 3}
grow(s)
fmt.Println(s) // [1 2 3] —— append 后长度变化外部感知不到
fill(s)
fmt.Println(s) // [100 2 3] —— 元素修改生效
}
原理:slice 值本身是一个 {指针, 长度, 容量} 的结构体,传参时拷贝这个结构体。拷贝的指针仍指向同一底层数组,所以能改元素;但 append/重新赋值改的是拷贝的那份头信息,外部感知不到。map、channel 是引用类型(内部是指针),传参拷贝的指针指向同一份数据,因此增删改都可见。
6. 闭包 closure:函数式编程的核心
6.1 定义与"按引用捕获”
闭包 = 函数 + 它引用的外部变量。Go 的闭包按引用捕获外部变量:闭包内看到的是变量本身,而非它当时的值。
func main() {
x := 10
f := func() {
x++ // 修改的是外层的 x,不是拷贝
}
f()
f()
fmt.Println(x) // 12 —— 外层 x 被闭包改了
}
6.2 用闭包实现计数器
每次调用外层函数返回一个新闭包,各自持有独立的 count:
func counter() func() int {
count := 0
return func() int {
count++
return count
}
}
func main() {
c1 := counter()
c2 := counter()
fmt.Println(c1(), c1(), c1()) // 1 2 3
fmt.Println(c2()) // 1 —— c2 有自己独立的 count
}
6.3 逃逸到堆的原理
counter 返回后,其局部变量 count 竟然还"活着"——这是因为编译器的逃逸分析发现 count 被返回的闭包引用,函数返回后仍需存活,于是把它从栈上"逃逸"分配到堆上,由 GC 管理生命周期。
# 查看逃逸分析结果
go build -gcflags="-m" main.go
# 输出类似:./main.go:xx:2: moved to heap: count
理解逃逸对性能很重要:闭包捕获变量会引起堆分配和 GC 压力,热路径上要留意(见第 10 节)。
6.4 闭包与循环变量(呼应第 2 节)
// Go 1.22+ 输出 0 1 2;1.21- 输出 3 3 3
var fns []func()
for i := 0; i < 3; i++ {
fns = append(fns, func() { fmt.Print(i) })
}
for _, fn := range fns {
fn()
}
闭包捕获的是变量引用,而 1.22 让每轮迭代的循环变量成为新实例,所以三个闭包捕获了三个不同的 i。
6.5 闭包实现中间件
闭包非常适合实现装饰器/中间件模式:
type Handler func(string) string
// 日志中间件:接收一个 Handler,返回包装后的 Handler
func withLog(next Handler) Handler {
return func(req string) string {
fmt.Println("请求:", req)
resp := next(req)
fmt.Println("响应:", resp)
return resp
}
}
func main() {
h := withLog(func(req string) string {
return "处理[" + req + "]"
})
h("hello")
}
7. 匿名函数与高阶函数
7.1 匿名函数与立即执行
// 赋值给变量
add := func(a, b int) int { return a + b }
fmt.Println(add(1, 2))
// 立即执行函数表达式(IIFE),常用于初始化
result := func(x int) int {
return x * x
}(5) // 定义后立刻用 (5) 调用
fmt.Println(result) // 25
7.2 函数是一等公民:作为参数与返回值
在 Go 中函数是一等公民,可以赋值、传参、返回、放进数据结构。
// 函数作为参数(回调)
func apply(nums []int, op func(int) int) []int {
result := make([]int, len(nums))
for i, n := range nums {
result[i] = op(n)
}
return result
}
// 函数作为返回值
func multiplier(factor int) func(int) int {
return func(x int) int { return x * factor }
}
func main() {
double := multiplier(2)
fmt.Println(apply([]int{1, 2, 3}, double)) // [2 4 6]
// 直接传匿名函数作回调
squared := apply([]int{1, 2, 3}, func(x int) int { return x * x })
fmt.Println(squared) // [1 4 9]
}
7.3 函数类型
可以用 type 给函数签名起别名,让代码更清晰(http.HandlerFunc 就是典型):
// 定义函数类型
type BinOp func(int, int) int
func calc(a, b int, op BinOp) int {
return op(a, b)
}
func main() {
var add BinOp = func(a, b int) int { return a + b }
fmt.Println(calc(3, 4, add)) // 7
}
8. 方法值与方法表达式
方法本质上是"receiver 作为第一个参数"的函数。Go 提供两种把方法转成函数值的方式。
type Counter struct{ n int }
func (c *Counter) Inc() { c.n++ }
func (c Counter) Value() int { return c.n }
8.1 方法值(method value):绑定了 receiver
实例.方法 会得到一个已经绑定 receiver 的函数值,调用时不用再传 receiver。
c := &Counter{}
inc := c.Inc // 方法值:绑定了 c 这个 receiver
inc() // 等价于 c.Inc()
inc()
fmt.Println(c.n) // 2
绑定发生在取方法值那一刻。对于值 receiver,会拷贝当时的实例:
c := Counter{n: 5}
get := c.Value // 此刻拷贝了 c(n=5)
c.n = 100
fmt.Println(get()) // 5 —— 用的是绑定时的拷贝,不是最新的 c
8.2 方法表达式(method expression):receiver 变参数
类型.方法 得到一个未绑定 receiver 的函数,receiver 变成显式的第一个参数。
c := &Counter{}
// 方法表达式:类型.方法,receiver 需要显式传入
inc := (*Counter).Inc
inc(c) // 显式把 c 作为第一个参数传入
fmt.Println(c.n) // 1
value := Counter.Value
fmt.Println(value(Counter{n: 42})) // 42,值 receiver 显式传入
| 形式 | 语法 | receiver | 调用方式 |
|---|---|---|---|
| 方法值 | 实例.方法 |
已绑定(取值时确定) | f() |
| 方法表达式 | 类型.方法 |
未绑定,作为首参 | f(实例) |
方法值常用于把某个对象的方法作为回调传出去;方法表达式常用于需要泛化 receiver 的场景。
9. defer 一句带过
defer 用于注册延迟调用,在函数返回前(return 赋值之后、真正返回之前)按 LIFO 顺序执行,常用于资源释放:
f, _ := os.Open("file.txt")
defer f.Close() // 函数结束时自动关闭
defer 与命名返回值配合可以修改返回值,涉及执行时机、参数求值时机等细节,下一章(第 9 章)专门详解,此处不展开。
10. 优化与最佳实践
1)range 大结构体用索引避免拷贝。 range 会拷贝每个元素,大结构体拷贝代价高:
type Big struct{ data [1024]byte; id int }
items := make([]Big, 1000)
// 差:每次迭代拷贝整个 Big(1KB+)
for _, item := range items {
_ = item.id
}
// 好:只取索引,按需访问,零拷贝
for i := range items {
_ = items[i].id
}
2)减少闭包逃逸。 热路径上避免不必要的闭包捕获,能用参数传就用参数传,减少堆分配:
// 若非必要,别让闭包捕获大对象;用 -gcflags="-m" 检查逃逸
3)命名返回值的取舍。 短函数、需 defer 改返回值时用命名返回值;长函数避免裸返回。
4)优先无条件 switch 替代长 if-else 链,可读性更好,编译器也更易优化。
5)Go 1.22+ 可以放心在循环里开 goroutine / 存闭包,不再需要 i := i 的手动拷贝。但为老项目贡献代码时,先看 go.mod 的版本声明确认语义。
6)map 遍历无序,需要稳定顺序时先把 key 收集到 slice 排序再遍历。
11. 高频面试题
Q1:下面代码在 Go 1.21 和 1.22 分别输出什么?为什么?
var fns []func()
for i := 0; i < 3; i++ {
fns = append(fns, func() { fmt.Print(i) })
}
for _, f := range fns {
f()
}
答:Go 1.21 及之前输出 333;Go 1.22 及之后输出 012。原因:1.22 之前循环变量 i 整个循环只有一个实例,三个闭包捕获同一个 i,循环结束 i==3;1.22 起每次迭代 i 都是新实例,三个闭包捕获三个独立变量。这个语义由 go.mod 中的 go 1.22 指令启用。
Q2:如何在 Go 1.22 之前修复上面的 bug?
答:两种经典方法。①循环内用同名局部变量遮蔽:for i:=0;...{ i := i; ... };②通过参数传值:go func(i int){...}(i)。本质都是每轮创建一个新变量让闭包捕获。
Q3:for-range 遍历 slice 时修改元素,为什么有时不生效?
答:range 拿到的 v 是元素的拷贝,修改 v 不影响原容器。要修改原元素必须通过索引 s[i] = ...。此外 range 后的表达式只求值一次,遍历中 append 不影响本次循环次数。
Q4:命名返回值 + defer 会输出什么?
func f() (result int) {
defer func() { result++ }()
return 10
}
答:输出 11。return 10 先把 result 赋值为 10,然后 defer 执行 result++ 变成 11,最后函数才真正返回。命名返回值使 defer 能修改最终返回值。若返回值未命名,则 defer 无法改变已确定的返回值。
Q5:switch 中 fallthrough 的行为?
答:fallthrough 会无条件执行紧邻的下一个 case 代码块,且不再判断该 case 的条件。Go 的 switch 默认不穿透,必须显式写 fallthrough 才穿透,且它必须是 case 块的最后一条语句。
Q6:Go 的参数传递是值传递还是引用传递?
答:Go 只有值传递。传递的是实参的拷贝。slice/map/channel 看起来像引用传递,是因为它们内部含指针,拷贝的头信息指向同一份底层数据,所以能改数据;但对 slice 重新 append/赋值改的是拷贝的头,外部感知不到。
Q7:方法值和方法表达式的区别?
答:方法值 实例.方法 绑定了 receiver(取值那一刻确定,值 receiver 会拷贝实例),调用时 f() 无需传 receiver;方法表达式 类型.方法 未绑定 receiver,receiver 变成显式的第一个参数,调用 f(实例)。
Q8:闭包捕获的变量为什么函数返回后还存活?
答:编译器的逃逸分析发现该变量被返回的闭包引用、函数返回后仍需存活,于是将其从栈分配改为堆分配,生命周期交由 GC 管理。可用 go build -gcflags="-m" 观察逃逸。
小结
本章梳理了 Go 的流程控制与函数:
- 流程控制做减法:只有
for一种循环(三段式/单条件/无条件/range);if/switch无括号、可带初始化语句;switch默认不穿透、支持多值 case、无条件 switch 替代 if-else、类型 switch 断言接口。 - for-range 三大陷阱:值是拷贝、表达式只求值一次,以及 Go 1.22 循环变量每轮新建这一重大语义变更——它彻底修复了闭包/goroutine 捕获循环变量的经典 bug,输出从
333变为012。 - 多重循环控制用标签
break/continue,goto慎用。 - 函数支持多返回值、命名返回值(配合 defer 强大但裸返回有可读性陷阱)、可变参数
...;参数一律值传递。 - 闭包按引用捕获外部变量,可实现计数器、中间件;捕获会导致变量逃逸到堆。
- 函数是一等公民,支撑高阶函数、回调、函数类型。
- 方法值绑定 receiver,方法表达式把 receiver 变首参。
下一章我们将专门深入 defer、panic 与 recover,把错误处理与延迟执行的机制彻底讲清。
xingliuhua