1.3 switch 与控制流边界坑 本节摘要:新增一个枚举值,线上当晚 NPE—— 对 null 键、分支覆盖与穿透行为的处理,是控制流里最容易踩的三个坑。本节逐个还原事故形态,给出 的使用纪律、switch 表达式(Java 14+)的穷尽性检查,以及循环中 / /标签的边界规则。 事故复盘:一次"无害"的枚举扩展 风控系统里有个渠道枚举,某次迭代新增了一个渠道 。开发改了上游的枚举定义,下游按渠道分发处理器的地方用的是 : 新渠道流量打进来,走到 返回 null,调用处 直接 NPE,当晚告警。更早的一版事故则是忘了 ——编译照样通过,运行时抛 ,因为传统 switch 语句遇到 null 匹配对象会直接 NPE,连进分支的机会都不给。
本节摘要:新增一个枚举值,线上当晚 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 语句默认不做穷尽性检查,新增枚举值时编译器一声不吭。所有依赖枚举分发的地方都成了定时炸弹,且引信在别人的提交里。
switch 的匹配基于调用 hashCode/ordinal 或字符串的 equals,键为 null 时直接 NPE。纪律:入口处判空,或者在分发函数里把 null 显式归入非法输入。
传统 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 表达式。
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 是控制流的越界输入第 1 章到此收束。下一章进入面向对象——equals 与 hashCode 的契约被破坏时,连缓存都会跟着说谎。