Skip to content

Go 判断与分支:if、switch、type switch 和 select

面向有 Java 经验的开发者,基于 Go 1.26。

Go 的分支关键字不多:普通判断用 if,多路匹配用 switch,接口动态类型判断用 type switch,并发通信的多路选择用 select。真正需要留意的是关键字背后的语义:条件必须是 bool,初始化语句会引入作用域,switch 默认不会贯穿,type switch 变量的静态类型随 case 变化,而 select 只负责 channel 通信。

后文会一直追问“这段代码为什么这样运行”。顺序阅读可以建立完整的控制流模型,工作中遇到具体问题时,也可以直接查对应语法的边界。

目录

1. 先建立心智模型

先把四种分支分别对应到它们要回答的问题:

语法回答的问题典型输入
if这个布尔条件是否成立err != niln > 0
switch一个值或一组条件匹配哪一支状态码、命令、区间
type switch接口值当前装着什么动态类型any、接口实现
select哪个 channel 通信现在可以完成结果、取消、超时

四者都是语句,不是表达式。Go 不能像 Kotlin 那样,把 ifswitch 的结果直接赋给变量:

go
var label string
if score >= 60 {
	label = "通过"
} else {
	label = "未通过"
}

Go 也没有 Java 的三元运算符。简单的二选一直接写 if 即可。为了省几行而造一个接收 any 的通用 ternary 函数,不但会丢失类型信息,还可能让两个候选值提前求值。

分支的共同规则是:

  • 花括号必须写,左花括号与 ifswitchselect 在同一行;
  • 条件不会做“真值”转换,必须得到 bool
  • 每个分支形成词法块,可以声明局部变量;
  • 分支只控制当前 goroutine,不会自动取消其他 goroutine;
  • 清晰的控制流比“技巧性地压缩代码”更符合 Go 的风格。

2. if 的完整语法

2.1 基本形式

go
if condition {
	// condition 为 true
}

条件必须是布尔值:

go
count := 3

if count > 0 {
	fmt.Println("有数据")
}

// if count { } // 编译错误:int 不能当作 bool

Java 开发者也不会把 int 直接当布尔值,但从 JavaScript、Python 或 C 转过来时要特别留意。空字符串、零、nil slice 都不会自动变成 false

go
if name != "" {
	// ...
}
if len(items) != 0 {
	// ...
}
if value != nil {
	// ...
}

2.2 else 与 else if

go
if temperature < 0 {
	fmt.Println("结冰")
} else if temperature < 30 {
	fmt.Println("正常")
} else {
	fmt.Println("炎热")
}

else if 不是独立语法节点,可以把它理解成 else 中紧接着另一个 if。格式化工具会把它排成一行。

Go 的自动分号插入规则决定了 else 必须紧跟前一个右花括号:

go
if ok {
	use()
} else {
	fallback()
}

else 放到下一行会在 } 后插入分号,从而导致语法错误。实际项目统一运行 gofmt,不需要手工争论格式。

2.3 if 的初始化语句

完整语法允许在条件前执行一条简单语句,两部分用分号分隔:

go
if value, err := strconv.Atoi(text); err != nil {
	return fmt.Errorf("解析年龄 %q: %w", text, err)
} else {
	fmt.Println("年龄:", value)
}

初始化部分可以是短变量声明、赋值、自增/自减、发送语句,或函数调用;工程代码里几乎总是短声明:

go
if n := len(items); n == 0 {
	fmt.Println("空集合")
} else if n == 1 {
	fmt.Println("单个元素")
} else {
	fmt.Printf("%d 个元素\n", n)
}

这种形式的价值不在于少写一行,而在于把临时变量限制在真正需要它的控制流内。

3. 初始化语句与作用域

3.1 变量对所有分支可见

if 初始化语句声明的变量,从声明处开始,一直到整个 if 语句结束都可见,包括所有 else ifelse

go
if user, ok := cache.Get(id); ok {
	return user, nil
} else if user, err := repository.Find(id); err != nil {
	return User{}, err
} else {
	return user, nil
}

这段代码的作用域关系如下:

  • 第一个 user 属于整个外层 if
  • else if 的初始化语句又声明了一个新的 usererr
  • 在最后的 else 中,可见的是 else if 声明的 user
  • 整个语句之后,这些变量都不可见。

这种写法合法,但多个同名变量叠在一起并不好读。工程代码通常会把它拆开:

go
if user, ok := cache.Get(id); ok {
	return user, nil
}

user, err := repository.Find(id)
if err != nil {
	return User{}, err
}
return user, nil

3.2 条件块是隐式作用域

初始化语句所在的位置可以看成 if 自带的外层块,分支体是它的子块:

go
if n := 10; n > 0 {
	message := "positive"
	fmt.Println(n, message)
} else {
	fmt.Println(n) // n 仍然可见
}

// fmt.Println(n)       // 编译错误
// fmt.Println(message) // 编译错误

同样的规则适用于 switch、type switch 和 select 的 case 块。

3.3 := 的“至少一个新变量”规则

短声明可以在同一作用域中复用旧变量,但左侧至少要有一个该作用域的新变量:

go
value, err := load()
value, normalized := normalize(value) // normalized 是新变量,因此合法
_, _, _ = value, err, normalized

进入内层块后,同名标识符会创建新变量,而不是更新外层变量:

go
var err error

if data, err := os.ReadFile("config.json"); err != nil {
	fmt.Println(err)
} else {
	fmt.Println(len(data))
}

fmt.Println(err) // 外层 err,仍然是 nil

如果调用结束后还要使用结果,就先在外层声明,再用 =

go
var data []byte
var err error

data, err = os.ReadFile("config.json")
if err != nil {
	return err
}
fmt.Println(len(data))

4. 条件表达式与短路求值

4.1 &&||

逻辑与、逻辑或从左到右求值,并且短路:

go
if user != nil && user.Active {
	// user 为 nil 时,不会读取 user.Active
}

if cached || loadFromDisk() {
	// cached 为 true 时,不调用 loadFromDisk
}

短路既是性能行为,也是正确性保证。把有副作用的函数放在右侧要谨慎,因为它可能根本不会执行。

优先级从高到低大致是 !、比较运算、&&||。条件复杂时直接加括号,比要求读者背优先级更稳妥:

go
if authenticated && (isOwner || isAdmin) {
	// ...
}

4.2 比较的边界

两个操作数必须可以比较且类型兼容。slice、map 和 function 只能与 nil 比较,不能彼此比较:

go
if slice == nil {
	// 合法
}

// if sliceA == sliceB { } // 编译错误

数组和 struct 在其所有元素或字段可比较时可以用 ==。浮点数中的 NaN 与包括自身在内的任何值都不相等,因此不要用 x == math.NaN() 判断:

go
if math.IsNaN(x) {
	// ...
}

接口值比较还有一个运行时边界:若两个接口的动态值包含不可比较类型,执行 == 会 panic。接口和 nil 的细节会在接口章节展开。

5. 提前返回与错误处理

Go 代码常用 guard clause 把异常路径尽早结束:

go
func Process(input string) error {
	if input == "" {
		return errors.New("input 不能为空")
	}

	data, err := load(input)
	if err != nil {
		return fmt.Errorf("加载 %q: %w", input, err)
	}

	if err := validate(data); err != nil {
		return fmt.Errorf("校验 %q: %w", input, err)
	}

	return save(data)
}

和深层嵌套相比,提前返回让主路径保持在较浅的缩进层级。它也与 Go 的显式错误返回自然配合。

不必机械地消灭每个 else。当两个分支对称、都很短,而且之后没有共享主路径时,if/else 往往更清楚:

go
if enabled {
	start()
} else {
	stop()
}

错误分类应该看语义

不要通过错误文本分支:

go
// 不稳:错误文案一改,逻辑就失效
if strings.Contains(err.Error(), "not found") {
	// ...
}

优先使用 errors.Iserrors.As 或带语义的方法:

go
if errors.Is(err, fs.ErrNotExist) {
	return createDefault()
}

var timeout interface{ Timeout() bool }
if errors.As(err, &timeout) && timeout.Timeout() {
	return retry()
}

6. 表达式 switch

6.1 基本语法

go
switch status {
case "pending":
	fmt.Println("等待处理")
case "running":
	fmt.Println("处理中")
case "done":
	fmt.Println("完成")
default:
	fmt.Println("未知状态")
}

Go 的 switch 默认只执行第一个匹配的 case,不需要写 break。这和 Java 传统 switch 最大的不同之一。

一个 case 可以列多个表达式:

go
switch ext {
case ".jpg", ".jpeg", ".png", ".webp":
	return "image"
case ".mp3", ".wav":
	return "audio"
default:
	return "unknown"
}

case 从上到下依次求值,匹配后停止。表达式不要求是编译期常量,因此可以调用函数:

go
switch target {
case primary():
	usePrimary()
case fallback():
	useFallback()
}

case 表达式一旦带有副作用,哪些调用真正发生就很难从代码表面判断。工程代码通常让这里保持为纯表达式。

6.2 switch 的初始化语句

if 一样,switch 可以带初始化语句:

go
switch n := len(items); n {
case 0:
	fmt.Println("空")
case 1:
	fmt.Println("一个")
default:
	fmt.Printf("%d\n", n)
}

语法形式是:

text
switch SimpleStmt; Expression {
case ExpressionList:
	...
default:
	...
}

如果只需要表达式,不写分号;如果只需要初始化语句,分号后可以省略表达式,此时等价于 switch true

6.3 default 可以放在任意位置

default 在源代码中可以出现在 case 之间,但只有所有 case 都不匹配时才执行。为了阅读顺序一致,通常把它放在末尾。

6.4 case 的比较要求

switch 表达式会先求值一次,然后依次与 case 表达式比较。比较必须合法:

go
switch code := response.StatusCode; code {
case http.StatusOK:
	// ...
case http.StatusNotFound:
	// ...
}

普通 switch 不能直接匹配 slice 或 map,因为它们不可比较。需要按长度、字段或自定义谓词判断时,用无表达式 switch。

7. 无表达式 switch

省略 switch 表达式等价于对 true 做匹配,因此每个 case 写布尔条件:

go
switch {
case score < 0 || score > 100:
	return "非法"
case score >= 90:
	return "优秀"
case score >= 60:
	return "通过"
default:
	return "未通过"
}

它很适合:

  • 互斥的区间;
  • 一组按优先级排列的规则;
  • if/else if 会显得冗长的分类;
  • case 条件形态不一致的分支。

case 按顺序检查。若把 score >= 60 放在 score >= 90 前面,90 分也会先命中“通过”。

不要把几十条业务规则堆进一个 switch。若每一支都有复杂计算,把条件提取为命名函数,或者把策略放入 map/接口实现:

go
switch {
case isExpired(order):
	return expire(order)
case canShip(order):
	return ship(order)
case needsReview(order):
	return enqueueReview(order)
default:
	return ErrNoTransition
}

8. case、break 与 fallthrough

8.1 break

普通 case 末尾隐式终止。显式 break 主要用于从 case 中提前结束 switch:

go
switch mode {
case "fast":
	if len(items) == 0 {
		break
	}
	process(items)
}
afterSwitch()

这里的 break 只离开 switch,不会离开包含它的 for。要离开外层循环,需要标签:

go
Outer:
for {
	switch next() {
	case "stop":
		break Outer
	}
}

8.2 fallthrough

fallthrough 无条件进入紧邻的下一个 case,不会再次检查下一个 case 的表达式:

go
switch n {
case 1:
	fmt.Println("one")
	fallthrough
case 2:
	fmt.Println("one or two")
}

n == 1 时打印两行。它只能出现在表达式 switch 的非最后一个 case 的末尾,不能用于 type switch。

多数“多个值执行同一逻辑”的需求应写成逗号分隔:

go
case 1, 2:
	fmt.Println("one or two")

fallthrough 很少是最佳设计,因为它让 case 之间形成隐式控制依赖。适合它的场景通常是确实需要“先执行本级,再无条件执行下一级”的累积逻辑,并且用函数调用表达反而更绕。

8.3 case 自带隐式块,但可显式加块

每个 case 是隐式块。若多个 case 需要使用同名局部变量而编译器报告冲突,或想更明确限制资源生命周期,可以增加花括号:

go
switch kind {
case "json":
	{
		decoder := json.NewDecoder(r)
		_ = decoder
	}
case "xml":
	{
		decoder := xml.NewDecoder(r)
		_ = decoder
	}
}

实际是否需要显式块应以代码清晰度为准,不必作为固定格式。

9. type switch

9.1 动态类型分支

type switch 只能用于接口值,写法中的 x.(type) 只允许出现在 type switch 的 guard 位置:

go
func Describe(value any) string {
	switch v := value.(type) {
	case nil:
		return "nil"
	case bool:
		return fmt.Sprintf("bool: %t", v)
	case int:
		return fmt.Sprintf("int: %d", v)
	case string:
		return fmt.Sprintf("string: %q", v)
	case fmt.Stringer:
		return "Stringer: " + v.String()
	default:
		return fmt.Sprintf("其他类型:%T", v)
	}
}

value 是接口表达式,v 是 guard 中声明的变量。只列一个具体类型的 case 中,v 的静态类型就是那个具体类型,所以可以直接做整数运算、访问方法等。

9.2 多类型 case 中的变量类型

若一个 case 列了多个类型,case 内变量保留原接口表达式的静态类型:

go
func PrintNumber(value any) {
	switch v := value.(type) {
	case int, int64:
		// v 的静态类型是 any,不是 int 或 int64
		fmt.Printf("number %v (%T)\n", v, v)
	}
}

如果两个类型需要不同操作,拆成两个 case 往往更好。

9.3 nil case 的含义

case nil 匹配的是接口本身为 nil:

go
var a any = nil
var p *bytes.Buffer = nil
var b any = p

fmt.Println(a == nil) // true
fmt.Println(b == nil) // false:b 有动态类型 *bytes.Buffer

type switch 中 b 会匹配 case *bytes.Buffer,而不是 case nil。接口由动态类型和动态值组成;“动态值是 nil 指针”不等于“接口为 nil”。

9.4 case 顺序与接口重叠

具体类型只会匹配一个 case,但一个动态类型可能实现多个接口。type switch 选择第一个匹配项:

go
switch v := value.(type) {
case io.ReadWriter:
	useReadWriter(v)
case io.Reader:
	useReader(v)
}

更具体的接口通常放前面。反过来写,所有 io.ReadWriter 都会先匹配 io.Reader,后面的分支没有机会执行。

9.5 type switch 不是建模的替代品

边界层需要处理 JSON、驱动返回值或通用日志字段时,type switch 很实用。如果核心领域逻辑也到处按具体类型分支,往往说明接口没有把行为表达出来:

go
type Validator interface {
	Validate() error
}

func ValidateAll(values []Validator) error {
	for _, value := range values {
		if err := value.Validate(); err != nil {
			return err
		}
	}
	return nil
}

与其不断问“你是什么类型”,Go 更鼓励问“你能做什么”。

10. select:通信分支

select 外形像 switch,但每个 case 必须是 channel 的发送或接收。它选择当前能够继续的通信:

go
func Wait(ctx context.Context, results <-chan string) (string, error) {
	select {
	case value := <-results:
		return value, nil
	case <-ctx.Done():
		return "", ctx.Err()
	}
}

10.1 接收的几种形式

go
select {
case value := <-ch:
	fmt.Println(value)
case value, ok := <-ch:
	fmt.Println(value, ok)
case <-ch:
	fmt.Println("只关心信号")
}

一个 select 里通常不会对同一个 channel 同时写这三种 case;这里仅展示语法。

发送 case:

go
select {
case jobs <- job:
	return nil
case <-ctx.Done():
	return ctx.Err()
}

发送表达式右侧的值在进入 select 时就会求值,channel 操作是否选中则要等选择完成。不要把昂贵或不可重复的副作用藏在发送值表达式中。

10.2 多个 case 同时就绪

如果多个通信都可以立即完成,运行时会从可用 case 中做近似均匀的伪随机选择。不要依赖源码顺序表达优先级:

go
select {
case v := <-high:
	handleHigh(v)
case v := <-normal:
	handleNormal(v)
}

上面的 high 没有天然的优先级。需要严格优先时必须显式设计,例如先做一次非阻塞的高优先级检查,再进入主 select;即使这样,仍要分析低优先级分支会不会饥饿。

10.3 没有 default 时会阻塞

若没有 case 可执行且没有 default,当前 goroutine 阻塞,直到某个通信可完成。若 select 没有任何 case:

go
select {}

它会永久阻塞。这个写法偶尔用于示例或长期运行的进程,但生产代码一般应该有明确的生命周期和取消路径。

10.4 default 让 select 非阻塞

go
select {
case queue <- item:
	return true
default:
	return false
}

这是“尝试发送”:队列当前不能接收就立即失败。它适合允许丢弃的指标、采样事件或明确设计的背压策略。

不要在紧循环里反复执行带 default 的 select:

go
for {
	select {
	case value := <-ch:
		handle(value)
	default:
		// 空转会占满一个 CPU 核
	}
}

如果没有别的工作要做,移除 default 让 goroutine 正常阻塞。

11. nil channel、关闭 channel 与超时

11.1 nil channel 会禁用 case

对 nil channel 发送或接收都会永久阻塞,因此 select 中对应 case 永远不会就绪。这可以用来动态开关分支:

go
func Merge(ctx context.Context, a, b <-chan int) {
	for a != nil || b != nil {
		select {
		case v, ok := <-a:
			if !ok {
				a = nil
				continue
			}
			fmt.Println("a:", v)
		case v, ok := <-b:
			if !ok {
				b = nil
				continue
			}
			fmt.Println("b:", v)
		case <-ctx.Done():
			return
		}
	}
}

关闭的 channel 接收会立刻返回元素类型零值和 ok == false。如果不把它设为 nil,这个 case 会一直就绪,可能形成忙循环并挤压其他 case。

11.2 关闭 channel 的发送分支

向已关闭 channel 发送会 panic。select 不会把这个错误变成“该 case 不可用”。channel 通常由发送方、而且是唯一负责生命周期的发送方关闭;接收方不要凭猜测关闭。

11.3 超时与取消

一次性超时:

go
select {
case result := <-results:
	return result, nil
case <-time.After(2 * time.Second):
	return Result{}, context.DeadlineExceeded
}

在循环或高频路径中,更适合显式 timer,并确保停止、回收:

go
timer := time.NewTimer(2 * time.Second)
defer timer.Stop()

select {
case result := <-results:
	return result, nil
case <-timer.C:
	return Result{}, context.DeadlineExceeded
}

跨调用传播截止时间时优先使用 context.Context

go
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()

select {
case result := <-results:
	return result, nil
case <-ctx.Done():
	return Result{}, ctx.Err()
}

context 的价值不仅是计时,还能把调用方取消一路传播到数据库、HTTP 请求和后台工作。

12. 作用域、遮蔽与常见错误

12.1 错误变量被遮蔽

go
func Load(path string) (data []byte, err error) {
	if data, err := os.ReadFile(path); err != nil {
		return nil, err
	} else {
		fmt.Println(len(data))
	}
	return data, err // 返回外层 nil, nil
}

初始化语句中的 dataerr 是新变量,函数的命名返回值没有被更新。改为:

go
func Load(path string) (data []byte, err error) {
	data, err = os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	return data, nil
}

12.2 在条件中声明后又想复用

go
if config, err := readConfig(); err != nil {
	return err
} else {
	apply(config)
}
// config 不可见

如果下一段逻辑仍需要 config,声明应放到 if 外。这不是作用域规则的麻烦,而是一个设计信号:变量生命周期比这个判断更长。

12.3 type switch 的原变量没有变窄

go
switch v := input.(type) {
case string:
	fmt.Println(len(v)) // v 是 string
}

// input 的静态类型仍然是原接口类型

Go 没有 Java 模式匹配那种跨语句的流式类型缩窄。类型信息只体现在 case 变量中。

13. 工程实践:如何组织复杂判断

13.1 先处理不满足条件的路径

go
func Ship(order Order) error {
	if order.ID == "" {
		return ErrMissingID
	}
	if order.Cancelled {
		return ErrCancelled
	}
	if !order.Paid {
		return ErrUnpaid
	}

	return dispatch(order)
}

这种写法把前置条件逐项列出,主操作放在最后。不要为了“只有一个 return”引入多层嵌套;Go 的资源释放通常由 defer 保证,不依赖单出口。

13.2 布尔表达式要有业务名字

go
canPublish := article.Reviewed &&
	(article.AuthorID == actor.ID || actor.Role == RoleAdmin) &&
	!article.Archived

if !canPublish {
	return ErrForbidden
}

当条件跨越多个领域概念时,进一步提取方法:

go
if !policy.CanPublish(actor, article) {
	return ErrForbidden
}

命名不是为了隐藏细节,而是让判断回答一个业务问题。

13.3 用表驱动代替重复 switch

稳定的一对一映射适合 map:

go
var contentTypes = map[string]string{
	".json": "application/json",
	".txt":  "text/plain; charset=utf-8",
}

contentType, ok := contentTypes[ext]
if !ok {
	contentType = "application/octet-stream"
}

若每种分支执行不同算法,函数表或接口策略通常比持续膨胀的 switch 更容易测试。反过来,只有三五个明确状态时,switch 比“为了抽象而抽象”更直接。

13.4 对枚举式状态保持防御

Go 的自定义整数类型不是封闭枚举,调用者可以构造未定义值:

go
type State uint8

const (
	StatePending State = iota
	StateRunning
	StateDone
)

func (s State) String() string {
	switch s {
	case StatePending:
		return "pending"
	case StateRunning:
		return "running"
	case StateDone:
		return "done"
	default:
		return fmt.Sprintf("State(%d)", s)
	}
}

是否允许 default 静默兜底取决于边界:格式化函数可以给未知值可诊断输出;资金状态机则应该返回明确错误。

14. 性能与并发语义

14.1 分支性能通常不是首要问题

编译器可能把密集整数 switch 优化成跳转表,也可能生成比较链;这属于实现细节。业务代码应先选择最清晰的表达方式,再用 benchmark 和 profile 找真实热点:

bash
go test -bench=. -benchmem ./...
go test -run=^$ -bench=BenchmarkDispatch -cpuprofile=cpu.out
go tool pprof cpu.out

把 switch 改成 map 不保证更快。map 要计算哈希并访问运行时结构,小规模分支常常不占优势。

14.2 条件求值不提供同步

go
if ready {
	use(data)
}

如果 readydata 被其他 goroutine 并发写,这段代码存在数据竞争。if 不会建立 happens-before 关系。需要 channel、mutex 或 sync/atomic

go
if ready.Load() {
	useImmutableData()
}

即使使用 atomic,也必须保证它发布的数据具有正确的同步协议,不能只把布尔变量替换成 atomic 就认为所有字段安全。

14.3 select 是调度点,不是事务

选中 channel case 只说明那一次通信可以完成,不代表与它相关的多个状态检查构成原子事务:

go
select {
case jobs <- job:
	// job 已交付或进入缓冲区
case <-ctx.Done():
	// 取消先被选中
}

如果发送和取消同时就绪,任意一支都可能被选中。协议必须允许这种竞态,例如接收方也检查 context,或给任务本身带幂等标识。

14.4 select 中表达式的求值时机

进入 select 时,所有 case 的 channel 操作数、发送语句的右值会按源码顺序求值一次;随后才选择可执行通信。被选中的接收语句,其左侧赋值在通信完成后发生。这个规则意味着:

  • channel 选择函数可能全部被调用;
  • 发送值构造可能发生,即使该 case 未被选中;
  • 接收结果只写入选中 case 的变量。

不要在这些表达式中隐藏数据库写入、计数递增等副作用。

15. 与 Java 的对照

JavaGo关键差异
if (condition)if conditionGo 不写圆括号,必须写花括号
局部声明后判断if x := f(); condGo 可把初始化限制在整个 if 语句
传统 switchswitchGo 默认不贯穿,不需 break
多个 case 标签case a, b, c:Go 在一个 case 中逗号列出
switch 表达式 / pattern matchingswitch 语句、type switchGo switch 不直接产生值
instanceof 模式匹配switch v := x.(type)type switch 仅用于接口动态类型
BlockingQueueCompletableFuture 竞争selectselect 直接选择 channel 通信
null 判断nil 判断带 nil 动态值的接口可能不等于 nil
if 中赋值通常需额外括号初始化语句 + 条件两者以分号分开,不是把赋值当 bool

Java 新式 switch 能强制穷尽 sealed hierarchy;Go 的 type switch 没有编译器级穷尽检查。接口是开放集合,新实现可以来自其他包。需要封闭状态时,可使用未导出方法限制实现范围,并仍对未知输入采取防御。

16. Go 1.22–1.26 相关变化

这一时期没有改写 if、普通 switch、type switch 或 select 的核心语法。与分支最相关的变化来自循环:

  • Go 1.22 起,按模块语言版本启用新的循环变量语义,每次迭代拥有自己的变量;在分支中创建闭包时,不再共享旧式的那一个循环变量;
  • Go 1.22 加入对整数做 range,常与 ifswitch 组合;
  • Go 1.23 加入 range-over-function,迭代器回调里的分支可以用 breakcontinue 表达停止与跳过。

这些变化会在第 6、7 章详细说明。迁移旧模块时要看 go.modgo 行,而不只是本机工具链版本;语言版本决定部分语义。

截至 Go 1.26,select 多个就绪 case 仍不承诺源码优先级,type switch 仍不提供穷尽检查,Go 也没有三元运算符。不要把其他语言的特性误写成 Go 新版本能力。

17. 常见误区

把数字或字符串直接当条件

go
// if count { } // 错
if count != 0 {
	// 对
}

认为 switch 默认贯穿

Go 默认结束当前 case。只有显式 fallthrough 才进入下一支,而且不会检查下一支条件。

用 fallthrough 合并值

同一逻辑直接写 case "a", "b":,不要写两个 case 再贯穿。

把初始化变量带出语句

if value := f(); value != 0 {} 中的 value 在整个 if 结束后不可见。

在内层用 :=,以为更新外层 err

检查变量作用域;需要写外层变量时用 =

假设 type switch 的多类型 case 已经变窄

case int, int64: 中的变量仍是原接口类型。需要具体操作就拆分。

认为 case nil 能匹配带 nil 指针的接口

接口只要有动态类型就不为 nil,即使动态值是 nil 指针。

认为 select 第一支优先

多个 case 同时就绪时选择不由源码顺序决定。

在循环里用 select { default: } 轮询

这通常造成 CPU 空转。没有工作时让 goroutine 阻塞,或者使用 timer/ticker 驱动。

收到关闭 channel 后不处理 ok

持续读取关闭 channel 会不断得到零值。需要区分零值数据与关闭,并在多路 select 中把关闭的 channel 设为 nil。

18. 速查表

if

go
if ok {
	// ...
}

if ok {
	// ...
} else {
	// ...
}

if value, err := load(); err != nil {
	return err
} else {
	use(value)
}

switch

go
switch value {
case a, b:
	// ...
case c:
	// ...
default:
	// ...
}

switch {
case n < 0:
	// ...
case n == 0:
	// ...
default:
	// ...
}

type switch

go
switch v := input.(type) {
case nil:
	// ...
case string:
	fmt.Println(len(v))
case io.Reader:
	_, _ = io.Copy(io.Discard, v)
default:
	fmt.Printf("%T\n", v)
}

select

go
select {
case value, ok := <-ch:
	if !ok {
		return
	}
	use(value)
case out <- result:
	// 已发送
case <-ctx.Done():
	return
}

非阻塞尝试:

go
select {
case ch <- value:
	return true
default:
	return false
}

19. 练习

  1. Grade(score int) (string, error),用无表达式 switch 处理非法分数、优秀、通过和未通过。为边界值 -1、0、59、60、89、90、100、101 写表驱动测试。
  2. ParsePort(text string) (uint16, error),使用 if 初始化语句调用 strconv.Atoi,区分格式错误和越界错误,错误中保留原输入。
  3. Describe(any) string,处理 nil、整数、字符串、fmt.Stringer 和其他类型。加入一个“nil 指针装入接口”的测试,观察它匹配哪一支。
  4. 实现 TrySend[T any](ch chan<- T, value T) bool,说明它适合哪些允许丢弃的业务,不适合哪些可靠消息场景。
  5. 实现两个输入 channel 的合并读取。任一输入关闭后将对应变量设为 nil,两个都关闭后返回,并支持 context 取消。
  6. 构造两个始终就绪的 buffered channel,重复 select 多次,统计各 case 次数。不要断言精确比例,只验证程序没有固定依赖第一支。
  7. 找一段三层 if/else 业务代码,改成 guard clause;比较错误路径、主路径和局部变量作用域是否更清楚。
  8. 写一个订单状态转换函数。先用 switch,再用 map[State]func(*Order) error 实现,比较新增状态、共享依赖和测试隔离的成本。

20. 可运行示例

分支语句的难点不在关键字数量,而在作用域、边界顺序和动态类型。下面三个例子位于 examples/ch05

20.1 用 if 初始化语句收紧变量作用域

让临时变量只活在分支里。 解析和校验如何写在一起,同时不让临时变量泄漏到整个函数?

go
package main

import (
	"fmt"
	"strconv"
)

func parsePort(text string) (uint16, error) {
	// port 只在整个 if/else-if 链中可见,出了语句就会离开作用域。
	// err 也被限制在错误处理附近,避免污染后续代码。
	if port, err := strconv.Atoi(text); err != nil {
		return 0, fmt.Errorf("端口 %q 不是整数: %w", text, err)
	} else if port < 1 || port > 65535 {
		return 0, fmt.Errorf("端口 %d 超出 1..65535", port)
	} else {
		return uint16(port), nil
	}
}

func main() {
	for _, text := range []string{"8080", "70000", "abc"} {
		port, err := parsePort(text)
		if err != nil {
			// 只打印稳定的业务说明,不展开依赖具体 Go 版本的底层错误文本。
			fmt.Printf("%q -> error\n", text)
			continue
		}
		fmt.Printf("%q -> %d\n", text, port)
	}
}

运行:

bash
go run ./examples/ch05/if-scope

预期输出:

text
"8080" -> 8080
"70000" -> error
"abc" -> error

作用域随这条 if 一起结束。

  1. port, err := strconv.Atoi(text) 在 if 初始化部分执行,两者的作用域覆盖整个 if/else-if/else 链。
  2. 第一支处理格式错误,第二支处理数值虽合法但超出端口范围,两个失败原因没有混在一起。
  3. 只有通过边界检查后才转换为 uint16,否则先转换会截断高位,让 70000 变成另一个看似合法的数。
  4. 离开 if 后 porterr 不再可见,后续代码不会误用未校验的中间值。

到作用域外访问一次。 在 if 之后打印 port,阅读编译器的作用域错误;再加入 "0""1""65535" 验证边界。

20.2 用无表达式 switch 表达区间

无表达式 switch 依赖 case 顺序。 多个互斥范围怎样保持从特殊边界到一般规则的清晰顺序?

go
package main

import "fmt"

func grade(score int) string {
	// 无表达式 switch 等价于 switch true,适合表达互斥的区间条件。
	// case 从上往下判断,匹配后默认停止,不需要手写 break。
	switch {
	case score < 0 || score > 100:
		return "非法"
	case score >= 90:
		return "优秀"
	case score >= 60:
		return "通过"
	default:
		return "未通过"
	}
}

func main() {
	for _, score := range []int{-1, 59, 60, 90, 101} {
		fmt.Printf("%d -> %s\n", score, grade(score))
	}
}

运行:

bash
go run ./examples/ch05/switch-grade

预期输出:

text
-1 -> 非法
59 -> 未通过
60 -> 通过
90 -> 优秀
101 -> 非法

匹配过程从特殊边界向一般规则推进。

  1. switch {} 相当于 switch true,每个 case 都可以写独立布尔条件。
  2. case 从上往下匹配,所以非法范围必须先拦住,否则 101 会先命中“优秀”。
  3. Go 的 case 默认不会向下一支贯穿,不需要也不应该机械补 break
  4. 60 和 90 这样的边界值放进固定输入,可以直接验证 >= 是否写对。

交换顺序看看结果怎样变化。 交换“非法”和“优秀”两支,观察 101 的结果;再增加“良好(75–89)”,先写边界再运行。

20.3 用类型 switch 区分接口的动态类型

接口的动态类型和值要分开观察。 any 中保存的值怎样安全分类?nil 接口和装着 nil 指针的接口为什么不是一回事?

go
package main

import "fmt"

type User struct {
	Name string
}

func describe(value any) string {
	// 类型 switch 判断的是接口中保存的动态类型。
	// case 里的 v 已经收窄为对应类型,不需要再次断言。
	switch v := value.(type) {
	case nil:
		return "nil 接口"
	case int:
		return fmt.Sprintf("整数 %d", v)
	case string:
		return fmt.Sprintf("字符串 %q", v)
	case *User:
		// “接口为 nil”和“接口里装着 nil 指针”是两种状态,必须分别处理。
		if v == nil {
			return "类型为 *User、值为 nil"
		}
		return "用户 " + v.Name
	default:
		return fmt.Sprintf("其他类型 %T", v)
	}
}

func main() {
	var nilUser *User
	values := []any{nil, 42, "go", nilUser, &User{Name: "Gopher"}}
	for _, value := range values {
		fmt.Println(describe(value))
	}
}

运行:

bash
go run ./examples/ch05/type-switch

预期输出:

text
nil 接口
整数 42
字符串 "go"
类型为 *User、值为 nil
用户 Gopher

五组输入覆盖了关键分支。

  1. switch v := value.(type) 根据接口的动态类型选择分支,并把 v 收窄为该分支类型。
  2. 真正的 nil 接口没有动态类型,会命中 case nil
  3. var nilUser *User; var value any = nilUser 的动态类型仍是 *User,因此命中 case *User,只是里面的指针值为 nil。
  4. *User 分支直接访问 v.Name 会因 nil 解引用而 panic,所以接口边界仍需检查带类型的 nil。

从 typed nil 和 case 顺序继续验证。 删除 v == nil 判断观察 panic;再让 User 实现 String() string,加入 fmt.Stringer case,并调整 case 顺序观察匹配规则。

21. 官方资料

判断语句写得好不好,通常不取决于用了哪个关键字,而取决于作用域是否准确、异常路径是否直接、并发协议是否明确。读代码时先问“这个分支在判断值、动态类型,还是通信是否就绪”,很多混淆会立刻消失。

以 Go 官方规范与标准库文档为准,示例面向 Go 1.26。