Java 的模式匹配是十几轮 JEP 叠加出来的:instanceof 模式三轮(JDK 14 预览、15 再预览、16 定稿),switch 上的模式匹配五轮(JDK 17~20 四次预览、21 定稿),记录模式三轮(19、20 预览、21 定稿),未命名变量两轮(21 预览、22 定稿),而原始类型模式到 JDK 27 已经是第五次预览,仍然没有定稿。
这篇文章按 JEP 的顺序回答另一类问题:每一轮究竟改了什么、为什么不得不改,以及那些只在预览期存在过的语法去了哪里。
文中所有编译器输出都来自本机实测(JDK 17.0.5 / JDK 21.0.8 / JDK 25+37-LTS,Apple M1 Pro,macOS);凡是标注「实测」的结论,都能在下面找到对应的原始输出块。JEP 的编号、版本、状态与引用的动机,全部核对自 openjdk.org 的 JEP 页面正文。

先看一条时间线

把后面要展开的东西压成一张表,方便随时对照:

JEP JDK 状态 关键变化
305 14 预览 instanceof 模式首次预览
375 15 第二次预览 与 JDK 14 的预览完全一致,只为继续收集反馈
394 16 定稿 模式变量不再隐式 final;恒真的 instanceof 模式改为编译错误
406 17 预览 switch 支持模式;case null;守卫模式 p && b;括号模式
420 18 第二次预览 常量标签须排在同类型守卫模式之前;sealed 泛型层次的穷尽性更精确;total pattern 也匹配 null
427 19 第三次预览 守卫模式 &&when 子句替换;null 语义回归传统 switch
405 19 预览 记录模式首次预览(含具名记录模式)
432 20 第二次预览 泛型记录模式类型参数推导;记录模式进入增强 for 头部;移除具名记录模式
433 20 第四次预览 enum 穷尽 switch 改抛 MatchException;switch 标签文法简化;switch 中支持泛型记录模式推导
440 21 定稿 移除增强 for 头部的记录模式
441 21 定稿 移除括号模式;允许限定枚举常量;when 守卫、支配与穷尽性规则定稿
443 21 预览 未命名模式与未命名变量 _
456 22 定稿 未命名变量与模式,无改动定稿
455 23 预览 原始类型进入模式、instanceof 与 switch
488 24 第二次预览 无改动
507 25 第三次预览 无改动
530 26 第四次预览 增强无条件精确性(unconditional exactness)定义;更严格的支配检查
532 27 第五次预览 无改动;截至 JDK 27 GA 仍未定稿

表内所有 JEP 的页头 Status 字段都是 Closed / DeliveredRelease 就是表里的 JDK 版本号。JDK 27 的发布日期是 2026-09-15,JEP 532 的 History 段落写的是「re-previewed by JEP 530 (JDK 26) … We here propose to preview it for a fifth time, without change」。

从 instanceof 说起:三轮换来的一个语法

样板代码的问题在哪

JDK 16 之前,判断类型再取用要写三段:

1
2
3
4
5
6
7
8
class BeforePatterns {
void use(Object obj) {
if (obj instanceof String) {
String s = (String) obj; // 同一件事写了两遍
// 使用 s
}
}
}

JEP 375 的 Motivation 段落把这件事拆得很细:这段代码里同时发生了三件事——测试、转换、声明新局部变量;重复出现三次的类型名既淹没了逻辑本身,又「provides opportunities for errors to creep unnoticed into programs」。

三轮的节奏是这样的:

  • JEP 305 在 JDK 14 首次预览;
  • JEP 375 在 JDK 15 原样再预览,它的 History 写得很直接:「This JEP proposes to re-preview the feature in JDK 15, with no changes relative to the preview in JDK 14, in order to gather additional feedback」;
  • JEP 394 在 JDK 16 定稿,并且带着两处修改。

JEP 394 的 History 列出的两处修改是:

  • 「Lift the restriction that pattern variables are implicitly final, to reduce asymmetries between local variables and pattern variables.」(模式变量不再是隐式 final,减少它与普通局部变量的不对称);
  • 「Make it a compile-time error for a pattern instanceof expression to compare an expression of type S against a pattern of type T, where S is a subtype of T.」(恒真的 instanceof 模式是编译错误,因为这种判断「will always succeed and is then pointless」)。

一个可运行的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import java.util.List;

public class InstanceofPattern {

sealed interface Shape permits Circle, Square {}
record Circle(double radius) implements Shape {}
record Square(double side) implements Shape {}

static String describe(Object o) {
if (o instanceof String s && !s.isBlank()) {
return "non-blank string: " + s;
} else if (o instanceof String s) {
return "blank string, length " + s.length();
} else if (o instanceof Shape shape) {
return "shape: " + shape;
}
return "other: " + o.getClass().getSimpleName();
}

public static void main(String[] args) {
List<Object> inputs = List.of("hello", " ", new Circle(1.5), new Square(2.0), 42);
for (Object o : inputs) {
System.out.println(describe(o));
}
}
}
1
2
3
4
5
non-blank string: hello
blank string, length 3
shape: Circle[radius=1.5]
shape: Square[side=2.0]
other: Integer

这段代码还展示了两条容易被忽略的规则:s 的作用域由流程分析决定,o instanceof String s && !s.isBlank()s 只在右侧表达式为真时可见;同一个方法里可以出现两处 o instanceof String s,因为它们处在互斥的分支上,前一处不满足时那个变量并不在作用域内。

那条「恒真即错误」的规则,在 JDK 21 被放宽了

固定类型对父类型做 instanceof 判断,JDK 16 起是错误,但这条限制没有一直保留。

1
2
3
4
5
6
7
8
9
10
11
12
public class InstanceofUnconditional {
public static void main(String[] args) {
Integer i = 42;
if (i instanceof Number n) {
System.out.println("number: " + n);
}
String s = "hello";
if (s instanceof CharSequence cs) {
System.out.println("length: " + cs.length());
}
}
}

同一份源码,只换 --release

1
2
3
4
5
6
7
8
9
10
$ javac --release 17 InstanceofUnconditional.java
InstanceofUnconditional.java:4: 错误: -source 17 中不支持 instanceof 中的无条件模式
if (i instanceof Number n) {
^
(请使用 -source 21 或更高版本以启用 instanceof 中的无条件模式)
1 个错误

$ javac --release 21 InstanceofUnconditional.java && java InstanceofUnconditional
number: 42
length: 5

实测的释放版本矩阵(JDK 25 的 javac,--release 17/18/19/20 全部报「不支持 instanceof 中的无条件模式」,--release 21/22/23/24/25 全部通过)说明这条限制是在 JDK 21 被放开的。编译器提示里的英文原文是 unconditional patterns in instanceof are not supported in -source 17,而「无条件模式(unconditional pattern)」正是 JEP 441 用来描述 case Object ocase String s 这类模式的术语——JEP 441 的正文写道「the type pattern String s unconditionally matches a selector expression of type String」。JDK 21 用一套更精确的支配/穷尽性规则,替换掉了 JDK 16 那刀切式的禁令。(把这两件事关联起来是我的解读,JEP 页面本身没有为这条放宽单独立项,能确证的只有编译器行为。)

switch 上的模式匹配:四轮预览

第一轮 JEP 406(JDK 17):把 switch 变成分派器

JEP 406 的 Goals 列了六条,其中三条决定了后面几轮的走向:

  • 「Introduce two new kinds of patterns: guarded patterns, to allow pattern matching logic to be refined with arbitrary boolean expressions, and parenthesized patterns, to resolve some parsing ambiguities.」:守卫模式与括号模式都是这一轮引入的;
  • 「Allow the historical null-hostility of switch to be relaxed when desired.」:switch 遇到 null 必抛异常的传统要放宽;
  • 「Ensure that all existing switch expressions and statements continue to compile with no changes and execute with identical semantics.」:既有 switch 代码必须一字不改、语义不变。

最后一条是往后所有争论的底线。它解释了为什么模式匹配不能”顺手”改掉 switch 的旧行为,只能加法式地扩展。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class ShapeSwitch {

sealed interface Shape permits Circle, Square, Rect {}
record Circle(double radius) implements Shape {}
record Square(double side) implements Shape {}
record Rect(double w, double h) implements Shape {}

static double area(Shape s) {
return switch (s) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Square sq -> sq.side() * sq.side();
case Rect r -> r.w() * r.h();
};
}

static String legacy(Object o) {
if (o instanceof Circle c) {
return "circle " + c.radius();
} else if (o instanceof Square sq) {
return "square " + sq.side();
}
return "unknown";
}

public static void main(String[] args) {
Shape s = new Rect(3, 4);
System.out.printf("area=%.2f%n", area(s));
System.out.println(legacy(new Circle(2)));
System.out.println(legacy(new Rect(1, 1)));
}
}
1
2
3
area=12.00
circle 2.0
unknown

area 没有 default 分支:Shape 是 sealed 的,编译器知道只有三种直接子类型,能证明这个 switch 表达式覆盖了全部取值。而 legacy 那条 if/else 链遇到 Rect 会悄悄地返回 unknown——注意这不是抛异常,是静默地走错分支,正是 JEP 406 描述的「overly general control construct」带来的病灶。

在 JDK 17 上跑这份源码需要预览开关,实测:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ javac ShapeSwitch.java
ShapeSwitch.java:11: 错误: patterns in switch statements 是预览功能,默认情况下禁用。
case Circle c -> Math.PI * c.radius() * c.radius();
^
(请使用 --enable-preview 以启用 patterns in switch statements)
1 个错误

$ javac --enable-preview --release 17 ShapeSwitch.java
注: ShapeSwitch.java 使用预览语言功能。
注: 有关详细信息,请使用 -Xlint:preview 重新编译。
$ java --enable-preview -cp . ShapeSwitch
area=12.00
circle 2.0
unknown

JDK 25 上同样的源码直接编译运行,输出一致——这是这个特性少有的”从预览到定稿没有改过表现”的部分。

第二轮 JEP 420(JDK 18):排序规则与 sealed 泛型的穷尽性

第二次预览只改了两处:

  • 「Dominance checking now forces a constant case label to appear before a guarded pattern of the same type, for readability」:常量标签必须排在同类型的守卫模式之前,理由是可读性
  • 「Exhaustiveness checking of switch blocks is now more precise with sealed hierarchies where the permitted direct subclass only extends an instantiation of the (generic) sealed superclass」:sealed 泛型层次结构下的穷尽性判断更精确了。

第二处是纯粹的实现难度:当 sealed 父类型是泛型、而 permitted 子类只继承了某个具体实例化时,穷尽性推导要跟着泛型走。

这一轮还有一条只活了一个版本的规则。JEP 420 正文写的是:

If the selector expression evaluates to null then any null case label or a total pattern case label is said to match.

在 JDK 18 的预览里,case Object o 这种对选择器类型「全覆盖」的模式是能接住 null 的,同一份 JEP 还写了 case p(p 是 total pattern)会支配 case null。到 JDK 19 这个说法被整段删掉了。

第三轮 JEP 427(JDK 19):when 取代 &&null 回到传统语义

JEP 427 的 History 只有两条,但每条都推翻了自己上一轮:

  • 「Guarded patterns are replaced with when clauses in switch blocks.」:守卫模式 p && bwhen 子句取代;
  • 「The runtime semantics of a pattern switch when the value of the selector expression is null are more closely aligned with legacy switch semantics.」:null 的运行时语义向传统 switch 靠拢。

JEP 427 的 Alternatives 段落给出了放弃 && 的理由:

An alternative to guarded pattern labels is to support guarded patterns directly as a special pattern form, e.g. p && e. Having experimented with this in previous previews, the resulting ambiguity with boolean expressions have lead us to prefer when clauses in pattern switches.

「与布尔表达式产生歧义」是纯语法层面的问题:case String t && t.length() > 3 里,&& 到底属于模式语法还是布尔表达式语法,读者和解析器都要犹豫。换成 when 之后,守卫的边界一眼可见。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class GuardDemo {

static final int threshold = 10;

static String classify(String s) {
return switch (s) {
case null -> "null";
case String t when t.isBlank() -> "blank";
case String t when t.length() > threshold -> "long: " + t.length();
default -> "short: " + s;
};
}

public static void main(String[] args) {
int when = 3;
System.out.println("when as identifier = " + when);
for (String input : new String[] {null, " ", "abcdefghijklmno", "hi"}) {
System.out.println(classify(input));
}
}
}
1
2
3
4
5
when as identifier = 3
null
blank
long: 15
short: hi

那段代码里之所以能写 int when = 3;,是因为 when上下文关键字而不是保留字,存量代码里叫 when 的变量不受影响。这种”把新关键字藏进上下文”的做法,正是为了满足 JEP 406 那条「既有代码一字不改」的目标。

&& 与括号模式的消失,是可以直接拿编译器验证的:

1
2
3
4
5
6
7
8
9
10
11
12
public class GuardedPatternAnd {
static String describe(String s) {
return switch (s) {
case String t && t.length() > 3 -> "long: " + t;
default -> "short: " + s;
};
}

public static void main(String[] args) {
System.out.println(describe("hello"));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
public class ParenthesizedPattern {
static String describe(Object o) {
return switch (o) {
case (String s) -> "string of length " + s.length();
default -> "other";
};
}

public static void main(String[] args) {
System.out.println(describe("hi"));
}
}

这两份源码在 JDK 17 的预览下能编译能跑:

1
2
3
4
5
6
7
$ javac --enable-preview --release 17 ParenthesizedPattern.java GuardedPatternAnd.java
注: 某些输入文件使用预览语言功能。
注: 有关详细信息,请使用 -Xlint:preview 重新编译。
$ java --enable-preview -cp . ParenthesizedPattern
string of length 2
$ java --enable-preview -cp . GuardedPatternAnd
long: hello

到 JDK 25 上,它们连解析都过不去:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ javac ParenthesizedPattern.java
ParenthesizedPattern.java:4: 错误: 非法的类型开始
case (String s) -> "string of length " + s.length();
^
1 个错误

$ javac GuardedPatternAnd.java
GuardedPatternAnd.java:4: 错误: 需要:或->
case String t && t.length() > 3 -> "long: " + t;
^
GuardedPatternAnd.java:4: 错误: 非法的表达式开始
case String t && t.length() > 3 -> "long: " + t;
^
GuardedPatternAnd.java:4: 错误: 需要';'
case String t && t.length() > 3 -> "long: " + t;
^
GuardedPatternAnd.java:4: 错误: 不是语句
case String t && t.length() > 3 -> "long: " + t;
^
4 个错误

注意报错的样子:删除语法留下的是一串语法解析错误,没有「这个语法已被移除,请改用 when」这样的提示。这是预览功能的一项隐性成本——用预览语法写的代码,在语法被删掉之后连错误信息都不会友好

null 那条规则的回退同样可以直接看到。JDK 18 预览里 total pattern 能接住 null,JDK 19 之后不能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class TotalPatternNull {

static String f(Object o) {
return switch (o) {
case Object x -> "matched object: " + x;
};
}

public static void main(String[] args) {
System.out.println(f("x"));
try {
System.out.println(f(null));
} catch (RuntimeException e) {
System.out.println("f(null) threw " + e);
}
}
}
1
2
matched object: x
f(null) threw java.lang.NullPointerException

case Object xObject 选择器是无条件模式,但它接不住 null(JDK 21 与 JDK 25 实测结果相同)。JEP 441 的最终表述是「With a case null, the switch executes the code associated with that label; without a case null, the switch throws NullPointerException, just as before」,并且补了一句「To maintain backward compatibility with the current semantics of switch, the default label does not match a null selector」——default 也不吃 null

第四轮 JEP 433(JDK 20):MatchException 与文法简化

JEP 433 的 History 列了三处改动:

  • 「An exhaustive switch … over an enum class now throws MatchException rather than IncompatibleClassChangeError if no switch label applies at run time.」
  • 「The grammar for switch labels is simpler.」
  • 「Inference of type arguments for generic record patterns is now supported in switch expressions and statements, along with the other constructs that support patterns.」

第一条是不兼容改动——JEP 433 自己写的是「This is a minor incompatible change to the language」,理由是「To align with pattern switch semantics」。为什么 enum switch 会有一个运行期才会触发的兜底分支?因为枚举随时可能新增常量:已经编译好的穷尽 switch 在遇到新常量时会落到编译器合成的兜底分支上。JEP 433 对它的描述是「An exhaustive switch over an enum fails to match only if the enum class is changed after the switch has been compiled, which is highly …」。

这件事可以在本机完整复现:先按两个常量的枚举编译调用方,再把枚举扩成三个常量并只重编译枚举,最后运行那份旧的调用方 class。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// EnumSwitchMain.java:依赖同目录的 Color.java,此处编译时 Color 只有 RED、GREEN 两个常量
public class EnumSwitchMain {
static String name(Color c) {
return switch (c) {
case RED -> "red";
case GREEN -> "green";
};
}

public static void main(String[] args) {
System.out.println(name(Color.RED));
System.out.println(name(Color.valueOf(args[0])));
}
}

Color 换成 public enum Color { RED, GREEN, BLUE } 重新编译后,运行同一个 EnumSwitchMain.class,只换编译时的 --release

1
2
3
4
5
6
7
8
9
10
11
12
13
--- --release 20 ---
red
green
red
Exception in thread "main" java.lang.IncompatibleClassChangeError
at EnumSwitchMain.name(EnumSwitchMain.java:4)

--- --release 21 ---
red
green
red
Exception in thread "main" java.lang.MatchException
at EnumSwitchMain.name(EnumSwitchMain.java:4)

JDK 20 语言级别编译出来的兜底分支抛 IncompatibleClassChangeError,JDK 21 语言级别抛 MatchException。这就是 JEP 433 那句话在运行时的样子(异常里没有消息文本,只有类型和栈帧,因为它是编译器合成的兜底)。

第二条「文法简化」是那种不会出现在 release note 里、但读者能感觉到的变化:case null, String s -> 这类混合标签的写法被重新规整过。第三条泛型推导的收益在下面的记录模式部分一起看。

记录模式:两个预览、一个撤回的语法

记录模式的版本线和 switch 模式匹配是交织的,JEP 441 里专门提到两者「has co-evolved … with which it has considerable interaction」。

  • JEP 405(JDK 19)首次预览,当时记录模式还可以具名,写成 Point(var x, var y) p
  • JEP 432(JDK 20)第二次预览,History 里三条改动:加泛型推导、允许出现在增强 for 头部、移除具名记录模式
  • JEP 440(JDK 21)定稿。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class RecordPattern {

record Point(int x, int y) {}
record Line(Point from, Point to) {}
record Box<T>(T value) {}
record Pair<A, B>(A a, B b) {}

static int manhattanDistance(Line line) {
return switch (line) {
case Line(Point(var x1, var y1), Point(var x2, var y2)) ->
Math.abs(x1 - x2) + Math.abs(y1 - y2);
};
}

static String unwrap(Object o) {
return switch (o) {
case Box(Box(var inner)) -> "nested box, inner=" + inner;
case Box(var v) -> "flat box, value=" + v;
default -> "not a box";
};
}

public static void main(String[] args) {
System.out.println("distance=" + manhattanDistance(new Line(new Point(0, 0), new Point(3, 4))));
System.out.println(unwrap(new Box<>(new Box<>(42))));
System.out.println(unwrap(new Box<>("text")));
System.out.println(new Pair<>("left", 2).equals(new Pair<>("left", 2)) ? "pair equals works" : "?");
}
}
1
2
3
4
distance=7
nested box, inner=42
flat box, value=text
pair equals works

case Line(Point(var x1, var y1), Point(var x2, var y2)) 一次拆到叶子;case Box(Box(var inner)) 里两个 Box 的类型参数都不需要写,靠 JEP 432 引入的推导——这是 JDK 19 预览时做不到的。

被移除的具名记录模式,在 JDK 25 上是一条硬邦邦的语法错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class NamedRecordPattern {
record Point(int x, int y) {}

static int sumX(Object o) {
return switch (o) {
case Point(var x, var y) p -> x;
default -> 0;
};
}

public static void main(String[] args) {
System.out.println(sumX(new Point(3, 4)));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ javac NamedRecordPattern.java
NamedRecordPattern.java:6: 错误: 需要')'或','
case Point(var x, var y) p -> x;
^
NamedRecordPattern.java:6: 错误: 不是语句
case Point(var x, var y) p -> x;
^
NamedRecordPattern.java:6: 错误: 需要';'
case Point(var x, var y) p -> x;
^
NamedRecordPattern.java:6: 错误: 需要';'
case Point(var x, var y) p -> x;
^
NamedRecordPattern.java:6: 错误: 不是语句
case Point(var x, var y) p -> x;
^
5 个错误

更短命的是「记录模式放在增强 for 头部」这个语法:JEP 432 在 JDK 20 加入它,JEP 440 在定稿时把它删掉,History 的说法是「the main change since the second preview is to remove support for record patterns appearing in the header of an enhanced for statement. This feature may be re-proposed in a future JEP.」——只活了一个版本

1
2
3
4
5
6
7
8
9
10
11
12
13
import java.util.List;

public class RecordPatternInFor {
record Point(int x, int y) {}

public static void main(String[] args) {
int sum = 0;
for (Point(var x, var y) : List.of(new Point(1, 2), new Point(3, 4))) {
sum += x + y;
}
System.out.println("sum=" + sum);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ javac RecordPatternInFor.java     # JDK 21
RecordPatternInFor.java:8: 错误: 需要')'或','
for (Point(var x, var y) : List.of(new Point(1, 2), new Point(3, 4))) {
^
RecordPatternInFor.java:8: 错误: 需要';'
for (Point(var x, var y) : List.of(new Point(1, 2), new Point(3, 4))) {
^
RecordPatternInFor.java:8: 错误: 不是语句
for (Point(var x, var y) : List.of(new Point(1, 2), new Point(3, 4))) {
^
RecordPatternInFor.java:8: 错误: 需要')'
for (Point(var x, var y) : List.of(new Point(1, 2), new Point(3, 4))) {
^
RecordPatternInFor.java:8: 错误: 不是语句
for (Point(var x, var y) : List.of(new Point(1, 2), new Point(3, 4))) {
^
RecordPatternInFor.java:8: 错误: 需要';'
for (Point(var x, var y) : List.of(new Point(1, 2), new Point(3, 4))) {
^
RecordPatternInFor.java:8: 错误: 需要';'
for (Point(var x, var y) : List.of(new Point(1, 2), new Point(3, 4))) {
^
7 个错误

JEP 432 里这个语法是有正式语义的(等价于引入一个临时变量再对每个元素做模式匹配),但它在定稿前被判定为「不必现在要」。这类撤回对语言设计是好事,对使用者是坏事:如果有人在 JDK 20 上写了它,升级到 21 就要改代码。

定稿:JEP 441 与 JEP 440(JDK 21)

删掉括号模式

JEP 441 的 History 只有两条实质改动,第一条是「Remove parenthesized patterns, since they did not have sufficient value」。

把三轮连起来看:JEP 406 引入括号模式的理由是「to resolve some parsing ambiguities」,它服务的是 p && b 那套守卫语法;JEP 427 把守卫改成 when 之后,括号模式失去存在理由,JEP 441 以「价值不足」把它删掉。前面那两个对照实验(JDK 17 能编译、JDK 25 报「非法的类型开始」)就是这条链条的两端。

允许限定枚举常量

第二条改动是「Allow qualified enum constants as case constants in switch expressions and statements」。JEP 441 对背景的说明是:

It has long been a requirement that, when switching over an enum type, the only valid case constants were enum constants. But this is a strong requirement that becomes burdensome with the new, richer forms of switch.

To maintain compatibility with existing Java code, when switching over an enum type a case constant can still use the simple name of a constant of the enum type being switched over.

旧写法(枚举选择器 + 常量简名)必须继续可用;新增的是「限定名可以出现在任何选择器类型上,只要赋值兼容」。JEP 441 给的例子可以直接跑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class QualifiedEnumConstant {

sealed interface Currency permits Coin {}
enum Coin implements Currency { HEADS, TAILS }

static String good1(Currency c) {
return switch (c) {
case Coin.HEADS -> "Heads";
case Coin.TAILS -> "Tails";
};
}

static String good2(Coin c) {
return switch (c) {
case HEADS -> "Heads";
case Coin.TAILS -> "Tails";
};
}

public static void main(String[] args) {
System.out.println(good1(Coin.HEADS) + " / " + good1(Coin.TAILS));
System.out.println(good2(Coin.HEADS) + " / " + good2(Coin.TAILS));
}
}
1
2
Heads / Tails
Heads / Tails

选择器是接口类型 Currency 时,限定名 Coin.HEADS 合法;而简名只在选择器就是这个枚举类型时才合法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class UnqualifiedEnumConstant {

sealed interface Currency permits Coin {}
enum Coin implements Currency { HEADS, TAILS }

static String bad(Currency c) {
return switch (c) {
case Coin.HEADS -> "Heads";
case TAILS -> "Tails";
default -> "Some currency";
};
}

public static void main(String[] args) {
System.out.println(bad(Coin.HEADS));
}
}
1
2
3
4
5
6
7
$ javac UnqualifiedEnumConstant.java
UnqualifiedEnumConstant.java:9: 错误: 找不到符号
case TAILS -> "Tails";
^
符号: 变量 TAILS
位置: 类 UnqualifiedEnumConstant
1 个错误

switch 一旦能处理任意类型,枚举常量就不再天然属于某一种选择器类型,标签合法性、穷尽性判断、导入规则都要跟着重算。

支配与穷尽性:编译器替人守规矩

JEP 441 把「支配(dominance)」这条规则讲得最清楚:

It is a compile-time error for a case label in a switch block to be dominated by any preceding case label in that switch block. This dominance requirement ensures that if a switch block contains only type pattern case labels, they will appear in subtype order.

以及一条排序建议:

All of this suggests a simple, predictable, and readable ordering of case labels in which the constant case labels should appear before the guarded pattern case labels, and those should appear before the unguarded pattern case labels.

实测三条编译器检查,先看模式之间的支配:

1
2
3
4
5
6
7
8
9
10
11
12
public class Dominance {
static String describe(Object o) {
return switch (o) {
case Object obj -> "any object: " + obj;
case String s -> "string " + s;
};
}

public static void main(String[] args) {
System.out.println(describe("x"));
}
}
1
2
3
4
5
$ javac Dominance.java
Dominance.java:5: 错误: 此 case 标签由前一个 case 标签支配
case String s -> "string " + s;
^
1 个错误

常量标签排在无守卫模式之后,同样被拒绝(JDK 21 与 JDK 25 报错文本一致):

1
2
3
4
5
6
7
8
9
10
11
12
13
public class DominatedConstant {
static String classify(String s) {
return switch (s) {
case String t -> "any string";
case "hello" -> "exactly hello";
case null -> "null";
};
}

public static void main(String[] args) {
System.out.println(classify("hello"));
}
}
1
2
3
4
5
$ javac DominatedConstant.java
DominatedConstant.java:5: 错误: 此 case 标签由前一个 case 标签支配
case "hello" -> "exactly hello";
^
1 个错误

穷尽性则是支配的镜像——支配保证”每个标签都有用”,穷尽保证”没有取值漏网”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class NotExhaustive {
sealed interface Shape permits Circle, Square, Rect {}
record Circle(double radius) implements Shape {}
record Square(double side) implements Shape {}
record Rect(double w, double h) implements Shape {}

static double area(Shape s) {
return switch (s) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Square sq -> sq.side() * sq.side();
};
}

public static void main(String[] args) {
System.out.println(area(new Circle(1)));
}
}
1
2
3
4
5
$ javac NotExhaustive.java
NotExhaustive.java:8: 错误: switch 表达式不包含所有可能的输入值
return switch (s) {
^
1 个错误

守卫让支配分析变得不可判定,JEP 441 对此的处理是:如果守卫是常量 true 才认为它能支配别的标签,此外一概不分析,理由是「a problem which is undecidable in general」。规范里还把守卫模式支配常量标签写成了规则(「A guarded pattern case label dominates a constant case label if the same pattern case label without the guard does」),不过按这份文本去试,实测 JDK 21 与 JDK 25 都接受「守卫模式在前、常量在后」的写法,只有无守卫模式在前时才会报支配错误。规范文本与实现之间的这点出入,写代码时不必指望编译器拦住。

null 的最终规则

null 的处理在四个版本里来回改,最终定下来的是 JDK 21 这套:

  • case null 就匹配,没有就抛 NullPointerException(JEP 441:「A switch block without a case null label is treated as if it has a case null rule whose body throws NullPointerException」);
  • default 不匹配 null
  • 可以把 nulldefault 合成一个标签(JEP 441:「It is meaningful, and not uncommon, to want to combine a null case with a default. To that end we allow null case labels to have an optional default」)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class NullDemo {

static String legacy(Object o) {
return switch (o) {
case String s -> "string " + s;
case Integer i -> "int " + i;
default -> "other";
};
}

static String explicit(Object o) {
return switch (o) {
case null -> "matched null";
case String s -> "string " + s;
default -> "other";
};
}

public static void main(String[] args) {
System.out.println("explicit(null) = " + explicit(null));
try {
System.out.println(legacy(null));
} catch (NullPointerException e) {
System.out.println("legacy(null) threw " + e);
}
System.out.println("legacy of plain string: " + legacy("x"));
}
}
1
2
3
explicit(null) = matched null
legacy(null) threw java.lang.NullPointerException
legacy of plain string: string x
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class NullCombined {
static String name(Object o) {
return switch (o) {
case String s -> "string " + s;
case Integer i -> "int " + i;
case null, default -> "not a string or integer: " + o;
};
}

public static void main(String[] args) {
System.out.println(name("x"));
System.out.println(name(7));
System.out.println(name(null));
System.out.println(name(3.5));
}
}
1
2
3
4
string x
int 7
not a string or integer: null
not a string or integer: 3.5

case null, default 在 JDK 21 与 JDK 25 上输出一致。

未命名变量:一次预览就定稿的那次

JEP 443 在 JDK 21 预览了 _(未命名模式与未命名变量),JEP 456 在 JDK 22 定稿,History 只有一句:「We here propose to finalize this feature without change.」——在一串反复修订的 JEP 里,这是唯一一次”零改动定稿”。

JEP 456 规定的可用位置是一张清单:局部变量声明、try-with-resources 的资源说明、基本 for 循环头、增强 for 循环头、catch 的异常参数、lambda 的形式参数;未命名模式则用在类型模式里。它还引出了更早的一段历史:「JEP 302 (Lambda Leftovers) examined the issue of unused lambda parameters and identified the role of underscore to denote them, but also covered many other issues which were handled better in other ways.」——用下划线表示”不用这个参数”的想法至少可以上溯到 JDK 11 的 Lambda Leftovers。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import java.util.List;

public class UnnamedDemo {

record Point(int x, int y) {}
record Line(Point from, Point to) {}

static int parseAll(List<String> inputs) {
int ok = 0;
for (String input : inputs) {
try {
Integer.parseInt(input);
ok++;
} catch (NumberFormatException _) {
System.out.println("skip: " + input);
}
}
return ok;
}

static String shape(Line line) {
return switch (line) {
case Line(Point(var x, var y), Point _) when x == 0 -> "starts on the y axis at y=" + y;
case Line(Point _, Point(var x, var y)) when x == 0 -> "ends on the y axis at y=" + y;
default -> "neither endpoint is on the y axis";
};
}

static int steps = 0;

static int tick() {
return ++steps;
}

public static void main(String[] args) {
System.out.println("parsed=" + parseAll(List.of("1", "x", "3")));
System.out.println(shape(new Line(new Point(0, 5), new Point(1, 1))));
System.out.println(shape(new Line(new Point(1, 5), new Point(0, 9))));
System.out.println(shape(new Line(new Point(2, 5), new Point(1, 1))));
for (int i = 0, _ = tick(); i < 2; i++) {
System.out.println("loop i=" + i);
}
}
}
1
2
3
4
5
6
7
skip: x
parsed=2
starts on the y axis at y=5
ends on the y axis at y=9
neither endpoint is on the y axis
loop i=0
loop i=1

catch (NumberFormatException _)Point _for (int i = 0, _ = tick(); ...) 分别覆盖了异常参数、未命名模式和基本 for 循环头三种位置。

版本差异在这个特性上特别明显,同一份源码:

1
2
3
4
5
6
7
8
9
10
11
import java.util.List;

public class UnderscoreNeedsPreview {
public static void main(String[] args) {
int total = 0;
for (String _ : List.of("a", "b", "c")) {
total++;
}
System.out.println("total=" + total);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ javac UnderscoreNeedsPreview.java          # JDK 21,不加预览开关
UnderscoreNeedsPreview.java:6: 错误: 未命名变量 是预览功能,默认情况下禁用。
for (String _ : List.of("a", "b", "c")) {
^
(请使用 --enable-preview 以启用 未命名变量)
1 个错误

$ javac --enable-preview --release 21 UnderscoreNeedsPreview.java
注: UnderscoreNeedsPreview.java 使用 Java SE 21 的预览功能。
注: 有关详细信息,请使用 -Xlint:preview 重新编译。
$ java --enable-preview -cp . UnderscoreNeedsPreview
total=3

$ javac UnderscoreNeedsPreview.java && java UnderscoreNeedsPreview # JDK 25
total=3

_ 的语义是”声明了但不能用”,这一点也和普通变量不同:

1
2
3
4
5
6
public class UnderscoreName {
public static void main(String[] args) {
int _ = 5;
System.out.println(_);
}
}
1
2
3
4
5
$ javac UnderscoreName.java
UnderscoreName.java:4: 错误: 此处不允许使用下划线
System.out.println(_);
^
1 个错误

JDK 21 上这条报错更啰嗦一些,还会说明 _ 只能用于声明:as of release 21, the underscore keyword '_' is only allowed to declare unnamed patterns, local variables, exception parameters or lambda parameters。措辞本身说明 _ 在 Java 里早就不是自由字符(Java 9 起它就是关键字),所以才能被选来承担这个语义——如果换一个普通标识符,存量代码里已经用了它的地方全都要改。

原始类型模式:五轮预览,仍未定稿

到 JDK 27 为止,这条支线已经预览了五次:

  • JEP 455(JDK 23)首次预览,允许原始类型出现在模式、instanceof 与 switch 里;
  • JEP 488(JDK 24)第二次预览,History:「re-previewed … without change」;
  • JEP 507(JDK 25)第三次预览,同样「without change」;
  • JEP 530(JDK 26)第四次预览,带了改动:「We here propose to preview it for a fourth time with two changes: Enhance the definition of unconditional exactness, and apply tighter dominance checks in switch constructs. These changes enable the compiler to identify a wider range of coding errors, although a small number of switch constructs that were previously legal will now be rejected.」
  • JEP 532(JDK 27)第五次预览,「without change」。

JEP 530 的两条改动都发生在编译器的判定逻辑里:unconditional exactness(转换是否无需运行期检查就能保证无损)的定义被加强,支配检查跟着变严——带来的后果是「少量以前合法的 switch 写法会被拒绝」。这属于语言设计里最难缠的一类改动:把以前能编译的老代码判为错误。

JEP 455 的 Motivation 把要缓解的痛点列得很具体:switch 只接受 byte/short/char/intbooleanfloatdoublelong 都不行;instanceof 只接受引用类型;手工写的范围检查(if (i >= -128 && i <= 127) 然后再强转)持续了近三十年;而 intfloat 这种可能丢精度的转换却能在赋值里悄悄发生。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class PrimitivePreview {

static String describe(int i) {
return switch (i) {
case 0 -> "zero";
case int x when x < 0 -> "negative";
case int x -> "positive " + x;
};
}

static String label(boolean loggedIn) {
return switch (loggedIn) {
case true -> "user";
case false -> "anonymous";
};
}

public static void main(String[] args) {
System.out.println(describe(-3) + " / " + describe(0) + " / " + describe(7));

int i = 200;
if (i instanceof byte b) {
System.out.println("fits in a byte: " + b);
} else {
System.out.println(i + " does not fit in a byte");
}
System.out.println("fits after shrinking: " + (42 instanceof byte));

long v = 20_000_000_000L;
System.out.println(switch (v) {
case 10_000_000_000L -> "ten billion";
case long x -> "some other long " + x;
});
System.out.println("label=" + label(true) + "/" + label(false));
}
}

上面这份源码在 JDK 25 上(第三次预览)实测:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ javac PrimitiveNeedsPreview.java      # 同样的写法,只是不加 --enable-preview
PrimitiveNeedsPreview.java:5: 错误: 基元模式 是预览功能,默认情况下禁用。
case int x when x < 0 -> "negative";
^
(请使用 --enable-preview 以启用 基元模式)
1 个错误

$ javac --enable-preview --release 25 PrimitivePreview.java
注: PrimitivePreview.java 使用 Java SE 25 的预览功能。
注: 有关详细信息,请使用 -Xlint:preview 重新编译。
$ java --enable-preview -cp . PrimitivePreview
negative / zero / positive 7
200 does not fit in a byte
fits after shrinking: true
some other long 20000000000
label=user/anonymous

第一行输出是 switch 上的数值分派,200 does not fit in a byteinstanceof byte b 做的无损转换判定(200 装不进 byte,模式不匹配),最后一行是布尔 switch。

而在 JDK 21 上,即使打开预览开关也过不去——那时这个特性还不存在:

1
2
3
4
5
6
$ javac --enable-preview --release 21 PrimitivePreview.java
PrimitivePreview.java:7: 错误: unexpected type
case int x when x < 0 -> "negative";
^
required: class or array
found: int

required: class or array 这句话本身就是 JDK 21 的语言模型:case 标签后面必须是类或数组类型。原始类型模式要动的正是这条最底层的规则,这也解释了为什么它需要五次预览:它要重写”模式适用性、无条件性、匹配”这三个谓语在原始类型上的定义。

设计启示:反复来自哪里

把上面这些改动归类,反复基本上来自四个地方,而不是”设计能力不够”。

**一、存量代码不能坏。**JEP 406 把「Ensure that all existing switch expressions and statements continue to compile with no changes and execute with identical semantics」写进了 Goals;JEP 441 保留 default 不匹配 null 的旧行为;新关键字选用上下文关键字 when(实测 int when = 3; 依然合法);枚举常量简名的旧写法在 JEP 441 里被明确保留;_ 之所以能被选中,是因为它在 Java 9 起就已经是关键字,没有存量代码在用。每一次”看起来优雅”的重命名,都要先过一遍兼容性。

二、语法歧义要现场消除。p && bwhen 取代,官方给的唯一理由是它与布尔表达式产生歧义;括号模式是为消除这类歧义引入的,守卫语法一换它就失去价值,于是在 JEP 441 被删;case null, String s ->case null, default 这些特殊标签形式,都是在”标签语法要保持可读”和”要和旧语法共存”之间磨出来的。JEP 433 那句轻描淡写的「The grammar for switch labels is simpler」,背后是一次文法重排。

**三、有些判定在理论上就很难。**守卫让支配分析变成不可判定问题,JEP 441 直接接受这个限制(只看守卫是否为常量 true);sealed 泛型层次的穷尽性判断在 JEP 420 被修正过一次;MatchException 不是随手挑的异常,它要配合 JEP 433 引入的运行期兜底语义;原始类型模式更是拖了五轮——JEP 530 还要回头加强 unconditional exactness 的定义,代价是「少量以前合法的写法会被拒绝」。

**四、预览机制本身的成本。**JEP 12 把预览特性定义为「fully specified, fully implemented, and yet impermanent」,并提醒「the feature may change subtly in Java SE $N+1」。本文里这样的例子有五个:括号模式、p && b 守卫、具名记录模式、增强 for 头部的记录模式、以及”total pattern 匹配 null”这条运行时规则。它们都在某个预览版本里正式存在过,然后被删掉或改掉,而且删除之后留下的通常只是解析错误。预览特性不要用在需要长期维护的公共 API 上,用在一次性脚本或内部试验里才划算。

总结

  • instanceof 模式用了三轮:JDK 14 预览、15 原样再预览、16 定稿。定稿时取消模式变量隐式 final,并把”恒真的 instanceof 模式”改成编译错误,但这条限制在 JDK 21 又被放宽(实测 --release 17 报错、--release 21 通过)。
  • switch 模式匹配用了四轮预览加一次定稿:JEP 406(17)引入模式、case null、守卫模式 p && b 与括号模式;JEP 420(18)要求常量标签排在守卫模式之前,并细化 sealed 泛型的穷尽性;JEP 427(19)用 when 取代 &&,并把 null 语义退回传统 switch;JEP 433(20)让枚举穷尽 switch 改抛 MatchException(实测 --release 20IncompatibleClassChangeError--release 21MatchException)并简化标签文法;JEP 441(21)定稿。
  • 记录模式用了两轮预览加一次定稿:JEP 405(19)引入具名记录模式,JEP 432(20)加泛型推导、把记录模式放进增强 for 头部、删掉具名记录模式,JEP 440(21)定稿时又把增强 for 头部那个语法删掉:它只活了一个版本。
  • 未命名变量是唯一”一次预览零改动定稿”的:JEP 443(21)→ JEP 456(22);_ 可以声明但不能使用(实测读取 _ 直接报错)。
  • 原始类型模式到 JDK 27 已经五轮预览(455 → 488 → 507 → 530 → 532),仍未定稿;JEP 530 还收紧了支配检查,代价是少量旧写法被拒绝。
  • 定稿后的核心规则:case null 可选、没有则抛 NPE、default 不匹配 nullcase null, default 可合并;标签不能被前面的标签支配,sealed/枚举的 switch 表达式必须穷尽(实测三种报错文本)。
  • 反复的四个来源是兼容存量代码、消除语法歧义、判定逻辑本身的难度,以及预览机制的成本;预览特性虽然能提前用,但语法被删后的报错往往只是解析错误,不适合放进长期维护的公共代码。

参考资料

系列索引:Java 系列,语言特性与运行时的长文集