Java · 现代语言特性 (record / sealed / 模式匹配)

record 一行定义不可变数据, sealed 封闭继承域, switch 模式匹配带穷尽检查 — Java 8 之后的三板斧, 传统样板代码的葬礼

40 行 DTO 的葬礼: record · sealed · 模式匹配穷尽 传统 DTO ≈ 40 行 (样板的坟场) public final class Order { private final String id; private final BigDecimal amount; private final Status status; public Order(String id, BigDecimal a, Status s) { ...9 行... } public String getId() { return id; } public BigDecimal getAmount() { ... } public Status getStatus() { ... } @Override public boolean equals( Object o) { ...15 行... } @Override public int hashCode() { ...5 行... } @Override public String toString() { ...3 行... } } 改一个字段, 全套重抄一遍还容易漏 equals 压缩 record 一行定义 public record Order( String id, BigDecimal amount, Status status) {} 自动生成 ↓ 构造器·访问器 id() equals·hashCode toString·全 final sealed 继承域: 分支全部上锁 sealed interface Shape permits Circle, Rect, Square «sealed» Shape record Circle record Rect record Square 每个子类必须三选一: final / sealed / non-sealed permits 之外的类 implements Shape → 编译错 子类需同模块 (无模块则同包), 早期版本要求同文件 反射: Class.getPermittedSubclasses() 可枚举封闭域 编译器从此知道"全部可能" — 穷尽检查的地基 switch 模式匹配 · 穷尽检查 (编译器保证) double area = switch (shape) { case Circle c -> c.r() * c.r() * PI; case Rect r -> r.w() * r.h(); case Square s -> s.a() * s.a(); }; // 漏掉 Square → 编译错: // the switch expression does not cover // all possible input values ✓ ✓ 新增 Triangle 子类 → 全项目相关 switch 编译报错, 自动提醒改造点 ✓ 不写 default 才有穷尽保护 — 写了等于把提醒关掉 守卫: case Circle c when c.r() > 9 -> ... 同类型再细分 解构: case Circle(Point p, double r) → 按组件拆开 (21+) 三小件速览 · switch 表达式 / var / 文本块 switch 表达式 (14) · 箭头 / yield int n = switch (x) { case A -> 1; default -> { yield f(); } }; 箭头 case 无 fall-through · 有返回值 · 块内用 yield 产值 var (10) · 局部类型推断 var list = new ArrayList<Order>(); ✓ 右侧类型可见 只能局部变量 · 编译期糖, 不是动态类型 文本块 (15) · """ String json = """ { "k": 1 } """; 最小公共缩进剥离 · 行尾 \ 续行 · \s 保尾随空格 Legend 传统样板 record/穷尽✓ sealed 域 编译错警示 新语法速览

record = 数据的极简表达

  • • 一行头部顶 40 行样板: 构造器/访问器/equals/hashCode/toString
  • • final + 不可变, 天然线程安全可缓存
  • • 紧凑构造器做校验: 非法状态不可表示
  • • 访问器是 id() 不是 getId() — 语义是"数据"不是"JavaBean"

sealed + 穷尽 switch

  • • permits 圈死实现集: 编译器知道"全部可能"
  • • 漏分支编译错, 不写 default 才有穷尽保护
  • • 新增子类 → 全项目 switch 自动报错提醒
  • • 状态机/渠道分发/访客模式的现代形态

表达力三小件

  • • switch 表达式: 有返回值, 箭头无 fall-through, yield 产值
  • • var: 只限局部, 右侧类型要一眼可见
  • • 文本块 """: 告别转义地狱, 缩进自动剥离
  • • instanceof 模式匹配: 判型即绑定, 消强转

💡 一句话理解

record 解决的是"数据载体的样板": 一个 DTO 真正要写的只有字段列表, 其余 40 行都是仪式 — record 把仪式交给编译器, 你写一行, 它生成全套且不会漏 equals。sealed 解决的是"继承域的失控": 谁能实现我的接口由我圈死, 于是编译器第一次"知道全部可能" — 这正是穷尽 switch 的地基: 漏一个分支编译不过, 新增子类全项目报错提醒, 相当于用编译器做需求评审。

三板斧合体的杀伤力: DTO 用 record, 业务域用 sealed interface 圈住, 分发逻辑用 switch 表达式 — if-else 长链、instanceof 强转链、忘改分支的事故, 一起进坟场。配套的 var/文本块/instanceof 模式匹配是零碎的减负件: var 只在局部且右侧类型明显时用, 文本块的缩进由结尾定界符说了算。

🧠 必知必会 必考 & 必会

record 语义
一行头部 = 编译器生成: 全参构造器、访问器 id() (不是 getId)、按组件值的 equals/hashCode/toString、final 类 + final 字段。它是"不可变数据的载体", 不是"少写 getter 的技巧"。
record Point(int x, int y) {}              // JDK16+ 一行 = 全套样板
new Point(1, 2).x();                        // → 1   访问器是 x() 不是 getX
new Point(1, 2).equals(new Point(1, 2));         // → true  按组件值比较
紧凑构造器
public Money { 校验/规范化 } 无参数列表形态: 先改参数, 编译器再隐式赋给字段 — 把"非法状态不可表示"的防线挪到对象诞生前。
public record Money(BigDecimal amount) {
    public Money {                          // 关键: 无参数列表 = 紧凑形态
        if (amount.signum() < 0) throw new IllegalArgumentException("<0");
    }
}
new Money(new BigDecimal("-1"));            // → IllegalArgumentException
record 与框架
Jackson 2.12+ 原生反序列化 (配 -parameters 或 @JsonProperty); JPA Entity 不适合 (要无参构造+可变+代理), 但 DTO/投影/聚合结果用 record 正合适。
// Jackson 2.12+: record 原生反序列化
OrderDTO dto = mapper.readValue(json, OrderDTO.class);
// JPA Entity ✗ 要无参构造+可变; 投影/DTO ✓
public record OrderView(String id) implements OrderProjection {}
sealed + permits
sealed interface Shape permits Circle, Rect, Square: 继承域封闭, 子类必须 final/sealed/non-sealed 三选一; 子类需同模块 (无模块则同包)。价值: 编译器知道"全部可能"。
public sealed interface Shape permits Circle, Rect {}  // JDK17+
public record Circle(double r) implements Shape {} // record 隐式 final
// class Triangle implements Shape {}  → 编译错: 不在 permits 域内
类型模式
case Circle c: 判断类型 + 绑定变量一步完成, 替代 instanceof + 强转样板; c 的作用域仅限该分支。
if (shape instanceof Circle c && c.r() > 1) {  // JDK16+ 判型+绑定
    area = c.r() * c.r() * Math.PI;    // c 已是 Circle, 无强转
}                                      // 关键: c 仅此分支内可见
record 解构模式
case Circle(Point p, double r): 按组件拆开嵌套取值 (Java 21 正式), 只有 record 能被解构 — 普通类没有"组件"概念。
double d = switch (shape) {            // JDK21+ 解构模式
    case Circle(double r) -> Math.PI * r * r;
    case Rect(double w, double h) -> w * h;
};  // 关键: 只有 record 有"组件"可解构, 普通类不行
穷尽性检查
sealed 域上的 switch 表达式不写 default 时, 漏一个分支编译错: 新增子类 = 所有相关 switch 自动报错。"用编译器做需求评审"是 sealed 的全部意义。
double a = switch (shape) {             // sealed 域, 不写 default
    case Circle c -> c.r() * c.r() * Math.PI;
    case Rect r   -> r.w() * r.h();
};  // 删任一分支 → 编译错 does not cover all possible values
switch 表达式
有值可接: int n = switch (x) { case A -> 1; default -> { yield calc(); } }; 箭头 case 无 fall-through, 块内用 yield 产值, 冒号 case 也能配 yield。
int n = switch (level) {              // JDK14+ 有值可接
    case 1, 2 -> 10;                    // 箭头 case 无 fall-through
    default -> { int v = bonus(); yield v; } // 块内 yield 产值
};
守卫 when
case WechatChannel w when w.isVip() -> ...: 同类型下再筛条件; 注意守卫分支不参与穷尽检查, 其后要有兜底分支。
BigDecimal fee = switch (channel) {   // JDK21+ 守卫 when
    case Wechat w when w.isVip() -> BigDecimal.ZERO; // 同型再筛
    case Wechat w -> w.rate();
    case Alipay a -> a.rate();           // 守卫不参与穷尽, 留兜底
};
var 边界
局部变量类型推断: 只能局部 (不能字段/参数/返回值), 且右侧类型要一眼可见; 编译期糖 — 字节码与显式类型完全一致, 不是动态类型。
var orders = new ArrayList<Order>();     // JDK10+ ✓ 右侧类型可见
// var x = service.process(ctx);    ✗ 类型被抹, review 猜谜
// 字段/参数/返回值用 var → 编译错; 它是编译期糖, 非动态类型
文本块
""" 三引号多行字符串: 收尾定界符所在列参与决定最小公共缩进剥离; 行尾 \ 续行不换行、\s 保留尾随空格 — JSON/SQL/日志模板专用。
String json = """
    { "city": "杭州" }
    """;        // JDK15+ → { "city": "杭州" }  无多余缩进
instanceof 模式匹配
if (o instanceof String s) 判型即绑定, 流敏感作用域: 只在"确定匹配"的分支可见, 复合条件里编译器替你把关。
if (o instanceof String s && !s.isBlank()) {  // JDK16+
    log(s.trim());              // s 只在"确定匹配"分支可见
}                               // 写进 else 分支 → 编译器直接拦
Optional 姿势
只做返回值表达"可能没有": map/orElse/orElseThrow 链; 不做字段/参数, 不裸 get(), 不 isPresent()+get() 退回 if 时代。
String city = userRepo.findById(uid)        // 仓储返回 Optional
    .map(User::city)
    .orElse("未知");                  // → 有值取值, 无值兜底
// 关键: 只做返回值; 字段/参数/裸 get() 都是反模式

🏭 生产实战 real world

场景 1 · API DTO 全面 record 化: 与 Lombok 分道扬镳

Lombok @Value 靠注解处理器魔改字节码, 升 JDK 常踩坑; record 是语言原生, 依赖链先升级再迁移:

// 迁移前置: jackson 升 2.12+; 编译参数加 -parameters (保住组件参数名)
public record OrderDTO(String id, BigDecimal amount, Status status) {}

// 注解直接贴组件上, 反序列化字段映射照常工作
public record OrderDTO(
    @JsonProperty("order_id")   String id,
    @JsonProperty("pay_amount") BigDecimal amount,
    Status status) {}
// record 是语言特性: 调试器/反射/IDE 全原生支持, 不依赖 lombok 插件
// 判据: 出入参 DTO/内部值对象 → record; 有行为有状态的领域对象 → class

场景 2 · 紧凑构造器: 金额非负校验 + 精度规范化

脏数据从网关进来, 与其在每个使用点判空判负, 不如在对象诞生时收口:

// 紧凑构造器: 校验/规范化写这里, 不必重写整套装配件
public record Money(BigDecimal amount, Currency currency) {
    public Money {                                    // 无参数列表 = 紧凑形态
        amount = amount.setScale(2, RoundingMode.HALF_UP);  // 统一 scale
        if (amount.signum() < 0)
            throw new IllegalArgumentException("amount < 0: " + amount);
    }                                                // 非法值挡在对象诞生前
}
// 反模式: 在这里写 this.amount = ... — 编译错, 字段赋值由编译器收尾

场景 3 · sealed 建模支付渠道: 新渠道编译期强制补分支

支付渠道是典型封闭集合 — 漏改一个渠道的费率/对账逻辑就是资损事故, 用 sealed 把风险变成编译错:

// permits 圈死实现集 — 加渠道漏改分支? 编译器追着你改
public sealed interface PaymentChannel
        permits AlipayChannel, WechatChannel, CardChannel {}

public record AlipayChannel(String appId) implements PaymentChannel {}
public record WechatChannel(String mchId) implements PaymentChannel {}
public record CardChannel(String bin)   implements PaymentChannel {}

// 新接 UnionPay 忘了改费率 switch → 缺分支, mvn compile 直接红, 上不了线

场景 4 · switch 表达式重构 if-else 链: 返回值 + 穷尽

渠道费率原来是一串 if-else, 新增渠道忘改就静默走 else 兜底 — 换成穷尽 switch 后这成了不可能事件:

// if-else 长链 → switch 表达式: 有返回值 + 穷尽 + 无 fall-through
BigDecimal fee = switch (channel) {
    case AlipayChannel a  -> a.amount().multiply(new BigDecimal("0.006"));
    case WechatChannel w when w.isVip() -> BigDecimal.ZERO;      // 守卫子条件
    case WechatChannel w  -> w.amount().multiply(new BigDecimal("0.005"));
    case CardChannel c    -> c.amount().multiply(new BigDecimal("0.012"));
};  // sealed 穷尽: 运行时走不到"没想过"的路径

场景 5 · 文本块写内嵌 JSON / SQL 模板

拼接 JSON 的引号转义地狱 + SQL 的 + 换行, 文本块一次解决, 缩进行为由收尾定界符说了算:

// 内嵌 JSON 模板: 告别引号转义地狱; 缩进由结尾 """ 所在列决定
String body = """
    {
      "city": "杭州",
      "tags": ["new", "vip"]
    }
    """;                       // 此列左侧的公共缩进整体剥掉

// SQL: 行尾 \ 续行不换行; \s 显式保留尾随空格 (默认会被剥)
String sql = """
    select id, amount \
    from t_order where city = '杭州'\
    """;

场景 6 · instanceof 模式匹配消强转

遗留代码里三层嵌套的 instanceof + (String) 强转, 模式匹配一步到位且作用域严格:

// 消强转: 类型判断 + 变量绑定一步完成, 变量作用域只在分支内
if (obj instanceof String s && !s.isBlank()) {
    log.info("raw string: {}", s.trim());   // s 已是 String, 无强转
} else if (obj instanceof List<?> list && list.size() > 100) {
    log.warn("oversized list: {}", list.size());
}
// 流敏感: 条件成立 s 才可用 — 写到 else 里去编译器直接拦下

场景 7 · record 实现多接口 + 静态工厂 of/parse

record 虽不能继承类, 但能实现接口 — 数据携带工厂与解析逻辑, 构造语义显式化:

// record 实现接口 + 静态工厂: 默认值收口, 非法值早爆
public record PageQuery(int page, int size) implements Query {
    public static PageQuery of(int page, int size) {
        if (size > 200) throw new IllegalArgumentException("size > 200");
        return new PageQuery(Math.max(page, 1), size);  // 默认值收口在工厂
    }
    public static PageQuery parse(Map<String, String> q) {
        return of(Integer.parseInt(q.getOrDefault("page", "1")),
                  Integer.parseInt(q.getOrDefault("size", "20")));
    }
}

场景 8 · sealed + 增强版访客: 替代 if-else 分发

事件路由表原来是 20 行 if-else 且新增事件要人肉记得改 — 封闭域上的穷尽 switch 让编译器当检查员:

// 分发逻辑从 if-else 长链变成封闭域上的穷尽 switch
public sealed interface Event permits OrderPaid, OrderRefunded, CartUpdated {}

public record Route(String table, String topic) {}
public Route route(Event e) {
    return switch (e) {                    // 新事件上线 → 这里编译报错
        case OrderPaid p     -> new Route("t_paid", "paid-topic");
        case OrderRefunded r -> new Route("t_refund", "refund-topic");
        case CartUpdated c   -> new Route("t_cart", "cart-topic");
    };
}

场景 9 · var 团队规约: 提效与可读的平衡线

var 用好了减噪, 用烂了 review 变猜谜 — 四条规约写进团队手册, 满足才许用:

// var 团队规约: 只用局部变量, 且右侧类型"一眼可见"
var orders = new ArrayList<OrderDTO>();    // ✓ 右侧类型明确
var map   = groupByCity(orders);            // ✗ 要翻方法签名才知道类型
try (var in = Files.newInputStream(path)) { // ✓ 资源具体类型无关紧要
    in.readAllBytes();
}
for (var e : orders.entrySet()) { log.debug("{}", e.getKey()); } // ✓ 减噪
// var 不能上字段/参数/返回值 — 它只是局部的类型推断, 不是动态类型

场景 10 · Optional 返回值: orElseThrow/map 链的正确姿势

仓储层返回 null 逼着调用方层层设防 — Optional 把"可能没有"变成显式契约, 兜底策略就地表达:

// Optional 只做返回值: 链式取值 + 显式兜底, 拒绝裸 get()
public City resolveCity(long uid) {
    return Optional.ofNullable(userRepo.find(uid))  // 仓储本就该返回 Optional
        .map(User::profile)
        .map(Profile::address)
        .map(Address::city)
        .orElseThrow(() -> new NotFound("user " + uid));  // 缺失走异常
}
// 禁: Optional 做字段/参数/get() — 序列化炸, 签名噪音, NoSuchElementException

⚠️ 编码注意与常见坑 pitfalls

坑 1 · record 不能被继承 — extends OrderDTO 报 "cannot inherit from final Order"; record 也隐式继承 java.lang.Record, 不能再 extends 别的类. 正解: 要多态就实现接口, 或上 sealed 域。
// 错: class VipOrder extends OrderDTO {}  // → cannot inherit from final
// 对: record VipOrder(String id) implements Discount {}  // 多态走接口
//     record 隐式 extends java.lang.Record, 也不能再 extends 别的类
坑 2 · JPA 实体用 record — 启动报错或懒加载失效: Entity 要无参构造 + 可变字段 + 运行时代理, record 全违反. 正解: 实体保留 class; 查询投影/DTO 用 record (Spring Data 原生支持 record 投影)。
// 错: @Entity public record Order(Long id) {}  // 启动报错/懒加载失效
// 对: @Entity public class Order { ... }       // 实体保留 class
//     record OrderView(String id) {}  ← 投影/DTO 用 record 没问题
坑 3 · Jackson 反序列化 record 报错 — InvalidDefinitionException: cannot construct instance... 原因: Jackson 2.12 前不认识 record; 或编译没带 -parameters 且组件没标 @JsonProperty, 参数名丢失对不上. 正解: 升 2.12+, 编译加 -parameters, 必要时组件逐个标注。
// 错: jackson 2.11 或没编 -parameters → InvalidDefinitionException
// 对: 升 jackson 2.12+; 编译带 -parameters; 必要时逐组件标注:
public record OrderDTO(@JsonProperty("order_id") String id) {}
坑 4 · record"不可变"被集合组件出卖 — 组件是 List, 外部持同一引用 add, record 内容跟着变, equals 分分钟不一致. 原因: record 的不可变只是"引用不变". 正解: 紧凑构造器里 items = List.copyOf(items) 防御拷贝。
// 错: record Cart(List<String> items) {}   // 引用不变 ≠ 内容不变
List<String> m = new ArrayList<>(List.of("a"));
Cart c = new Cart(m);  m.add("b");       // → c.items() 变 [a, b]
// 对: public Cart { items = List.copyOf(items); }  防御拷贝
坑 5 · 紧凑构造器给字段赋值 — 写 this.amount = ... 报 "cannot assign a value to final variable". 原因: 紧凑形态里字段赋值是编译器的收尾动作, 不许抢. 正解: 只改参数, 隐式赋值自动生效。
// 错: public Money { this.amount = amount; }  → cannot assign final
// 对: public Money { amount = amount.setScale(2, HALF_UP); }
//     字段赋值是编译器的收尾动作, 抢了就编译错, 只改参数即可
坑 6 · permits 子类放错位置 — 编译错 "subclass of sealed class must be in same package/module". 原因: sealed 要求子类同模块 (无模块化则同包), 早期版本要求同文件. 正解: 规划好包结构再 sealed, 或子类与接口同文件声明。
// 错: Shape 在 com.app.core, Circle 在 com.app.web
//     → subclass of sealed class must be in same package/module
// 对: 子类与接口同包声明, 或同一个 .java 文件里写一起
坑 7 · 模式匹配语法版本漂移 — 同一份代码在 JDK 17 编译过、16 报 "unsupported release" (类型模式 17 正式, record 解构 21 正式, 预览期还要 --enable-preview). 正解: pom 的 maven.compiler.release 锁死版本, 升级说明里记录语法边界。
// 错: JDK17 编译 case Point(int x, int y) → 编译不过 (解构 21 才正式)
// 对: pom 锁死版本, 升级说明记录语法边界:
//     <maven.compiler.release>21</maven.compiler.release>
坑 8 · switch 表达式里写 return — 编译错 "attempt to return from within a switch expression". 原因: 表达式的值由 yield 产出, return 属于方法不属于 case. 正解: 块内 yield value;; 真要提前退出方法就别用 switch 表达式。
int n = switch (x) {
    case A -> { return 1; }         // 错: return from within switch expression
    default -> { int v = calc(); yield v; } // 对: yield 产值
};
坑 9 · var 类型不可见 — var x = service.process(...) review 时全靠猜, 泛型嵌套更灾难 (是 List 还是 Optional<List>?). 原因: 推断把类型信息从调用点抹掉. 正解: 团队规约"右侧不含类型名/构造器就不用 var"。
var r = service.process(ctx);         // 错: List? Optional? review 猜谜
List<Order> r2 = service.process(ctx);  // 对: 类型回到调用点
var orders = new ArrayList<Order>();    // 对: 右侧含构造器, 一眼可见
坑 10 · var 上字段/参数 — 编译错 "var is not allowed here". 原因: var 只设计给局部变量推断, 签名是 API 契约不许被抹. 正解: 字段/参数类型老实写全。
// 错: class A { var name; } / void f(var p) {} → var is not allowed here
// 对: class A { String name; }  void f(String p) {}  签名类型写全
var name = "ok";                       // var 只属于方法内局部变量
坑 11 · 文本块缩进"玄学" — 拼出的串前面多/少一截空格, JSON/SQL 解析报错. 原因: 剥离的是"所有行 + 收尾定界符"的最小公共缩进, 不是开头定界符所在列. 正解: 收尾 """ 单独一行并顶到目标列, 疑难杂症用 .stripIndent() 打印验证。
String a = """
    abc
    """;       // 对: 收尾有缩进 → "abc"     (公共缩进 4 被剥)
String b = """
    abc
""";         // 错: 收尾顶格公共缩进=0 → "    abc" (4 空格保留)
坑 12 · 文本块尾随空格消失 — 拼签名串/SQL 时结果悄悄变了. 原因: 规范规定行尾空格默认剥除. 正解: \s 显式保留一个空格; 对齐敏感场景逐行检查。
// 错: 行尾空格默认剥除 — 拼签名/SQL 结果悄悄变了
String s = """
    sign: alice\s
    """;       // 对: \s 保留尾随空格 → "sign: alice "
坑 13 · instanceof 绑定变量作用域 — if (!(o instanceof String s)) return; 后面用 s 报 "cannot find symbol". 原因: 流敏感作用域, 取反后"匹配成立"不被保证. 正解: 正向 if 包住使用处, 或改 Optional/map 链。
if (!(o instanceof String s)) return;
log(s);                          // 错: cannot find symbol s
if (o instanceof String s) log(s);    // 对: 正向 if 包住使用处
坑 14 · record 数组组件 equals 不等 — 两个"内容相同"的 record 判不等, Set/Map key 失灵. 原因: 生成的 equals 对数组组件仍是引用比较 (不是 Arrays.equals). 正解: 组件用 List 替代数组, 或手写 equals 补 Arrays.equals。
record Tags(String[] arr) {}
new Tags(new String[]{"a"}).equals(new Tags(new String[]{"a"}));  // 错: → false
record Tags2(List<String> arr) {}   // 对: 同内容比较 → true
坑 15 · getPermittedSubclasses 判空 — 反射遍历 permitted 子类时 NPE. 原因: 非 sealed 类返回 null, 隐藏类返回空. 正解: 先 isSealed() 再取; 注意返回的是 Class 描述, 用前做可达性处理。
// 错: cls 非 sealed 时 getPermittedSubclasses() 返回 null → NPE
if (cls.isSealed())                          // 对: 先判再取
    for (Class<?> c : cls.getPermittedSubclasses()) handle(c);
坑 16 · record 加实例字段 — 想塞个缓存/Lazy 字段直接编译错. 原因: record 的实例状态 = 组件, 不许额外实例字段 (静态字段可以). 正解: 派生值写成方法 amountWithTax(); 真有可变状态就不是 record 的场景。
// 错: record Order(...) { private double cache; }  → 实例字段必须静态
// 对: 派生值写成方法
double amountWithTax() { return amount().multiply(TAX); }
坑 17 · 解构模式用在普通类 — case Point(int x, int y) 对 class Point 编译错. 原因: 解构模式只支持 record 的组件访问器, 普通类没有"组件"概念. 正解: 建模改 record, 或退回类型模式 + getter。
class Point { int x, y; }
// 错: case Point(int x, int y) → 普通类无组件, 编译错
record Point2(int x, int y) {}        // 对: 改 record 才可解构 (JDK21+)
坑 18 · switch 撞上 null — 传统 switch(shape) 传 null 直接 NPE; 模式匹配 switch 同样 NPE, 但 Java 21 起可 case null -> 显式接住. 正解: 入口判空或 case null 兜底, 别赌"不会是 null"。
double a = switch (shape) {
    case null -> 0;                  // 对: JDK21+ 显式接住 null
    case Circle c -> c.r() * c.r() * Math.PI;
};  // 错: 不写 case null, 传 null → NullPointerException
坑 19 · Optional.get 裸调 — NoSuchElementException 在千里之外的调用方爆, 栈里没有业务语义. 原因: get() 假设"一定有", 把契约检查甩给了运行时. 正解: orElseThrow(() -> new NotFound(...)) 带语义, 或 orElse 给默认。
opt.get();                                // 错: → NoSuchElementException, 无业务语义
opt.orElseThrow(() -> new NotFound("user " + uid));  // 对
String v = opt.orElse("default");          // 对: 能兜底就兜底
坑 20 · 文本块里 \ 转义差异 — 正则 "\\d+" 到文本块里行为变了, 或行尾想保留反斜杠却被当续行拼掉. 原因: 文本块转义规则与普通串一致, 行尾 \ 专用于续行. 正解: 正则照旧双写 \\d; 行尾真反斜杠写成 \\ 或前面加 \s 隔断。
// 错: 文本块里写 \d 当正则 → illegal escape; 行尾单 \ 被当续行拼掉
Pattern p = Pattern.compile("""
    \\d{3}-\\d{4}""");        // 对: 照旧双写 → 正则 \d{3}-\d{4}