前阵子维护一个老旧的报表计算模块,里面充斥着instanceof检查后强制转型的代码,各种if-else嵌套了四五层,每新加一种运算类型,都得小心翼翼改好几处,生怕遗漏一个分支导致运行时崩溃。
正好项目升级到了Java 21,我把这部分逻辑用密封类(Sealed Classes)配合模式匹配(Pattern Matching)重写了一遍。结果令人满意——不仅消除了所有的强制转型,编译时就能检查出遗漏的运算类型,整个代码结构从一棵撑满条件判断的巨树变成了一组轻巧的叶子节点。这篇就用这个表达式求值器的完整例子,把密封类和模式匹配的配合之道拆解清楚。
为什么需要密封类
设想我们要表示一个算术表达式的树结构:有整数常量、加法节点、乘法节点。传统做法是定义一个抽象父类或接口,然后让各个子类实现它。问题是:任何人都可以在其他包里扩展这个接口,导致你在编写遍历逻辑(比如求值)时,无法穷举所有可能的子类型。
密封类就是干这个的:限制一个类或接口能有哪些子类,并在编译期确定一个封闭的层次结构。声明时使用sealed关键字,然后用permits列出所有允许的子类。
第一步:定义密封的表达式类
// 表达式根接口,仅允许三种子类型
public sealed interface Expr
permits Constant, Add, Multiply { }
// 整数常量
public record Constant(int value) implements Expr { }
// 加法
public record Add(Expr left, Expr right) implements Expr { }
// 乘法
public record Multiply(Expr left, Expr right) implements Expr { }
这里用record进一步简化代码,自动生成了构造函数、访问器和equals等方法。现在,任何一个地方只要拿到Expr,编译器就知道它必定是Constant、Add或Multiply之一,再没有第四种可能。
第二步:用模式匹配写出安全求值器
在Java 21中,switch已经可以匹配类型并自动提取变量,结合密封类,你能够写出穷举型的分支,而且不需要强制转型。
public class Evaluator {
public static int eval(Expr expr) {
return switch (expr) {
case Constant(int value) -> value;
case Add(Expr left, Expr right) -> eval(left) + eval(right);
case Multiply(Expr left, Expr right) -> eval(left) * eval(right);
// 不需要default分支——编译器确认了所有可能性
};
}
}
这里的关键点:case Constant(int value) 直接解构了record,拿到了内部的 int 值;case Add(Expr left, Expr right) 同时匹配了类型并解构出了左右子树。整个过程没有 instanceof,没有 (Constant) expr 强转,代码像模式声明一样直白。
你可能会问:如果以后增加一种减法,会发生什么?编译器会在switch上直接报错——因为你必须覆盖所有允许的子类型。这就从根本上防止了“加新类型但忘了改某处逻辑”的隐蔽bug。
第三步:构造并运行一个表达式
public class Main {
public static void main(String[] args) {
// (3 + 5) * 10
Expr expr = new Multiply(
new Add(new Constant(3), new Constant(5)),
new Constant(10)
);
int result = Evaluator.eval(expr);
System.out.println("结果: " + result); // 输出 80
}
}
运行一下,80被正确计算出来。而且就算表达式嵌套得更深,eval的递归也自然运转。
扩展:添加更多运算时的工作流
假设现在需要加入减法和除法。只需要在permits列表里追加新类型,并创建对应的record:
public sealed interface Expr
permits Constant, Add, Multiply, Subtract, Divide { }
public record Subtract(Expr left, Expr right) implements Expr { }
public record Divide(Expr left, Expr right) implements Expr { }
然后修改Evaluator的switch:编译器立刻提醒你缺少分支,补上即可:
case Subtract(Expr left, Expr right) -> eval(left) - eval(right);
case Divide(Expr left, Expr right) -> {
int divisor = eval(right);
if (divisor == 0) throw new ArithmeticException("除以零");
yield eval(left) / divisor;
}
这种“编译时提醒”的能力,让类型体系真正成为了代码行为的指南,而不只是文档里的一个继承图。
密封类 + 模式匹配的优势小结
- 消除强制转型:模式匹配自动把变量转换为匹配的类型,代码更简洁。
- 穷举检查:密封类让
switch无需default分支,添加新类型时编译告警,防止遗漏。 - 解构记录:
record与模式匹配天然融合,直接在case里拆解字段。 - 纯函数式风格:表达式树可以用不可变记录表示,整个求值器没有副作用。
应对更复杂的场景:带变量的表达式
如果想扩展成一个带有变量的表达式求值器(例如 3*x + 2),只需在Expr密封体系里增加一个Variable记录:
public record Variable(String name) implements Expr { }
求值函数增加一个环境参数,并在switch里添加对变量的处理:
public static int eval(Expr expr, Map<String, Integer> env) {
return switch (expr) {
case Constant(int value) -> value;
case Variable(String name) -> env.getOrDefault(name, 0);
case Add(Expr left, Expr right) -> eval(left, env) + eval(right, env);
// ... 其他分支
};
}
依然是全量编译保护,新加入的Variable如果不处理,switch即刻报错。
注意事项
- 密封类的所有直接子类必须在同一个模块或包下(如果没声明模块),这符合封闭体系的设计意图。
- 模式匹配
switch要求穷举,但如果你用接口而没有密封,则仍需default分支,因此密封类是该功能的理想搭档。 - 记录里如果包含引用类型字段,模式匹配后的变量仍然是引用,需注意空指针。
- 目前仍在预览特性中的
switch模式匹配(如解构容器类)也在进一步演进,有望让更多场景受益。
总结
用密封类定义领域模型,用模式匹配编写分支逻辑,Java的面向对象体系终于有了闭环的类型穷举能力。从表达式求值器这个例子里可以看到,过去又长又脆的instanceof判断完全被一种声明式风格替代,新增类型时编译器会逼着你补全所有处理逻辑,代码的健壮性随之上升了一个等级。
如果你手里的Java项目还在几十个else if (x instanceof SomeType)里挣扎,不妨把其中那些天然封闭的类型族抽出来,换成密封记录加模式匹配。这种重写不费力,却能把隐藏的类型陷阱一次清空,后续加功能也从“猜哪里漏了”变成“看编译器提示”。

