1.3 switch与控制流边界坑


文档摘要

1.3 switch 与控制流边界坑 本节摘要:新增一个枚举值,线上当晚 NPE—— 对 null 键、分支覆盖与穿透行为的处理,是控制流里最容易踩的三个坑。本节逐个还原事故形态,给出 的使用纪律、switch 表达式(Java 14+)的穷尽性检查,以及循环中 / /标签的边界规则。 事故复盘:一次"无害"的枚举扩展 风控系统里有个渠道枚举,某次迭代新增了一个渠道 。开发改了上游的枚举定义,下游按渠道分发处理器的地方用的是 : 新渠道流量打进来,走到 返回 null,调用处 直接 NPE,当晚告警。更早的一版事故则是忘了 ——编译照样通过,运行时抛 ,因为传统 switch 语句遇到 null 匹配对象会直接 NPE,连进分支的机会都不给。

1.3 switch 与控制流边界坑

本节摘要:新增一个枚举值,线上当晚 NPE——switch 对 null 键、分支覆盖与穿透行为的处理,是控制流里最容易踩的三个坑。本节逐个还原事故形态,给出 default 的使用纪律、switch 表达式(Java 14+)的穷尽性检查,以及循环中 break/continue/标签的边界规则。

事故复盘:一次"无害"的枚举扩展

风控系统里有个渠道枚举,某次迭代新增了一个渠道 WECHAT_MINI。开发改了上游的枚举定义,下游按渠道分发处理器的地方用的是 switch

public RiskHandler pickHandler(Channel channel) { switch (channel) { case ALIPAY: return alipayHandler; case BANK: return bankHandler; default: return null; // "反正以后再补" } }

新渠道流量打进来,走到 default 返回 null,调用处 .handle(risk) 直接 NPE,当晚告警。更早的一版事故则是忘了 default——编译照样通过,运行时抛 NullPointerException,因为传统 switch 语句遇到 null 匹配对象会直接 NPE,连进分支的机会都不给。

这类事故的结构性问题在于:枚举是"开放集合",新增值是常态,而 switch 语句默认不做穷尽性检查,新增枚举值时编译器一声不吭。所有依赖枚举分发的地方都成了定时炸弹,且引信在别人的提交里。

三个坑逐个拆

坑一:null 键

switch 的匹配基于调用 hashCode/ordinal 或字符串的 equals,键为 null 时直接 NPE。纪律:入口处判空,或者在分发函数里把 null 显式归入非法输入。

坑二:穿透(fall-through)

传统 switch 的 case 只是标签不是边界,忘写 break 就会"滑"到下一个分支:

int level; switch (tier) { case "VIP": level = 3; // 忘了 break,VIP 用户被按 SVIP 计费 case "SVIP": level = 2; break; default: level = 1; }

历史上把穿透当特性用的场景(多个 case 共享逻辑)可以写成连续标签 case A: case B: doX(); break;,语义清晰得多。

坑三:新增枚举值

如上,编译器不检查是否覆盖全。两类解法:其一,default 里抛异常而不是返回 null——宁可快失败,不可静默错;其二,升级到 switch 表达式。

switch 表达式:把穷尽性交给编译器

Java 14 起转正的 switch 表达式从语法层面消灭了后两个坑:

RiskHandler handler = switch (channel) { case ALIPAY -> alipayHandler; case BANK -> bankHandler; case WECHAT, WECHAT_MINI -> wechatHandler; // 枚举覆盖全部值时可省 default;一旦漏一个 编译直接失败 };

箭头语法无穿透,多值标签显式聚合,最关键的是穷尽性检查:新增 WECHAT_MINI 那一刻,所有旧 switch 表达式立刻编译报错,事故在编译期被拦截。这是"让错误左移"的典型样本——同样的错误,语句版在深夜的生产环境爆炸,表达式版在下午的 IDE 里画红线。

特性 switch 语句 switch 表达式
穿透 默认穿透 需 break 箭头语法无穿透
穷尽性 不检查 漏分支静默 编译期强制覆盖
null 键 NPE 仍 NPE 需入口判空
返回值 有 可赋值
多值标签 需堆叠 case 逗号聚合

循环里的边界

控制流的另一半事故在循环。三个高频样本:

标签与嵌套循环——内层 break 只跳出一层,想从双层循环中整体退出要么用标志位,要么用标签:

outer: for (Order o : orders) { for (Item i : o.getItems()) { if (i.isRisky()) { processRisk(o); break outer; // 直接退出整个外层循环 } } }

标签被很多规范视为"禁用语法",但它比标志位版本少一个可变状态,我更倾向在双层扫描这种局部场景里用,前提是循环体必须短。

修改循环变量与边界缓存——经典死循环样本:

for (int i = 0; i < list.size(); i++) { if (needRemove(i)) { list.remove(i); // 删除后整体左移,i 却继续前进 → 漏元素 i--; // 手工回退补丁 脆弱 } }

正确做法是迭代器删除或倒序遍历(详见第 4 章集合一节,那里有完整事故复盘)。

条件永远为真的 while——while (queue.size() > 0) 在队列被并发清空又填充时语义漂移,需要"有限次尝试"的地方写成 for 加计数上限,给循环一个必然的出口。

⚠️ 常见坑:default: return null。它把"未覆盖"伪装成"正常返回",NPE 会出现在离事故现场很远的地方。default 里抛 IllegalStateException 并带上实际值,排错时间从小时级降到分钟级。

💡 关键直觉:分支覆盖是编译器能帮你检查的东西,就别靠人肉记忆。switch 表达式的穷尽性检查、密封类(sealed)与模式匹配的组合,都是把"运行时惊喜"变成"编译期报错"的工具。

防坑清单

  • 枚举分发的 switch:能升级表达式就升级,不能升级则 default 必须抛异常
  • switch 前判空,null 是控制流的越界输入
  • 连续 case 显式写成多值标签,不依赖穿透
  • 双层退出优先重构为方法 + return,其次才是标签
  • 新增枚举值时全局搜索该枚举名,逐个确认分发点(这个动作写进团队手册)

本节要点回顾

  • null 键:switch 遇 null 直接 NPE,入口判空是纪律
  • 穿透:语句版默认穿透,多值标签替代穿透复用
  • 穷尽性:switch 表达式 + 枚举 = 编译期拦截新增值,错误左移
  • default 纪律:抛异常优于返回 null,快失败优于静默错
  • 循环边界:删除元素回退索引、双层退出、无限 while 的必然出口

第 1 章到此收束。下一章进入面向对象——equalshashCode 的契约被破坏时,连缓存都会跟着说谎。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U