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 != nil、n > 0 |
switch | 一个值或一组条件匹配哪一支 | 状态码、命令、区间 |
| type switch | 接口值当前装着什么动态类型 | any、接口实现 |
select | 哪个 channel 通信现在可以完成 | 结果、取消、超时 |
四者都是语句,不是表达式。Go 不能像 Kotlin 那样,把 if 或 switch 的结果直接赋给变量:
var label string
if score >= 60 {
label = "通过"
} else {
label = "未通过"
}Go 也没有 Java 的三元运算符。简单的二选一直接写 if 即可。为了省几行而造一个接收 any 的通用 ternary 函数,不但会丢失类型信息,还可能让两个候选值提前求值。
分支的共同规则是:
- 花括号必须写,左花括号与
if、switch、select在同一行; - 条件不会做“真值”转换,必须得到
bool; - 每个分支形成词法块,可以声明局部变量;
- 分支只控制当前 goroutine,不会自动取消其他 goroutine;
- 清晰的控制流比“技巧性地压缩代码”更符合 Go 的风格。
2. if 的完整语法
2.1 基本形式
if condition {
// condition 为 true
}条件必须是布尔值:
count := 3
if count > 0 {
fmt.Println("有数据")
}
// if count { } // 编译错误:int 不能当作 boolJava 开发者也不会把 int 直接当布尔值,但从 JavaScript、Python 或 C 转过来时要特别留意。空字符串、零、nil slice 都不会自动变成 false:
if name != "" {
// ...
}
if len(items) != 0 {
// ...
}
if value != nil {
// ...
}2.2 else 与 else if
if temperature < 0 {
fmt.Println("结冰")
} else if temperature < 30 {
fmt.Println("正常")
} else {
fmt.Println("炎热")
}else if 不是独立语法节点,可以把它理解成 else 中紧接着另一个 if。格式化工具会把它排成一行。
Go 的自动分号插入规则决定了 else 必须紧跟前一个右花括号:
if ok {
use()
} else {
fallback()
}把 else 放到下一行会在 } 后插入分号,从而导致语法错误。实际项目统一运行 gofmt,不需要手工争论格式。
2.3 if 的初始化语句
完整语法允许在条件前执行一条简单语句,两部分用分号分隔:
if value, err := strconv.Atoi(text); err != nil {
return fmt.Errorf("解析年龄 %q: %w", text, err)
} else {
fmt.Println("年龄:", value)
}初始化部分可以是短变量声明、赋值、自增/自减、发送语句,或函数调用;工程代码里几乎总是短声明:
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 if 和 else:
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的初始化语句又声明了一个新的user和err;- 在最后的
else中,可见的是else if声明的user; - 整个语句之后,这些变量都不可见。
这种写法合法,但多个同名变量叠在一起并不好读。工程代码通常会把它拆开:
if user, ok := cache.Get(id); ok {
return user, nil
}
user, err := repository.Find(id)
if err != nil {
return User{}, err
}
return user, nil3.2 条件块是隐式作用域
初始化语句所在的位置可以看成 if 自带的外层块,分支体是它的子块:
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 := 的“至少一个新变量”规则
短声明可以在同一作用域中复用旧变量,但左侧至少要有一个该作用域的新变量:
value, err := load()
value, normalized := normalize(value) // normalized 是新变量,因此合法
_, _, _ = value, err, normalized进入内层块后,同名标识符会创建新变量,而不是更新外层变量:
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如果调用结束后还要使用结果,就先在外层声明,再用 =:
var data []byte
var err error
data, err = os.ReadFile("config.json")
if err != nil {
return err
}
fmt.Println(len(data))4. 条件表达式与短路求值
4.1 && 和 ||
逻辑与、逻辑或从左到右求值,并且短路:
if user != nil && user.Active {
// user 为 nil 时,不会读取 user.Active
}
if cached || loadFromDisk() {
// cached 为 true 时,不调用 loadFromDisk
}短路既是性能行为,也是正确性保证。把有副作用的函数放在右侧要谨慎,因为它可能根本不会执行。
优先级从高到低大致是 !、比较运算、&&、||。条件复杂时直接加括号,比要求读者背优先级更稳妥:
if authenticated && (isOwner || isAdmin) {
// ...
}4.2 比较的边界
两个操作数必须可以比较且类型兼容。slice、map 和 function 只能与 nil 比较,不能彼此比较:
if slice == nil {
// 合法
}
// if sliceA == sliceB { } // 编译错误数组和 struct 在其所有元素或字段可比较时可以用 ==。浮点数中的 NaN 与包括自身在内的任何值都不相等,因此不要用 x == math.NaN() 判断:
if math.IsNaN(x) {
// ...
}接口值比较还有一个运行时边界:若两个接口的动态值包含不可比较类型,执行 == 会 panic。接口和 nil 的细节会在接口章节展开。
5. 提前返回与错误处理
Go 代码常用 guard clause 把异常路径尽早结束:
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 往往更清楚:
if enabled {
start()
} else {
stop()
}错误分类应该看语义
不要通过错误文本分支:
// 不稳:错误文案一改,逻辑就失效
if strings.Contains(err.Error(), "not found") {
// ...
}优先使用 errors.Is、errors.As 或带语义的方法:
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 基本语法
switch status {
case "pending":
fmt.Println("等待处理")
case "running":
fmt.Println("处理中")
case "done":
fmt.Println("完成")
default:
fmt.Println("未知状态")
}Go 的 switch 默认只执行第一个匹配的 case,不需要写 break。这和 Java 传统 switch 最大的不同之一。
一个 case 可以列多个表达式:
switch ext {
case ".jpg", ".jpeg", ".png", ".webp":
return "image"
case ".mp3", ".wav":
return "audio"
default:
return "unknown"
}case 从上到下依次求值,匹配后停止。表达式不要求是编译期常量,因此可以调用函数:
switch target {
case primary():
usePrimary()
case fallback():
useFallback()
}case 表达式一旦带有副作用,哪些调用真正发生就很难从代码表面判断。工程代码通常让这里保持为纯表达式。
6.2 switch 的初始化语句
和 if 一样,switch 可以带初始化语句:
switch n := len(items); n {
case 0:
fmt.Println("空")
case 1:
fmt.Println("一个")
default:
fmt.Printf("%d 个\n", n)
}语法形式是:
switch SimpleStmt; Expression {
case ExpressionList:
...
default:
...
}如果只需要表达式,不写分号;如果只需要初始化语句,分号后可以省略表达式,此时等价于 switch true。
6.3 default 可以放在任意位置
default 在源代码中可以出现在 case 之间,但只有所有 case 都不匹配时才执行。为了阅读顺序一致,通常把它放在末尾。
6.4 case 的比较要求
switch 表达式会先求值一次,然后依次与 case 表达式比较。比较必须合法:
switch code := response.StatusCode; code {
case http.StatusOK:
// ...
case http.StatusNotFound:
// ...
}普通 switch 不能直接匹配 slice 或 map,因为它们不可比较。需要按长度、字段或自定义谓词判断时,用无表达式 switch。
7. 无表达式 switch
省略 switch 表达式等价于对 true 做匹配,因此每个 case 写布尔条件:
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/接口实现:
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:
switch mode {
case "fast":
if len(items) == 0 {
break
}
process(items)
}
afterSwitch()这里的 break 只离开 switch,不会离开包含它的 for。要离开外层循环,需要标签:
Outer:
for {
switch next() {
case "stop":
break Outer
}
}8.2 fallthrough
fallthrough 无条件进入紧邻的下一个 case,不会再次检查下一个 case 的表达式:
switch n {
case 1:
fmt.Println("one")
fallthrough
case 2:
fmt.Println("one or two")
}当 n == 1 时打印两行。它只能出现在表达式 switch 的非最后一个 case 的末尾,不能用于 type switch。
多数“多个值执行同一逻辑”的需求应写成逗号分隔:
case 1, 2:
fmt.Println("one or two")fallthrough 很少是最佳设计,因为它让 case 之间形成隐式控制依赖。适合它的场景通常是确实需要“先执行本级,再无条件执行下一级”的累积逻辑,并且用函数调用表达反而更绕。
8.3 case 自带隐式块,但可显式加块
每个 case 是隐式块。若多个 case 需要使用同名局部变量而编译器报告冲突,或想更明确限制资源生命周期,可以增加花括号:
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 位置:
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 内变量保留原接口表达式的静态类型:
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:
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.Buffertype switch 中 b 会匹配 case *bytes.Buffer,而不是 case nil。接口由动态类型和动态值组成;“动态值是 nil 指针”不等于“接口为 nil”。
9.4 case 顺序与接口重叠
具体类型只会匹配一个 case,但一个动态类型可能实现多个接口。type switch 选择第一个匹配项:
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 很实用。如果核心领域逻辑也到处按具体类型分支,往往说明接口没有把行为表达出来:
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 的发送或接收。它选择当前能够继续的通信:
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 接收的几种形式
select {
case value := <-ch:
fmt.Println(value)
case value, ok := <-ch:
fmt.Println(value, ok)
case <-ch:
fmt.Println("只关心信号")
}一个 select 里通常不会对同一个 channel 同时写这三种 case;这里仅展示语法。
发送 case:
select {
case jobs <- job:
return nil
case <-ctx.Done():
return ctx.Err()
}发送表达式右侧的值在进入 select 时就会求值,channel 操作是否选中则要等选择完成。不要把昂贵或不可重复的副作用藏在发送值表达式中。
10.2 多个 case 同时就绪
如果多个通信都可以立即完成,运行时会从可用 case 中做近似均匀的伪随机选择。不要依赖源码顺序表达优先级:
select {
case v := <-high:
handleHigh(v)
case v := <-normal:
handleNormal(v)
}上面的 high 没有天然的优先级。需要严格优先时必须显式设计,例如先做一次非阻塞的高优先级检查,再进入主 select;即使这样,仍要分析低优先级分支会不会饥饿。
10.3 没有 default 时会阻塞
若没有 case 可执行且没有 default,当前 goroutine 阻塞,直到某个通信可完成。若 select 没有任何 case:
select {}它会永久阻塞。这个写法偶尔用于示例或长期运行的进程,但生产代码一般应该有明确的生命周期和取消路径。
10.4 default 让 select 非阻塞
select {
case queue <- item:
return true
default:
return false
}这是“尝试发送”:队列当前不能接收就立即失败。它适合允许丢弃的指标、采样事件或明确设计的背压策略。
不要在紧循环里反复执行带 default 的 select:
for {
select {
case value := <-ch:
handle(value)
default:
// 空转会占满一个 CPU 核
}
}如果没有别的工作要做,移除 default 让 goroutine 正常阻塞。
11. nil channel、关闭 channel 与超时
11.1 nil channel 会禁用 case
对 nil channel 发送或接收都会永久阻塞,因此 select 中对应 case 永远不会就绪。这可以用来动态开关分支:
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 超时与取消
一次性超时:
select {
case result := <-results:
return result, nil
case <-time.After(2 * time.Second):
return Result{}, context.DeadlineExceeded
}在循环或高频路径中,更适合显式 timer,并确保停止、回收:
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:
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 错误变量被遮蔽
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
}初始化语句中的 data、err 是新变量,函数的命名返回值没有被更新。改为:
func Load(path string) (data []byte, err error) {
data, err = os.ReadFile(path)
if err != nil {
return nil, err
}
return data, nil
}12.2 在条件中声明后又想复用
if config, err := readConfig(); err != nil {
return err
} else {
apply(config)
}
// config 不可见如果下一段逻辑仍需要 config,声明应放到 if 外。这不是作用域规则的麻烦,而是一个设计信号:变量生命周期比这个判断更长。
12.3 type switch 的原变量没有变窄
switch v := input.(type) {
case string:
fmt.Println(len(v)) // v 是 string
}
// input 的静态类型仍然是原接口类型Go 没有 Java 模式匹配那种跨语句的流式类型缩窄。类型信息只体现在 case 变量中。
13. 工程实践:如何组织复杂判断
13.1 先处理不满足条件的路径
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 布尔表达式要有业务名字
canPublish := article.Reviewed &&
(article.AuthorID == actor.ID || actor.Role == RoleAdmin) &&
!article.Archived
if !canPublish {
return ErrForbidden
}当条件跨越多个领域概念时,进一步提取方法:
if !policy.CanPublish(actor, article) {
return ErrForbidden
}命名不是为了隐藏细节,而是让判断回答一个业务问题。
13.3 用表驱动代替重复 switch
稳定的一对一映射适合 map:
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 的自定义整数类型不是封闭枚举,调用者可以构造未定义值:
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 找真实热点:
go test -bench=. -benchmem ./...
go test -run=^$ -bench=BenchmarkDispatch -cpuprofile=cpu.out
go tool pprof cpu.out把 switch 改成 map 不保证更快。map 要计算哈希并访问运行时结构,小规模分支常常不占优势。
14.2 条件求值不提供同步
if ready {
use(data)
}如果 ready 和 data 被其他 goroutine 并发写,这段代码存在数据竞争。if 不会建立 happens-before 关系。需要 channel、mutex 或 sync/atomic:
if ready.Load() {
useImmutableData()
}即使使用 atomic,也必须保证它发布的数据具有正确的同步协议,不能只把布尔变量替换成 atomic 就认为所有字段安全。
14.3 select 是调度点,不是事务
选中 channel case 只说明那一次通信可以完成,不代表与它相关的多个状态检查构成原子事务:
select {
case jobs <- job:
// job 已交付或进入缓冲区
case <-ctx.Done():
// 取消先被选中
}如果发送和取消同时就绪,任意一支都可能被选中。协议必须允许这种竞态,例如接收方也检查 context,或给任务本身带幂等标识。
14.4 select 中表达式的求值时机
进入 select 时,所有 case 的 channel 操作数、发送语句的右值会按源码顺序求值一次;随后才选择可执行通信。被选中的接收语句,其左侧赋值在通信完成后发生。这个规则意味着:
- channel 选择函数可能全部被调用;
- 发送值构造可能发生,即使该 case 未被选中;
- 接收结果只写入选中 case 的变量。
不要在这些表达式中隐藏数据库写入、计数递增等副作用。
15. 与 Java 的对照
| Java | Go | 关键差异 |
|---|---|---|
if (condition) | if condition | Go 不写圆括号,必须写花括号 |
| 局部声明后判断 | if x := f(); cond | Go 可把初始化限制在整个 if 语句 |
传统 switch | switch | Go 默认不贯穿,不需 break |
| 多个 case 标签 | case a, b, c: | Go 在一个 case 中逗号列出 |
switch 表达式 / pattern matching | switch 语句、type switch | Go switch 不直接产生值 |
instanceof 模式匹配 | switch v := x.(type) | type switch 仅用于接口动态类型 |
BlockingQueue、CompletableFuture 竞争 | select | select 直接选择 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,常与if、switch组合; - Go 1.23 加入 range-over-function,迭代器回调里的分支可以用
break、continue表达停止与跳过。
这些变化会在第 6、7 章详细说明。迁移旧模块时要看 go.mod 的 go 行,而不只是本机工具链版本;语言版本决定部分语义。
截至 Go 1.26,select 多个就绪 case 仍不承诺源码优先级,type switch 仍不提供穷尽检查,Go 也没有三元运算符。不要把其他语言的特性误写成 Go 新版本能力。
17. 常见误区
把数字或字符串直接当条件
// 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
if ok {
// ...
}
if ok {
// ...
} else {
// ...
}
if value, err := load(); err != nil {
return err
} else {
use(value)
}switch
switch value {
case a, b:
// ...
case c:
// ...
default:
// ...
}
switch {
case n < 0:
// ...
case n == 0:
// ...
default:
// ...
}type switch
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
select {
case value, ok := <-ch:
if !ok {
return
}
use(value)
case out <- result:
// 已发送
case <-ctx.Done():
return
}非阻塞尝试:
select {
case ch <- value:
return true
default:
return false
}19. 练习
- 写
Grade(score int) (string, error),用无表达式 switch 处理非法分数、优秀、通过和未通过。为边界值-1、0、59、60、89、90、100、101写表驱动测试。 - 写
ParsePort(text string) (uint16, error),使用 if 初始化语句调用strconv.Atoi,区分格式错误和越界错误,错误中保留原输入。 - 写
Describe(any) string,处理 nil、整数、字符串、fmt.Stringer和其他类型。加入一个“nil 指针装入接口”的测试,观察它匹配哪一支。 - 实现
TrySend[T any](ch chan<- T, value T) bool,说明它适合哪些允许丢弃的业务,不适合哪些可靠消息场景。 - 实现两个输入 channel 的合并读取。任一输入关闭后将对应变量设为 nil,两个都关闭后返回,并支持 context 取消。
- 构造两个始终就绪的 buffered channel,重复 select 多次,统计各 case 次数。不要断言精确比例,只验证程序没有固定依赖第一支。
- 找一段三层
if/else业务代码,改成 guard clause;比较错误路径、主路径和局部变量作用域是否更清楚。 - 写一个订单状态转换函数。先用 switch,再用
map[State]func(*Order) error实现,比较新增状态、共享依赖和测试隔离的成本。
20. 可运行示例
分支语句的难点不在关键字数量,而在作用域、边界顺序和动态类型。下面三个例子位于 examples/ch05。
20.1 用 if 初始化语句收紧变量作用域
让临时变量只活在分支里。 解析和校验如何写在一起,同时不让临时变量泄漏到整个函数?
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)
}
}运行:
go run ./examples/ch05/if-scope预期输出:
"8080" -> 8080
"70000" -> error
"abc" -> error作用域随这条 if 一起结束。
port, err := strconv.Atoi(text)在 if 初始化部分执行,两者的作用域覆盖整个 if/else-if/else 链。- 第一支处理格式错误,第二支处理数值虽合法但超出端口范围,两个失败原因没有混在一起。
- 只有通过边界检查后才转换为
uint16,否则先转换会截断高位,让 70000 变成另一个看似合法的数。 - 离开 if 后
port与err不再可见,后续代码不会误用未校验的中间值。
到作用域外访问一次。 在 if 之后打印 port,阅读编译器的作用域错误;再加入 "0"、"1" 和 "65535" 验证边界。
20.2 用无表达式 switch 表达区间
无表达式 switch 依赖 case 顺序。 多个互斥范围怎样保持从特殊边界到一般规则的清晰顺序?
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))
}
}运行:
go run ./examples/ch05/switch-grade预期输出:
-1 -> 非法
59 -> 未通过
60 -> 通过
90 -> 优秀
101 -> 非法匹配过程从特殊边界向一般规则推进。
switch {}相当于switch true,每个 case 都可以写独立布尔条件。- case 从上往下匹配,所以非法范围必须先拦住,否则 101 会先命中“优秀”。
- Go 的 case 默认不会向下一支贯穿,不需要也不应该机械补
break。 - 60 和 90 这样的边界值放进固定输入,可以直接验证
>=是否写对。
交换顺序看看结果怎样变化。 交换“非法”和“优秀”两支,观察 101 的结果;再增加“良好(75–89)”,先写边界再运行。
20.3 用类型 switch 区分接口的动态类型
接口的动态类型和值要分开观察。 any 中保存的值怎样安全分类?nil 接口和装着 nil 指针的接口为什么不是一回事?
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))
}
}运行:
go run ./examples/ch05/type-switch预期输出:
nil 接口
整数 42
字符串 "go"
类型为 *User、值为 nil
用户 Gopher五组输入覆盖了关键分支。
switch v := value.(type)根据接口的动态类型选择分支,并把v收窄为该分支类型。- 真正的 nil 接口没有动态类型,会命中
case nil。 var nilUser *User; var value any = nilUser的动态类型仍是*User,因此命中case *User,只是里面的指针值为 nil。- 在
*User分支直接访问v.Name会因 nil 解引用而 panic,所以接口边界仍需检查带类型的 nil。
从 typed nil 和 case 顺序继续验证。 删除 v == nil 判断观察 panic;再让 User 实现 String() string,加入 fmt.Stringer case,并调整 case 顺序观察匹配规则。
21. 官方资料
- Go 语言规范:If statements
- Go 语言规范:Switch statements
- Go 语言规范:Select statements
- Go 语言规范:Blocks
- Go 语言规范:Declarations and scope
- Go 语言规范:Short variable declarations
- Go 语言规范:Comparison operators
- Go 语言规范:Logical operators
context包errors包- Go 1.22 Release Notes
- Go 1.23 Release Notes
- Go 1.26 Release Notes
- Effective Go:if、switch 与 type switch
判断语句写得好不好,通常不取决于用了哪个关键字,而取决于作用域是否准确、异常路径是否直接、并发协议是否明确。读代码时先问“这个分支在判断值、动态类型,还是通信是否就绪”,很多混淆会立刻消失。