3.1 if 语句

age := 18

if age >= 18 {
    fmt.Println("成年")
} else if age >= 12 {
    fmt.Println("青少年")
} else {
    fmt.Println("儿童")
}

两个 Go 特色

  1. 条件不加括号(加了也能编译,但不规范,会被 gofmt 警告)
  2. if 可以带初始化语句(Go 特色):
// if 前可以加一个初始化语句,变量作用域只在 if-else 块内
if num := computeScore(); num >= 90 {
    fmt.Println("优秀", num)
} else {
    fmt.Println("继续努力", num)
}
// num 在这里不可访问(出了 if-else 块就失效)

💡 「变量作用域」是什么意思?为什么出了 {} 就访问不到?这个概念非常重要,3.6 节会系统讲透(包括 if/for 作用域、变量遮蔽等新手必踩的坑)。现在先记住::= 在 if 初始化语句里声明的变量,只在 if-else 链内有效

对比 Node.js

// JS:  if (age >= 18) { ... }     // 必须括号
// Go:  if age >= 18 { ... }        // 不加括号

// JS:  if (1) { ... }              // 合法(1 转 true)
// Go:  if 1 { ... }                // ❌ 编译错误,条件必须是 bool

⚠️ 再次强调:Go 条件必须是 bool 类型,整数不能当布尔用


3.2 for 循环 —— Go 只有 for,没有 while

Go 把所有循环都统一成了 for。三种形式:

形式一:经典三段式(和 C/Java 一样)

for i := 0; i < 5; i++ {
    fmt.Println(i)
}
// 上面的代码等价于
for i := range 5 {
    fmt.Println(i)
}
// 如果

形式二:类 while(省略前后)

n := 10
for n > 0 {
    fmt.Println(n)
    n--
}
// 等价于 JS: while (n > 0) { ... }

形式三:无限循环

for {
    fmt.Println("无限循环")
    break    // 不 break 就真的无限
}

形式四:range 遍历(最重要,类似 for...of)

nums := []int{10, 20, 30}    // 切片,下章详讲
for index, value := range nums {
    fmt.Printf("索引 %d: 值 %d\n", index, value)
}

// 只要值,丢弃索引(用 _ 占位)
for _, v := range nums {
    fmt.Println(v)
}

// 只要索引
for i := range nums {
    fmt.Println(i)
}

对比 Node.js

// JS
for (const v of nums) { }       // for...of
nums.forEach((v, i) => { })     // forEach
for (const i in obj) { }        // for...in 遍历 key

// Go
for i, v := range nums { }      // range 一个,同时拿到索引和值

关键陷阱:range 遍历切片,value 是拷贝

nums := []int{1, 2, 3}
for _, v := range nums {
    v *= 10      // ❌ 不会改变 nums,因为 v 是拷贝
}
fmt.Println(nums)   // [1 2 3],没变

// 要改原数组:
for i := range nums {
    nums[i] *= 10    // ✅ 通过索引修改
}

遍历 map(顺序不保证!)

m := map[string]int{"a": 1, "b": 2, "c": 3}
for k, v := range m {
    fmt.Println(k, v)   // 顺序可能每次不同!
}

⚠️ Go 的 map 遍历顺序是随机的,这是故意的(防止依赖顺序)。下章详讲。


3.3 switch —— 默认不穿透

day := "Monday"

switch day {
case "Saturday", "Sunday":        // 多个值用逗号
    fmt.Println("周末")
case "Monday", "Tuesday", "Wednesday", "Thursday", "Friday":
    fmt.Println("工作日")
default:
    fmt.Println("未知")
}

Go switch 的三大特色

  1. 默认不穿透(不需要写 break!)—— 和 PHP/Java/C 完全相反
  2. 要穿透就写 fallthrough(极少用)
  3. case 可以是任意类型、任意表达式(不限于常量)
// case 可以是表达式
score := 85
switch {
case score >= 90:
    fmt.Println("A")
case score >= 80:
    fmt.Println("B")
case score >= 60:
    fmt.Println("C")
default:
    fmt.Println("D")
}
// 等价于一长串 if-else if,但更清晰

对比 PHP

// PHP: 每个 case 必须写 break,否则穿透
switch ($day) {
    case "Monday": ...; break;     // 不写 break 会穿透到下一个
}

// Go: 默认不穿透
switch day {
case "Monday": ...                 // 自动 break,不会穿透
}

3.4 函数 —— 多返回值是 Go 的标志

函数声明语法

// 基本函数
func add(a int, b int) int {
    return a + b
}

// 参数类型相同时可以简写
func add(a, b int) int {
    return a + b
}

// 调用
sum := add(3, 5)

对比 Node.js 箭头函数

// JS
const add = (a, b) => a + b

// Go
func add(a, b int) int { return a + b }

语法顺序不同(Go 是 func 名(参数) 返回类型),但概念一样。

多返回值(Go 标志性特性)

这是 Go 和 PHP/JS 最大的差异之一。Go 函数可以返回多个值

// 返回商和余数
func divmod(a, b int) (int, int) {
    return a / b, a % b
}

q, r := divmod(17, 5)
fmt.Println(q, r)   // 3 2

最常见的用法:返回结果 + 错误(这是 Go 的标志性模式,第 07 章会系统讲错误处理):

简单说:error 是 Go 内置的「错误类型」,nil 表示没错误。函数把「成功的结果」和「错误」一起返回,调用方通过检查 err != nil 判断是否出错。

func parseAge(s string) (int, error) {
    n, err := strconv.Atoi(s)
    if err != nil {
        return 0, err       // 出错:返回零值 + 错误
    }
    return n, nil            // 成功:返回结果 + nil(表示没错误)
}

age, err := parseAge("28")
if err != nil {              // 检查有没有出错
    fmt.Println("解析失败:", err)
    return
}
fmt.Println(age)

_ 丢弃某个返回值

q, _ := divmod(17, 5)    // 只要商,不要余数
_, r := divmod(17, 5)    // 只要余数

对比 Node.js:JS 函数只能返回一个值,要返回多个得用数组或对象解构:

// JS 的模拟多返回值(不优雅)
function divmod(a, b) {
    return [Math.floor(a/b), a%b]
}
const [q, r] = divmod(17, 5)

// Go 的多返回值是语言原生支持,类型明确、性能更好

命名返回值

返回值可以命名,相当于在函数开头声明了变量:

func divide(a, b int) (quotient int, remainder int) {
    quotient = a / b
    remainder = a % b
    return    // 裸 return,自动返回命名返回值
}

return 后不写值,会自动返回命名返回值。但大型函数里用裸 return 容易混乱,建议小函数用,大函数显式 return

可变参数

func sum(nums ...int) int {    // nums 是 []int 切片
    total := 0
    for _, n := range nums {
        total += n
    }
    return total
}

sum(1, 2, 3)           // 6
sum(1, 2, 3, 4, 5)     // 15

// 传一个切片
nums := []int{1, 2, 3}
sum(nums...)           // 注意三个点,展开切片

对比 Node.js

// JS
function sum(...nums) { }       // rest 参数
sum(...[1, 2, 3])               // spread 展开

// Go
func sum(nums ...int) { }        // 可变参数
sum(nums...)                     // 展开切片

几乎一样的概念。


3.5 闭包与匿名函数

Go 的函数是一等公民,可以赋值给变量、作为参数传递、作为返回值。

3.5.1 ⚠️ 先记住一个限制:函数内部不能声明「命名函数」

从 JS/PHP 转 Go 的人,第一个会撞上的限制是这个:

// JS:函数内部定义命名函数,完全合法
function outer() {
    function inner() {        // ← 命名函数,内部可见
        console.log("inner")
    }
    inner()                   // ← 能调用
}
// ❌ Go 不允许!编译错误
func outer() {
    func inner() {            // 错误:函数内部不能声明命名函数
        fmt.Println("inner")
    }
    inner()
}

Go 的规则func 名字() {} 这种命名函数声明只能写在包级别(函数外面),不能嵌套在函数里。

那函数内部要定义可复用的函数怎么办?匿名函数赋值给变量

// ✅ Go 的做法:匿名函数赋给变量
func outer() {
    inner := func() {         // 匿名函数,赋给变量 inner
        fmt.Println("inner")
    }
    inner()                   // ← 能调用,和命名函数效果一样
    inner()                   // ← 能反复调用
}

为什么 Go 这么设计? 简化作用域规则、防止函数嵌套地狱(像 JS 回调地狱)、倡导「小函数平铺组合」。实际开发里你很少需要嵌套命名函数,用匿名函数赋值完全够用。

💡 这条限制也解释了为什么 Go 代码看起来「扁平」——所有函数都平铺在包级别,不层层嵌套。

3.5.2 匿名函数

// 赋值给变量
greet := func(name string) string {
    return "Hello, " + name
}
fmt.Println(greet("Go"))

// 立即调用(IIFE,执行一次就丢弃)
result := func(x int) int { return x * 2 }(5)   // 10

闭包(捕获外部变量):

func counter() func() int {
    count := 0
    return func() int {
        count++
        return count
    }
}

c := counter()
fmt.Println(c())    // 1
fmt.Println(c())    // 2
fmt.Println(c())    // 3

对比 Node.js

// JS 完全一样的写法
const counter = () => {
    let count = 0
    return () => ++count
}
const c = counter()
c()  // 1

闭包概念可迁移,只是语法不同。


3.6 变量作用域与遮蔽(重要)

前面 3.1~3.5 节多次出现「作用域」这个词(比如 if 的初始化语句、循环变量),本节系统讲透。这是新手最容易踩坑的地方之一

3.6.1 先分清两个概念:作用域 vs 可见性

很多人(包括很多教程)把这两个混在一起讲,其实它们是两回事

作用域(Scope) 可见性(Visibility)
回答的问题 变量在哪些代码区域能被访问? 标识符能不能被别的包访问?
维度 横向(代码块层级) 纵向(包层级)
决定因素 变量声明在哪对 {} 标识符首字母大小写
谁讲的 本节 第 05、09 章

举个对照例子:

package main

import "fmt"

var GlobalCount = 0       // 包级变量:作用域=整个包;可见性=公开(大写)

func main() {
    local := 1            // 局部变量:作用域=main 函数内;可见性=私有(小写,但其实无意义,因为别的包看不到 main 内部)
    fmt.Println(local, GlobalCount)
}

本节只讲作用域(横向的代码块层级)。

3.6.2 块作用域 {}

Go 用 {} 划分作用域。一对 {} 就是一个块,块内声明的变量只在块内有效:

func main() {
    x := 1
    fmt.Println(x)        // ✅ 1

    {                     // 内层块开始
        y := 2
        fmt.Println(x, y) // ✅ 外层变量内层能用
    }                     // 内层块结束,y 的生命期到此为止

    fmt.Println(x)        // ✅ 1
    // fmt.Println(y)     // ❌ 编译错误:y 在这里不存在
}

规则一句话内层块能访问外层块变量,外层块访问不到内层块变量

3.6.3 if 语句的作用域

3.1 节提到的「if 带初始化语句」,那个变量的作用域只在整个 if-else 块内

if num := computeScore(); num >= 90 {   // num 在这里声明
    fmt.Println("优秀", num)             // ✅ num 能用
} else {
    fmt.Println("继续努力", num)          // ✅ num 在 else 里也能用
}
// fmt.Println(num)                      // ❌ 出了 if-else,num 就不存在了

注意 num 的作用域是整个 if-else 链(包括 else 分支),但出了右大括号就失效。

3.6.4 for 循环变量的作用域

循环变量 i 只在 for 循环内有效:

for i := 0; i < 3; i++ {
    fmt.Println(i)        // ✅ 0, 1, 2
}
// fmt.Println(i)         // ❌ i 出了循环就不存在

// 每次循环,i 是「新的」吗?
// Go 1.22+ :是,每次循环 i 是独立的新变量(重要!影响 goroutine,见 3.6.7)
// Go 1.21 及之前:不是,所有循环共享同一个 i

这涉及一个经典并发 bug(3.6.7 节详讲)。

3.6.5 函数作用域

函数的参数内部变量只在函数内有效,函数返回后就消失(除非被闭包捕获):

func add(a, b int) int {
    result := a + b       // result 只在 add 内有效
    return result
}

func main() {
    sum := add(2, 3)
    fmt.Println(sum)      // 5
    // fmt.Println(result)  // ❌ result 是 add 的局部变量,外部访问不到
}

3.6.6 包级作用域

在函数外面声明的变量,作用域是整个包,包内所有函数都能访问:

package main

import "fmt"

var globalCount = 0       // 包级变量,整个 main 包内可见

func increment() {
    globalCount++          // ✅ 函数内能访问包级变量
}

func main() {
    increment()
    increment()
    fmt.Println(globalCount)   // 2
}

⚠️ 但是! 还记得你之前问的问题吗——「为什么 c = true 写在函数外报错」?

原因正是:函数外只能有「声明」,不能有「语句」。包级作用域只接受声明

// 函数外(包级):
var c bool = false   // ✅ 声明,合法
c = true             // ❌ 赋值语句,非法(语句只能写在函数里)

如果你想「先声明一个值,启动时再改它」,把修改放到 init()main() 里(详见 3.7 节 init 函数)。

3.6.7 ⚠️ 变量遮蔽(Shadowing)—— 必须懂的坑

内层作用域声明了和外层同名的变量,外层变量被「遮住」看不见了

还记得你之前那行被注释的代码吗?

var b string = "8888"    // 外层 b(包级)

func main() {
    // var b = 9999       // 你注释掉的这行 —— 如果不注释,会怎样?
    fmt.Println(b)
}

取消注释后,函数内的 b 会遮蔽(shadow)包级的 b

  • 函数内看到的是新的 b(int 型 9999)
  • 包级的 b(string 型 "8888")在函数内完全访问不到

⚠️ 更隐蔽的坑:用 := 时,只要左边有一个新变量,整个赋值会用 :=;但如果左边都是已存在的变量,就是普通赋值 =

func main() {
    count := 0
    
    if true {
        count := 10      // ⚠️ 这是「声明新的 count」,不是「修改外层 count」!
        fmt.Println(count) // 10(内层 count)
    }
    fmt.Println(count)   // 0(外层 count 没被改!)
}

新手以为 count := 10 修改了外层 count,其实是声明了一个同名新变量。外层 count 还是 0。

怎么避免?

  1. VSCode 装 gopls 后,开启 shadow 检查("gopls": {"analyses": {"shadow": true}}
  2. go vet -shadow 检查
  3. 命名时避免内外层同名

3.6.8 ⚠️ 闭包 + 循环变量的经典 bug

这是 Go 历史最著名的坑,几乎每个 Go 开发者都踩过:

// ❌ Go 1.21 及之前的坑
funcs := []func(){}
for i := 0; i < 3; i++ {
    funcs = append(funcs, func() {
        fmt.Println(i)     // 闭包捕获的是「i 这个变量」
    })
}
for _, f := range funcs {
    f()   // 输出:3 3 3(不是 0 1 2!)
}

为什么? Go 1.21 及之前,整个 for 循环共享同一个 i。三个闭包捕获的都是同一个 i,循环结束时 i = 3,所以全打印 3。

Go 1.22+ 修复了这个问题:每次循环 i 都是独立的新变量,上面的代码会正确输出 0 1 2

但为了兼容旧代码,以及写更清晰的代码,推荐传参数

// ✅ 安全写法:把 i 作为参数传入
for i := 0; i < 3; i++ {
    funcs = append(funcs, func(n int) func() {
        return func() {
            fmt.Println(n)   // n 是参数,每次独立
        }
    }(i))
}

这个 bug 在第 08 章 goroutine 里会再遇到(goroutine + 循环变量),现在先有个印象。

3.6.9 作用域速查表

变量声明位置 作用域范围 能在哪访问
函数外(包级 var 整个包 包内所有函数
函数内 当前函数 函数内
{} 块内 当前块 块内(含子块)
if x := f(); ... 整个 if-else 链 if 和 else 分支
for i := ... 循环体 循环内
switch x := ... switch 块 case 分支

💡 记忆口诀

  • 变量只在它诞生的那对 {} 里活着,出了右大括号就消失
  • 内层能看外层,外层看不到内层
  • 内外层同名 = 遮蔽,外层被隐藏(坑!)

3.7 init 函数 —— Go 特有

每个包可以有一个或多个 init() 函数,在 main 之前自动执行

package main

import "fmt"

func init() {
    fmt.Println("init 函数先执行")
}

func main() {
    fmt.Println("main 函数后执行")
}
// 输出:
// init 函数先执行
// main 函数后执行

执行顺序:变量声明 → init() → main()。多个文件的 init 按文件名字母序执行(一般不依赖这个顺序)。

init 的实际用途(什么时候该用它)

用途一:初始化包级变量(最常见)

有些全局变量不能直接声明赋值(要计算、要查环境变量),用 init 最合适:

var config Config

func init() {
    // 从环境变量读配置,赋给 config
    config.Port = os.Getenv("PORT")
    config.DBUrl = os.Getenv("DB_URL")
    // 如果直接 var config = loadConfig() 也能做,但 init 里能处理错误、打印日志
    log.Println("配置加载完成:", config.Port)
}

用途二:自动注册(框架/驱动常用,最经典的场景)

Go 的数据库驱动、模板引擎都用 init「自注册」。原理:驱动的 init 函数把自己注册到一个全局表里,使用方只需下划线导入(见 9.3 节),不用手动调用:

// 假设这是 mysql 驱动包内部
package mysql

import "database/sql"

func init() {
    // 把自己注册到 database/sql 的驱动表里
    sql.Register("mysql", &MySQLDriver{})
}

使用方只要 _ "github.com/go-sql-driver/mysql"(下划线导入),这个 init 就会执行,驱动就注册好了。这是 Go 生态的核心机制——第 09 章 9.3 节会详细讲下划线导入。

用途三:校验前置条件

程序启动时检查必需的环境、配置,不满足直接 panic(fail fast):

func init() {
    if os.Getenv("API_KEY") == "" {
        log.Fatal("必须设置 API_KEY 环境变量")   // 启动失败比运行时报错好
    }
}

init 的规则

  • 不能手动调用init() 写在代码里会报错),只能由运行时自动执行
  • 没有参数、没有返回值
  • 一个文件可以有多个 init,按顺序执行
  • 一个包可以有多个 init(跨文件)

💡 什么时候用 init vs 直接在 main 里做?

  • 只属于本包的初始化(注册、配置)→ 用 init
  • 整个程序的启动流程(连数据库、起服务)→ 放 main 里
  • 经验:业务代码少用 init,库和框架多用 init

3.8 Node.js / PHP ↔ Go 对照表

概念 Node.js / PHP Go 备注
if 条件 if (x) if x Go 不加括号,条件必须 bool
while 循环 while (x) for x {} Go 只有 for
for...of for (const v of arr) for _, v := range arr
forEach arr.forEach(cb) for i, v := range arr Go 没有 forEach 方法
switch 穿透 默认穿透,要 break 默认不穿透 Go 默认行为相反
函数声明 function f() {} func f() {}
箭头函数 (a,b) => a+b func(a,b int) int {return a+b} Go 更啰嗦
多返回值 用数组/对象解构 原生支持 (int, error) Go 标志特性
解构忽略 [, b] = arr _, b := f() Go 用下划线
可变参数 ...args nums ...int 几乎一样
闭包 支持 支持 概念可迁移
立即执行 (function(){})() func(){}() Go 也支持