Build Web Application with Golang 第二章精读:Go 的控制语句、函数与 panic/recover 机制

原创2026-10-02 22:36:191,842 阅读
文章标签:文档教程后端

Build Web Application with Golang 第二章精读:Go 的控制语句、函数与 panic/recover 机制

本篇技术指南围绕开源电子书《Build Web Application with Golang》第二章第 3 节(西班牙语版本 es/02.3.md)展开,系统梳理 Go 语言的三大类流程控制语句(条件、循环、跳转)、函数定义的完整语法(多返回值、可变参数、值传递与指针)、defer 延迟执行、函数作为一等公民的类型用法、panic/recover 错误处理机制,以及 main/init 函数与包导入(import)的底层执行顺序。结合仓库中配套的可运行示例源码(en/code/src/apps/ch.2.3),读者读完即可在 Web 后端开发中熟练使用这些基础但核心的语言特性,并正确理解 Go 程序的初始化与包加载流程。

一、Go 的流程控制:三类语句一览

Go 语言的流程控制可以归纳为三大类:条件语句(condicionales)、循环控制(controles de ciclos) 与 无条件跳转(saltos incondicionales)。凭借它们,仅用少量简单语句即可表达复杂的业务逻辑。与 C 系语言相比,Go 仅有 25 个保留字(见 es/02.0.md),其中负责流程控制的主要是 if、else、for、range、switch、select、goto、break、continue、fallthrough,下面逐一深入。

1.1 if:无需括号,支持初始化语句

Go 的 if 是最常用的条件保留字:条件成立执行一段代码,否则执行另一段或什么都不做。与 C/Java 不同,Go 的 if 条件不需要用括号包裹:

if x > 10 {
    fmt.Println("x es mayor que 10")
} else {
    fmt.Println("x es menor que 10")
}

if 最实用的特性是可以在条件之前附带一条初始化语句,且初始化变量的作用域被严格限制在 if/else 块内部:

// 先初始化 x,再判断 x 是否大于 10
if x := computedValue(); x > 10 {
    fmt.Println("x es mayor que 10")
} else {
    fmt.Println("x es menor que 10")
}

// 下面的代码无法编译,因为 x 只在 if/else 块内可见
fmt.Println(x)

多条件判断可以直接使用 else if 链:

if entero == 3 {
    fmt.Println("entero es igual a 3")
} else if entero < 3 {
    fmt.Println("entero es menor que 3")
} else {
    fmt.Println("entero es mayor que 3")
}

仓库配套示例 en/code/src/apps/ch.2.3/main.go 中的 show_if() 与 show_if_var() 完整复现了上述三种写法,其中 computedValue() 返回固定值 1,演示了初始化语句在条件判断前先执行的效果。

1.2 goto:同函数内的标签跳转,务必谨慎

goto 跳转到同一函数体内定义的标签(label),标签名以冒号 : 结尾,且区分大小写:

func myFunc() {
    i := 0
Here:   // 标签以 ":" 结尾
    fmt.Println(i)
    i++
    goto Here   // 跳转到标签 "Here"
}

需要特别小心使用 goto,滥用会导致代码难以阅读与维护。仓库示例 en/code/src/apps/ch.2.3/main.go 中的 show_goto() 用 if i < 10 限制了跳转次数,避免形成死循环。

1.3 for:Go 中唯一也是最强大的循环

for 是 Go 中最强大的循环控制,既能完成典型的计数循环,也能扮演 while 的角色。标准三表达式形式:

for expression1; expression2; expression3 {
    //...
}

其中 expression1 与 expression3 通常是变量定义或函数返回值赋值,expression2 是条件判断。expression1 在每个循环开始前执行,expression3 在每轮循环体结束后执行。一个完整的求和示例:

package main
import "fmt"

func main(){
    sum := 0;
    for index:=0; index < 10 ; index++ {
        sum += index
    }
    fmt.Println("la suma es igual a ", sum)
}
// 输出:sum es igual a 45

并行赋值:Go 支持用 , 一次给多个变量赋值,例如 i, j = i + 1, j - 1,这在循环体内交换或同步更新多个变量时非常方便。

省略表达式:当 expression1 和 expression3 不需要时可以直接省略:

sum := 1
for ; sum < 1000;  {
    sum += sum
}

如果连分号 ; 也一并省略,for 就变成了地道的 while 循环:

sum := 1
for sum < 1000 {
    sum += sum
}

break 与 continue:break 跳出当前循环,continue 跳过本轮剩余代码进入下一轮。若存在嵌套循环,break 默认跳出的是它所在的那一层循环:

for index := 10; index>0; index-- {
    if index == 5{
        break // 或 continue
    }
    fmt.Println(index)
}
// break 输出:10、9、8、7、6
// continue 输出:10、9、8、7、6、4、3、2、1

range 遍历:for 配合 range 可以遍历切片(slice)或映射(map)等集合类型:

for k,v:=range map {
    fmt.Println("llave del mapa:",k)
    fmt.Println("valor del mapa:",v)
}

由于 Go 支持多返回值,且编译器会对声明了却未使用的变量报错,因此常用下划线 _ 丢弃不需要的返回值:

for _, v := range map{
    fmt.Println("valor del mapa:", v)
}

仓库示例 en/code/src/apps/ch.2.3/main.go 的 show_for_loop() 与 show_loop_through_map() 演示了标准循环、while 风格循环、break 中断以及 map 遍历的完整用法。

1.4 switch:替代冗长 if-else 的多分支利器

当 if-else 链过长、可读性与可维护性变差时,应当改用 switch:

switch sExpr {
case expr1:
    algunas instrucciones
case expr2:
    algunas otras instrucciones
case expr3:
    algunas otras instrucciones
  default:
    otro código
}

switch 的 sExpr 与各 case 表达式类型必须一致。它非常灵活:条件不必是常量,从上到下依次匹配,命中即执行;如果 switch 后不跟表达式,则等价于比较 true(可用于条件分支)。经典用法:

i := 10
switch i {
case 1:
    fmt.Println("i es igual a 1")
case 2, 3, 4:
    fmt.Println("i es igual a 2, 3 o 4")
case 10:
    fmt.Println("i es igual a 10")
default:
    fmt.Println("Todo lo que yo se, es que i es un entero")
}

注意两点:单个 case 可以并列多个值(如 case 2, 3, 4:);且 Go 的 case 执行完后会自动跳出 switch,无需像 C 那样写 break。如果希望命中后继续向下匹配,必须显式使用 fallthrough:

integer := 6
switch integer {
    case 4:
        fmt.Println("integer <= 4")
        fallthrough
    case 5:
        fmt.Println("integer <= 5")
        fallthrough
    case 6:
        fmt.Println("integer <= 6")
        fallthrough
    case 7:
        fmt.Println("integer <= 7")
        fallthrough
    case 8:
        fmt.Println("integer <= 8")
        fallthrough
    default:
        fmt.Println("default case")
}

上述程序输出:

integer <= 6
integer <= 7
integer <= 8
default case

从源码结构看,en/code/src/apps/ch.2.3/main.go 中的 show_switch() 完整复刻了这一 fallthrough 链,可直接运行验证输出结果。

二、函数:func 关键字与完整签名语法

Go 使用保留字 func 定义函数,完整的签名语法如下:

func funcName(input1 type1, input2 type2) (output1 type1, output2 type2) {
    // 函数体
    // 返回多个值
    return value1, value2
}

由上述示例可以得到以下关键规则:

  • 用 func 声明函数 funcName;
  • 函数可以有零个、一个或多个参数,参数类型写在参数名之后,多个参数用 , 分隔;
  • 函数可以返回多个值;
  • 示例中两个返回值分别命名为 output1、output2,也可以省略名字只写类型;
  • 若只有一个返回值且省略名字,return 后无需用逗号分隔多个值;
  • 若函数没有返回值,可以完全省略返回部分;
  • 若函数声明了返回值,则函数体内必须至少有一处 return。

一个求最大值的实际例子:

package main
import "fmt"

// 返回 a 和 b 中较大的值
func max(a, b int) int {
    if a > b {
        return a
    }
    return b
}

func main() {
    x := 3
    y := 4
    z := 5

    max_xy := max(x, y) // 调用 max(x, y)
    max_xz := max(x, z) // 调用 max(x, z)

    fmt.Printf("max(%d, %d) = %d\n", x, y, max_xy)
    fmt.Printf("max(%d, %d) = %d\n", x, z, max_xz)
    fmt.Printf("max(%d, %d) = %d\n", y, z, max(y,z)) // 直接在调用处传参
}

注意 max 的两个参数都是 int 类型,因此可以简写为 a, b int 而非 a int, b int;同时 max 只有一个返回值,只需写出返回值类型即可,这是一种简写形式。

2.1 多返回值:Go 相对 C 的一大优势

Go 原生支持函数返回多个值,这是它优于 C 语言的特性之一。示例(同时返回 A+B 与 A×B):

package main
import "fmt"

// 返回 A + B 和 A * B 的结果
func SumAndProduct(A, B int) (int, int) {
    return A+B, A*B
}

func main() {
    x := 3
    y := 4

    xPLUSy, xTIMESy := SumAndProduct(x, y)

    fmt.Printf("%d + %d = %d\n", x, y, xPLUSy)
    fmt.Printf("%d * %d = %d\n", x, y, xTIMESy)
}

返回值可以命名。一旦命名,函数体内只需写裸 return,因为命名返回值会自动初始化并随 return 一起返回:

func SumAndProduct(A, B int) (add int, Multiplied int) {
    add = A+B
    Multiplied = A*B
    return
}

需要留意:如果函数需要被包外使用(即函数名以大写字母开头),建议写完整显式的 return 语句,这样可读性更好。该示例在仓库中对应 en/code/src/apps/ch.2.3/variadic_functions/main.go。

2.2 可变参数(variadic):arg ...int

Go 支持定义可变参数个数的函数,称为 variadic 函数。语法是在参数类型前加省略号:

func myfunc(arg ...int) {}

arg ...int 表示该函数接收不定数量的 int 参数。在函数体内,arg 是一个整数切片(slice),可以像切片一样遍历:

for _, n := range arg {
    fmt.Printf("Y el número es: %d\n", n)
}

2.3 值传递与指针:为什么修改不生效,又如何生效

当我们向函数传参时,函数实际拿到的是变量的副本,函数内的任何修改都不会影响原始变量。验证示例:

package main
import "fmt"

// 给 'a' 加 1 的简单函数
func add1(a int) int {
    a = a+1 // 修改 a 的值
    return a // 返回 a 的新值
}

func main() {
    x := 3

    fmt.Println("x = ", x)  // 输出 "x = 3"

    x1 := add1(x)  // 调用 add1(x)

    fmt.Println("x+1 = ", x1) // 输出 "x+1 = 4"
    fmt.Println("x = ", x)    // 输出 "x = 3"
}

原因很简单:调用 add1 时,传入的是 x 的一份拷贝,而非 x 本身,所以函数内 a = a+1 无法改动外部的 x。

要真正修改原变量,需要借助指针。变量存储在内存中并拥有内存地址,只有拿到地址并修改该地址上的值,才能改变原变量。做法是:把 &x(x 的内存地址)传给函数,并将参数类型改为指针类型 *int。注意:我们传的仍然是指针的拷贝,而不是值的拷贝。

package main
import "fmt"

// 给 a 加 1 的简单函数
func add1(a *int) int {
    *a = *a+1 // 修改 a 指向地址上的值
    return *a // 返回 a 指向地址上的新值
}

func main() {
    x := 3

    fmt.Println("x = ", x)  // 输出 "x = 3"

    x1 := add1(&x)  // 传入 x 的内存地址

    fmt.Println("x+1 = ", x1) // 输出 "x+1 = 4"
    fmt.Println("x = ", x)    // 输出 "x = 4"——注意 x 被真正修改了
}

为什么要用指针?优势主要有三点:

  • 可以让多个函数协同修改同一个变量;
  • 传递内存地址(如 8 字节)开销低,按值拷贝大结构体在时间和空间上都低效;
  • string(字符串)、slice(切片)、map(映射)是引用类型,默认按引用传递;但注意:如果需要改变切片的长度(如追加导致扩容),必须显式传递切片指针。

仓库配套示例 en/code/src/apps/ch.2.3/pass_by_value_and_pointer/main.go 用 add_by_value 与 add_by_reference 两个函数并列演示了值传递与指针传递的行为差异,可直接运行对照。

2.4 defer:逆序执行的延迟调用

defer 是 Go 中设计非常精妙的关键字:一个函数内可以注册多个 defer,它们会在**函数即将返回时按逆序(后注册先执行)**依次执行。它特别适合资源管理场景——比如打开文件后,无论函数走哪个分支返回,都要保证文件被关闭。

未使用 defer 时的重复代码:

func ReadWrite() bool {
    file.Open("file")
    // 执行一些任务
    if failureX {
        file.Close()
        return false
    }

    if failureY {
        file.Close()
        return false
    }

    file.Close()
    return true
}

使用 defer 后代码既简洁又清晰:

func ReadWrite() bool {
    file.Open("file")
    defer file.Close()
    if failureX {
        return false
    }
    if failureY {
        return false
    }
    return true
}

多个 defer 按逆序执行,下面的例子会输出 4 3 2 1 0:

for i := 0; i < 5; i++ {
    defer fmt.Printf("%d ", i)
}

仓库示例 en/code/src/apps/ch.2.3/main.go 的 show_defer() 在循环中注册了 5 个 defer,并以一个额外的 defer 收尾,展示了逆序输出与 LIFO 行为。

2.5 函数作为值与类型:一等公民

在 Go 中函数也是变量,可以用 type 为函数签名定义类型别名,拥有相同签名的函数可以被视为同一类型:

type typeName func(input1 inputType1 , input2 inputType2 [, ...]) (result1 resultType1 [, ...])

这一特性的核心价值在于:函数可以作为值传递,也可以作为参数传给其他函数。完整示例(用函数类型实现过滤逻辑):

package main
import "fmt"

type testInt func(int) bool // 定义一个函数类型变量

func isOdd(integer int) bool {
    if integer%2 == 0 {
        return false
    }
    return true
}

func isEven(integer int) bool {
    if integer%2 == 0 {
        return true
    }
    return false
}

// 把函数 f 作为另一个函数的参数传入

func filter(slice []int, f testInt) []int {
    var result []int
    for _, value := range slice {
        if f(value) {
            result = append(result, value)
        }
    }
    return result
}

func main(){
    slice := []int {1, 2, 3, 4, 5, 7}
    fmt.Println("slice = ", slice)
    odd := filter(slice, isOdd)    // 函数作为值使用
    fmt.Println("Odd elements of slice are: ", odd)
    even := filter(slice, isEven)
    fmt.Println("Even elements of slice are: ", even)
}

这里 testInt 是一个函数类型变量,filter 的第三个参数 f testInt 与 isOdd/isEven 的签名一致,因此可以传入不同的判定函数,让同一套过滤逻辑复用多种规则——这在接口(interface)设计中尤其有用,能使程序逻辑更复杂也更灵活。仓库示例 en/code/src/apps/ch.2.3/type_function/main.go 与文档完全一致。

2.6 panic 与 recover:Go 的错误处理双雄

Go 没有 Java 那样的 try-catch 异常结构,取而代之的是内建函数 panic 与 recover 来处理运行期错误。尽管 panic 非常强大,但不应该频繁使用。

  • panic:内建函数,用于打断程序的正常流程、使其进入恐慌(panic)状态。当函数 F 调用 panic 后,F 立即停止继续执行,但它注册的 defer 函数始终会执行;随后 F 向它的调用点返回,恐慌逐层向外传播,直到所有函数都以 panic 返回至该 goroutine 的最顶层,程序才会终止。panic 可以由程序显式调用产生,也可以由某些运行时错误(如数组越界访问)自动触发。
  • recover:内建函数,用于让一个 goroutine 从恐慌状态恢复。只有放在 defer 函数里的 recover 调用才有意义,因为程序处于恐慌状态时普通函数不会继续执行。若当前处于恐慌状态,recover 能捕获 panic 传入的值;若程序处于正常状态,它返回 nil。

使用 panic 的示例(初始化时检查环境变量):

var user = os.Getenv("USER")

func init() {
    if user == "" {
        panic("no hay valor para $USER")
    }
}

验证并捕获 panic 的示例:

func throwsPanic(f func()) (b bool) {
    defer func() {
        if x := recover(); x != nil {
            b = true
        }
    }()
    f() // 如果 f 触发 panic,这里会将其恢复
    return
}

仓库示例 en/code/src/apps/ch.2.3/panic_and_recover/main.go 将 panic、recover 与 defer 组合在一起:check_user() 在 $USER 为空时触发 panic,外层 throwsPanic 用 defer 中的 recover 捕获并返回 didPanic = true。

2.7 main 与 init:程序初始化的固定顺序

Go 程序中有两个特殊的保留函数 main 与 init:init 可用于所有包,main 只能用于 main 包;两者都不能有参数,也不能有返回值。虽然同一个包中可以写多个 init,但强烈建议每个包只写一个 init。

Go 程序会自动调用 init() 与 main(),无需(也不允许)手动调用。对每个包而言 init 是可选的,但 main 包必须有且仅有一个 main 函数。初始化流程如下:

  1. 程序从 main 包开始初始化与执行;
  2. 如果 main 包导入了其他包,这些包会在编译期被导入;同一包即使被多次导入也只编译一次;
  3. 导入完成后,程序先初始化被导入包中的常量与变量,再执行该包的 init(若存在),如此逐层递进;
  4. 所有被导入包初始化完毕后,程序才初始化 main 包自身的常量与变量,随后执行 main 包的 init(若存在),最后进入 main 函数。

整体流程如下图所示:

Go 程序的初始化流程:先初始化被导入包,再初始化 main 包并执行 main 函数

图 2.6 Go 程序的初始化流程

仓库中的两个示例能直观验证这一机制:自定义包 en/code/src/apps/ch.2.3/import_packages/only_call_init/only_call_init.go 定义了一个打印日志的 init();en/code/src/apps/ch.2.3/type_function/main.go 则在 main 包中也注册了 init(),运行程序即可看到包初始化先于 main 执行的输出顺序。

2.8 import:标准库与第三方包的导入方式

Go 程序频繁使用 import 引入包:

import(
    "fmt"
)

之后即可通过包名调用其导出函数:

fmt.Println("Hola mundo")

fmt 属于 Go 标准库,位于 $GOROOT/pkg 下。导入第三方包通常有两种路径形式:

  1. 相对路径:import "./model"——加载同目录下的包,不建议使用这种方式;
  2. 绝对路径:import "shorturl/model"——从 $GOPATH/pkg/shorturl/model 加载包。

此外还有三个容易让初学者困惑的特殊导入操作符:

1. 点操作符 .

import(
    . "fmt"
)

点号表示导入后可以省略包名直接调用其函数:不必写 fmt.Printf("Hola mundo"),直接写 Printf("Hola mundo") 即可。

2. 别名操作符

import(
    f "fmt"
)

把导入的包起个别名,调用时用别名代替包名:f.Printf("Hola mundo")。

3. 下划线操作符 _

import (
    "database/sql"
    _ "github.com/ziutek/mymysql/godrv"
)

_ 的含义是:只导入该包并触发它的 init() 函数,但不确定(或暂时不用)该包的任何导出函数。常见于数据库驱动的注册场景——驱动包通过 init() 完成自身注册,主程序无需直接调用它。

仓库示例 en/code/src/apps/ch.2.3/import_packages/main.go 在同一文件中同时演示了四种导入方式:_ 触发 only_call_init 的 init()、f "fmt" 别名导入、. "math" 点导入(使 E、Pow 等符号全局可见),以及 mymath 自定义包和 text/template 标准包的常规导入,运行时会依次输出包初始化日志与运算结果。

三、与 Web 开发实践的结合

本节内容虽然是 Go 语言基础,但在本书后续的 Web 开发章节中处处可见:

  • 表单校验(第 4 章)大量使用 if-else 与 switch 分支处理校验逻辑;
  • 数据库操作(第 5 章)依赖 defer 保证连接与文件资源在函数返回时可靠释放;
  • 高并发处理(第 7 章)中 panic/recover 用于保护 goroutine 不被单个请求的异常拖垮;
  • 路由与模板渲染(第 3、6 章)依赖 init 完成配置加载与驱动注册,依赖 import 组织模块化代码。

建议读者将本文所有示例与 en/code/src/apps/ch.2.3 目录下的源码逐一对照运行,在动手实践中吃透控制流与函数的每一个细节,为后续章节的实战开发打下坚实基础。

相关章节导航

登录后查看全文
build-web-application-with-golang