JEP 488: Primitive Types in Patterns, instanceof, and switch (Second Preview) | 模式、instanceof 和 switch 中的基本类型(第二次预览)
摘要
通过允许在所有模式上下文中使用原始类型来增强模式匹配,并扩展 instanceof
和 switch
以支持所有原始类型。这是 预览语言特性。
历史
此特性最初由 JEP 455 提出,并作为预览特性在 JDK 23 中交付。我们在此提议第二次预览该特性,不做更改。
目标
通过对所有类型(无论是原始类型还是引用类型)启用类型模式,实现统一的数据探索。
将类型模式与
instanceof
对齐,并将instanceof
与安全转换对齐。允许模式匹配在嵌套和顶级模式上下文中使用原始类型。
提供易于使用的结构,消除由于不安全的类型转换而丢失信息的风险。
继承 Java 5 (枚举
switch
) 和 Java 7 (字符串switch
) 中对switch
的改进,允许switch
处理任何原始类型的值。
非目标
- 添加新的转换到 Java 语言不是目标之一。
动机
关于原始类型的多个限制在使用模式匹配、instanceof
和 switch
时带来了不便。消除这些限制将使 Java 语言更加统一和更具表现力。
switch
的模式匹配不支持原始类型模式
第一个限制是 switch
(JEP 441) 的模式匹配不支持原始类型模式,即指定原始类型的类型模式。只支持指定引用类型的类型模式,例如 case Integer i
或 case String s
。(自 Java 21 起,记录模式 (JEP 440) 也支持用于 switch
。)
使用 switch
中对原始类型模式的支持,我们可以改进 switch
表达式
switch (x.getStatus()) {
case 0 -> "okay";
case 1 -> "warning";
case 2 -> "error";
default -> "unknown status: " + x.getStatus();
}
通过将 default
子句变为带有暴露匹配值的原始类型模式的 case
子句:
switch (x.getStatus()) {
case 0 -> "okay";
case 1 -> "warning";
case 2 -> "error";
case int i -> "unknown status: " + i;
}
支持原始类型模式还将允许守卫检查匹配的值:
switch (x.getYearlyFlights()) {
case 0 -> ...;
case 1 -> ...;
case 2 -> issueDiscount();
case int i when i >= 100 -> issueGoldCard();
case int i -> ... appropriate action when i > 2 && i < 100 ...
}
记录模式对原始类型的支持有限
另一个限制是记录模式对原始类型的支持有限。记录模式通过将记录分解为其单独的组件来简化数据处理。当组件是一个原始值时,记录模式必须精确地确定值的类型。这给开发人员带来了不便,并且与其他有助于自动转换的 Java 语言部分不一致。
例如,假设我们希望处理通过这些记录类表示的 JSON 数据:
sealed interface JsonValue {
record JsonString(String s) implements JsonValue { }
record JsonNumber(double d) implements JsonValue { }
record JsonObject(Map<String, JsonValue> map) implements JsonValue { }
}
JSON 不区分整数和非整数,因此 JsonNumber
使用 double
组件表示数字以获得最大的灵活性。然而,在创建 JsonNumber
记录时,我们不需要传递一个 double
;我们可以传递一个 int
,如 30
,Java 编译器会自动将 int
扩展为 double
:
var json = new JsonObject(Map.of("name", new JsonString("John"),
"age", new JsonNumber(30)));
不幸的是,如果我们希望使用记录模式分解 JsonNumber
,Java 编译器不会如此配合。由于 JsonNumber
是用 double
组件声明的,我们必须针对 double
分解 JsonNumber
并手动转换为 int
:
if (json instanceof JsonObject(var map)
&& map.get("name") instanceof JsonString(String n)
&& map.get("age") instanceof JsonNumber(double a)) {
int age = (int)a; // 不可避免(并且可能有损!)的类型转换
}
换句话说,原始类型模式可以嵌套在记录模式内,但它们是不变的:模式中的原始类型必须与记录组件的原始类型相同。不可能通过 instanceof JsonNumber(int age)
分解 JsonNumber
并让编译器自动将 double
组件缩小到 int
。
这种限制的原因在于缩小可能是有损的:运行时 double
组件的值对于 int
变量来说可能太大或精度太高。然而,模式匹配的一个关键好处是它能自动拒绝非法值,只需简单地不进行匹配。如果 JsonNumber
的 double
组件太大或太精确以至于无法安全缩小到 int
,那么 instanceof JsonNumber(int age)
只需返回 false
,程序将在另一个分支中处理较大的 double
组件。
使用原始类型模式的支持,我们可以解除此限制。模式匹配可以在顶级以及嵌套在记录模式内部时保护将值缩小到原始类型的可能有损的转换。由于任何 double
都可以转换为 int
,原始类型模式 int a
将适用于 JsonNumber
类型 double
的对应组件。只有当 double
组件可以无信息损失地转换为 int
时,instanceof
才会匹配模式并执行 if
分支,局部变量 a
将在范围内:
if (json instanceof JsonObject(var map)
&& map.get("name") instanceof JsonString(String n)
&& map.get("age") instanceof JsonNumber(int a)) {
... n ...
... a ...
}
这将使得嵌套的原始类型模式能够像嵌套的引用类型模式一样顺畅工作。
instanceof
模式匹配不支持原始类型模式
另一个限制是,instanceof
的模式匹配(JEP 394)不支持原始类型模式。仅支持指定引用类型的模式。(自 Java 21 起,instanceof
也支持记录模式。)
原始类型模式在 instanceof
中就像在 switch
语句中一样有用。instanceof
的目的,广义上讲,是为了测试一个值是否可以安全地转换为给定类型;这就是为什么我们总是看到 instanceof
和类型转换操作紧密相连。由于将原始值从一种类型转换为另一种类型时可能出现的信息丢失,这种测试对于原始类型来说至关重要。
例如,即使可能有信息丢失,int
值到 float
的转换也可以通过赋值语句自动执行,并且开发者不会收到任何警告:
int getPopulation() {...}
float pop = getPopulation(); // 潜在的信息丢失是静默的
同时,将 int
值转换为 byte
需要显式转换,但由于转换可能是损失性的,因此必须先进行繁琐的范围检查:
if (i >= -128 && i <= 127) {
byte b = (byte)i;
... b ...
}
如果 instanceof
支持原始类型模式,则可以取代 Java 语言内置的损失性转换,并避免开发人员近三十年来手工编写的细致入微的范围检查。换句话说,instanceof
不仅可以检查类型,还可以检查值。上述两个例子可以重写为:
if (getPopulation() instanceof float pop) {
... pop ...
}
if (i instanceof byte b) {
... b ...
}
instanceof
运算符结合了赋值语句的便捷性和模式匹配的安全性。如果输入(如 getPopulation()
或 i
)可以安全地转换为原始类型模式中的类型,则模式匹配成功,并立即提供转换结果(如 pop
或 b
)。但是,如果转换会导致信息丢失,则模式不匹配,程序应在不同的分支处理无效输入。
在 instanceof
和 switch
中使用原始类型
如果我们打算解除对原始类型模式的限制,那么解除相关的一个限制也会有所帮助:当 instanceof
接受一个类型而非模式时,它只接受引用类型,而不接受原始类型。当接受一个原始类型时,instanceof
会检查转换是否安全,但不会实际执行转换:
if (i instanceof byte) { // i的值适合byte
... (byte)i ... // 需要传统的类型转换
}
这一改进使 instanceof T
和 instanceof T t
的语义重新对齐,否则允许在一个上下文中使用原始类型而在另一个上下文中不允许就会破坏这种对齐。
最后,解除 switch
只能处理 byte
、short
、char
和 int
值而不能处理 boolean
、float
、double
或 long
值的限制也将是有帮助的。
基于 boolean
值的切换将是三元条件运算符 (?:
) 的一种有用的替代方式,因为 boolean
开关不仅能包含表达式也能包含语句。例如,下面的代码使用 boolean
开关在 false
情况下执行一些日志记录:
startProcessing(OrderStatus.NEW, switch (user.isLoggedIn()) {
case true -> user.id();
case false -> { log("未识别的用户"); yield -1; }
});
基于 long
值的切换将允许 case
标签成为 long
常量,从而无需用单独的 if
语句处理非常大的常量:
long v = ...;
switch (v) {
case 1L -> ...;
case 2L -> ...;
case 10_000_000_000L -> ...;
case 20_000_000_000L -> ...;
case long x -> ... x ...;
}
描述
在 Java 21 中,原始类型模式仅允许作为记录模式中的嵌套模式使用,例如:
v instanceof JsonNumber(double a)
为了支持通过模式匹配更统一地探索匹配候选者v
的数据,我们将:
扩展模式匹配,使原始类型模式适用于更广泛的匹配候选类型。这将允许表达式如
v instanceof JsonNumber(int age)
。增强
instanceof
和switch
结构以支持作为顶级模式的原始类型模式。进一步增强
instanceof
结构,使其在用于类型测试而非模式匹配时,能够针对所有类型进行测试,而不仅仅是引用类型。这将扩展instanceof
当前在引用类型上预条件安全转换的角色,应用于所有类型。更广泛地说,这意味着
instanceof
可以保障所有的转换,不论是匹配候选者的类型被测试(例如,x instanceof int
或y instanceof String
)还是其值被匹配(例如,x instanceof int i
或y instanceof String s
)。进一步增强
switch
结构,使其不仅限于处理部分基本原始类型,而是适用于所有原始类型。
我们通过修改少量管理原始类型使用的 Java 语言规则,并确定一种类型到另一种类型的转换何时是安全的来实现这些变化——这涉及到要转换的值以及转换的源和目标类型的知识。
转换的安全性
如果转换不会导致信息丢失,则认为该转换是精确的。转换是否精确取决于所涉及的类型对以及输入值:
对于某些类型对,在编译时就知道从第一种类型转换为第二种类型对于任何值都不会丢失信息。这种转换被称为无条件精确。对于无条件精确的转换,在运行时不需要任何操作。示例包括
byte
到int
、int
到long
和String
到Object
。对于其他类型对,需要运行时测试来检查值能否从第一种类型转换为第二种类型而不丢失信息,或者如果执行强制转换的话,不会抛出异常。如果转换不会导致信息丢失或异常,则转换是精确的;否则,转换不精确。可能精确的转换示例包括
long
到int
和int
到float
,其中通过数值相等 (==
) 或表示等价分别在运行时检测精度损失。从Object
到String
的转换也需要运行时测试,转换是否精确取决于输入值动态上是否为String
。
简而言之,如果原始类型之间的转换是从一个整型类型拓宽到另一个,或从一个浮点类型拓宽到另一个,或从byte
、short
或char
拓宽到浮点类型,或从int
拓宽到double
,则该转换是无条件精确的。此外,装箱转换和拓宽引用转换也是无条件精确的。
下表表示了原始类型之间允许的转换。无条件精确的转换用符号ɛ
表示。符号≈
表示恒等转换,ω
表示拓宽原始转换,η
表示缩窄原始转换,ωη
表示拓宽和缩窄原始转换。符号—
表示不允许转换。
到 → | byte | short | char | int | long | float | double | boolean |
---|---|---|---|---|---|---|---|---|
从 ↓ | ||||||||
byte | ≈ | ɛ | ωη | ɛ | ɛ | ɛ | ɛ | — |
short | η | ≈ | η | ɛ | ɛ | ɛ | ɛ | — |
char | η | η | ≈ | ɛ | ɛ | ɛ | ɛ | — |
int | η | η | η | ≈ | ɛ | ω | ɛ | — |
long | η | η | η | η | ≈ | ω | ω | — |
float | η | η | η | η | η | ≈ | ɛ | — |
double | η | η | η | η | η | η | ≈ | — |
boolean | — | — | — | — | — | — | — | ≈ |
将此表与JLS §5.5中的等效表格比较,可以看出许多由 JLS §5.5 中ω
允许的转换在此处“升级”为无条件精确的ɛ
。
instanceof
作为安全转换的前置条件
传统上,instanceof
类型测试仅限于引用类型。instanceof
的经典含义是作为一个前置条件检查,它问的是:将此值转换为此类型是否安全和有用?这个问题对于原始类型来说甚至比引用类型更为相关。对于引用类型,如果偶然遗漏了检查然后执行了一个不安全的转换,通常不会造成危害:会抛出一个 ClassCastException
并且错误转换的值将无法使用。相反,对于原始类型,在没有方便的方法来检查安全性的情况下执行不安全的转换可能会导致细微的错误。而不是抛出异常,它可能会在无声中丢失信息,如幅度、符号或精度,允许错误转换的值流入程序的其余部分。
为了使原始类型能够在 instanceof
类型测试运算符中使用,我们移除了限制(JLS §15.20.2),即左操作数的类型必须是引用类型,并且右操作数必须指定一个引用类型。类型测试运算符变为:
InstanceofExpression:
RelationalExpression instanceof Type
...
在运行时,我们通过精确转换扩展了 instanceof
对原始类型的支持:如果左侧的值可以通过精确转换转换为右侧的类型,则将其值转换为该类型是安全的,instanceof
返回 true
。
以下是扩展后的 instanceof
如何保护类型转换的一些示例。无条件精确转换无论输入值如何都返回 true
;所有其他转换都需要一个运行时测试,其结果如下所示。
byte b = 42;
b instanceof int; // true (无条件精确)
int i = 42;
i instanceof byte; // true (精确)
int i = 1000;
i instanceof byte; // false (不精确)
int i = 16_777_217; // 2^24 + 1
i instanceof float; // false (不精确)
i instanceof double; // true (无条件精确)
i instanceof Integer; // true (无条件精确)
i instanceof Number; // true (无条件精确)
float f = 1000.0f;
f instanceof byte; // false
f instanceof int; // true (精确)
f instanceof double; // true (无条件精确)
double d = 1000.0d;
d instanceof byte; // false
d instanceof int; // true (精确)
d instanceof float; // true (精确)
Integer ii = 1000;
ii instanceof int; // true (精确)
ii instanceof float; // true (精确)
ii instanceof double; // true (精确)
Integer ii = 16_777_217;
ii instanceof float; // false (不精确)
ii instanceof double; // true (精确)
我们并没有向 Java 语言添加任何新的转换,也没有更改现有的转换,更没有改变现有上下文(如赋值)中的哪些转换是允许的。instanceof
是否适用于给定值和类型取决于在类型转换上下文中是否允许转换以及它是否是精确的。例如,如果 b
是一个 boolean
变量,那么 b instanceof char
永远不允许,因为从 boolean
到 char
没有类型转换。
在 instanceof
和 switch
中的原始类型模式
类型模式合并了一个类型测试与一个条件转换。这避免了在类型测试成功时需要显式转换的需求,而未转换的值可以在类型测试失败时在不同的分支中处理。当 instanceof
类型测试运算符只支持引用类型时,只有引用类型模式才被允许用于 instanceof
和 switch
;现在 instanceof
类型测试运算符支持原始类型,自然地允许在 instanceof
和 switch
中使用原始类型模式。
为此,我们取消了原始类型不能用于顶级类型模式的限制。因此,冗长且容易出错的代码
int i = 1000;
if (i instanceof byte) { // false -- i 不能精确转换为 byte
byte b = (byte)i; // 可能存在数据丢失
... b ...
}
可以写成
if (i instanceof byte b) {
... b ... // 没有信息丢失
}
因为 i instanceof byte b
意味着“测试 i instanceof byte
,如果是这样,将 i
转换为 byte
并将该值绑定到 b
”。
类型模式的语义由三个谓词定义:适用性、无条件性和匹配。我们按照以下方式解除对原始类型模式处理的限制:
适用性是指模式在编译时是否合法。以前,原始类型模式的适用性要求匹配候选者与模式中的类型完全相同。例如,
switch (... an int ...) { case double d: ... }
不允许,因为double
模式不适用于int
。现在,如果
U
可以转换为T
而不产生未经检查的警告,则类型模式T t
适用于类型为U
的匹配候选者。由于int
可以转换为double
,所以该switch
现在是合法的。无条件性是指在编译时是否知道适用的模式将在运行时匹配匹配候选者的全部可能值。无条件模式不需要运行时检查。
当我们将原始类型模式的应用范围扩大到更多类型时,我们必须指明它们在哪种类型上是无条件的。如果从
U
到T
的转换是无条件精确的,则原始类型模式对于类型T
来说在类型为U
的匹配候选者上是无条件的。这是因为无论输入值如何,无条件精确转换都是安全的。以前,如果不是
null
引用的值v
匹配类型为T
的类型模式,如果v
可以转换为T
而不抛出ClassCastException
。这种匹配的定义足以应对原始类型模式有限的角色。现在原始类型模式可以广泛使用,匹配被泛化为意味着一个值可以精确转换为T
,这涵盖了抛出ClassCastException
以及潜在的信息丢失。
穷尽性
一个 switch
表达式,或者其 case
标签为模式的 switch
语句,要求是穷尽的:选择器表达式的所有可能值都必须在 switch
块中处理。如果 switch
包含一个无条件类型模式,则它是穷尽的;它也可能由于其他原因而穷尽,例如涵盖了密封类的所有可能允许的子类型。在某些情况下,即使存在不会被任何 case
匹配的可能运行时值,switch
仍可以被认为穷尽;在这种情况下,Java 编译器会插入一个合成的 default
子句来处理这些未预料到的输入。穷尽性在 模式:穷尽性、无条件性和剩余部分 中有更详细的介绍。
随着原始类型模式的引入,我们在确定穷尽性的规则上添加了一条新规则:对于匹配候选者是一个原始类型 P
的包装类型 W
的 switch
,如果 T
在 P
上是无条件精确的,则类型模式 T t
可以穷尽 W
。在这种情况下,null
成为了剩余部分的一部分。在以下示例中,匹配候选者是原始类型 byte
的包装类型,并且从 byte
到 int
的转换是无条件精确的。因此,以下 switch
是穷尽的:
Byte b = ...
switch (b) { // 穷尽的 switch
case int p -> 0;
}
这种行为类似于记录模式的穷尽性处理方式。
正如 switch
使用模式穷尽性来确定 case
是否覆盖所有输入值一样,switch
使用支配关系来确定是否存在任何将不匹配任何输入值的 case
。
如果一种模式匹配另一种模式所匹配的所有值,则前者支配后者。例如,类型模式 Object o
支配类型模式 String s
,因为所有能匹配 String s
的都会同样匹配 Object o
。在 switch
中,如果一个没有保护的类型模式 P
的 case
标签前置了一个类型模式为 Q
的 case
标签,且 P
支配 Q
,则是非法的。支配的含义没有改变:如果类型模式 T t
对类型为 U
的匹配候选者是无条件的,则类型模式 T t
支配类型模式 U u
。
switch
中扩展的原始类型支持
我们增强了 switch
结构,允许选择器表达式的类型为 long
、float
、double
和 boolean
,以及相应的装箱类型。
如果选择器表达式的类型为 long
、float
、double
或 boolean
,则 case
标签中使用的任何常量都必须与选择器表达式的类型或其对应的装箱类型相同。例如,如果选择器表达式的类型为 float
或 Float
,则任何 case
常量都必须是类型为 float
的浮点字面量(JLS §3.10.2)。这个限制是因为 case
常量和选择器表达式之间的不匹配可能导致有损转换,从而破坏程序员意图。下面的 switch
是合法的,但如果常量 0f
被错误地写成 0
,则将是非法的。
float v = ...
switch (v) {
case 0f -> 5f;
case float x when x == 1f -> 6f + x;
case float x -> 7f + x;
}
case
标签中浮点字面量的语义在编译时和运行时都是根据表示等价性定义的。使用两个表示等价的浮点字面量会导致编译时错误。例如,下面的 switch
是非法的,因为字面量 0.999999999f
向上舍入为 1.0f
,创建了重复的 case
标签。
float v = ...
switch (v) {
case 1.0f -> ...
case 0.999999999f -> ... // 错误:重复的标签
default -> ...
}
由于 boolean
类型只有两个不同的值,列出了 true
和 false
情况的 switch
被认为是穷尽的。以下 switch
是合法的,但如果有 default
子句则是非法的。
boolean v = ...
switch (v) {
case true -> ...
case false -> ...
// 或者替代方案: case true, false -> ...
}
未来工作
在规范化 Java 语言的类型比较和模式匹配规则之后,我们可以考虑引入常量模式。目前,在 switch
中,常量只能作为 case 常量出现,例如这段代码中的 42
:
short s = ...
switch (s) {
case 42 -> ...
case int i -> ...
}
常量不能出现在记录模式中,这限制了模式匹配的实用性。例如,以下 switch
是不可能的:
record Box(short s) {}
Box b = ...
switch (b) {
case Box(42) -> ... // Box(42) 不是有效的记录模式
case Box(int i) -> ...
}
得益于这里定义的适用性规则,常量可以被允许出现在记录模式中。在 switch
中,case Box(42)
将意味着 case Box(int i) when i == 42
,因为 42
是类型为 int
的字面量。