本笔记参考自: 《On Java 中文版》
在Java中,“模式匹配”经历过好几个版本的功能扩充。这些扩充和switch关键字密切相关。如果对Java最新的特性感兴趣,可以查看Java增强建议(JEP)。
新特性:switch的箭头语法
switch的箭头语法在JDK 14加入到了Java之中。现在,我们可以通过这种新的语法来使用switch:
【例子:新的switch语句】
import static java.util.stream.IntStream.range;
public class ArrowInSwitch {
static void arrows(int i) {
switch (i) {
case 1 -> System.out.println("......"); // 可以是一条语句
case 2 -> { // 也可以是语句块
// ...
}
default -> System.out.println();
}
}
public static void main(String[] args) {
range(0, 4).forEach(i -> arrows(i));
}
}
这种语法与传统的switch语法的一个区别在于,它舍弃了原本需要写在每个case后面的break。这使得句式整体看上去更加简洁了。
注意:不能在同一个switch中同时使用冒号和箭头。
新特性:switch中的case null
JDK 17的一项预览功能,允许我们在switch语句中使用case null(这原本是非法的)。
import java.util.function.Consumer;
public class CaseNull {
// 原本只能先进行非空判断:
static void old(String s) {
if (s == null) {
System.out.println("null");
return;
}
switch (s) {
case "X" -> System.out.println("X");
default -> System.out.println("default");
}
}
// 预览功能允许我们在switch语句中为null新增一条分支语句
static void checkNull(String s) {
switch (s) {
case "X" -> System.out.println("X");
case null -> System.out.println("null");
default -> System.out.println("default");
}
// 冒号语法也可以使用null了:
switch (s) {
case "X":
System.out.println("X");
break;
case null:
System.out.println("null");
break;
default:
System.out.println("default");
}
}
// 用于测试:
static void test(Consumer<String> cs) {
cs.accept("X");
cs.accept("Y");
try {
cs.accept(null);
} catch (NullPointerException e) {
System.out.println(e.getMessage());
}
}
public static void main(String[] args) {
test(CaseNull::old);
test(CaseNull::checkNull);
}
}
程序执行的结果是:
由于在JDK 17时该功能还是预览状态(JDK 21时已经成为正式特性),因此我们需要将下面的语句和javac配合使用,才能成功编译:
--enable-preview -source 17
值得一提的是,default并不会覆盖null这一情况,因此下面的方法在使用时可能引发报错:
static void defaultOnly(String s) {
switch (s) {
case "X" -> System.out.println("X");
default -> System.out.println("default");
}
}
可以使用例子中的test()方法对其进行测试,会得到如下结果:
switch无法如其设计时预计的那样覆盖所有的可能值,这是Java为了向后兼容做出的让步。
还有一件事,现在的switch语句支持通过逗号合并多种模式。这意味着我们可以做到合并null和default,这样就不会出现上面的这种报错了:
case null, default -> System.out.println("null|default");
不过最新的JDK 21还是做出了一些细微的改动,比如下面的语句在JDK 21中是不合法的(或许是处于安全性的考虑):
新特性:将switch作为表达式使用
JDK 14添加的这项新功能,允许我们将switch作为表达式使用。现在,switch语句也可以返回值了:
【例子:有返回值的switch语句】
public class SwitchExpression {
static int colon(String s) {
var result = switch (s) {
case "i":
yield 1;
case "j":
yield 2;
case "k":
yield 3;
default:
yield 0;
};
return result;
}
static int arrow(String s) {
var result = switch (s) {
case "i" -> 1;
case "j" -> 2;
case "k" -> 3;
default -> 0;
};
return result;
}
public static void main(String[] args) {
for (var s : new String[]{"i", "j", "k", "z"})
System.out.format(
"%s %d %d%n", s, colon(s), arrow(s));
}
}
程序执行的结果是:
如例子所示,现在我们可以通过yield关键字从switch语句中返回一个值。需要注意一点,break不能和yield并用。这是合理的,因为可以假定,当我们使用yield的时候,一定存在一个需要返回值的变量。
不过yield不能这样用:
很显然,yield必须存在于case语句中。
一个case想要包含多条语句,就需要使用大括号。在此之上,若需要返回值,那么即使使用箭头语法,我们也需要在case语句中添加yield。像这样:
在枚举中的实际应用
之前也曾举过“信号灯”的例子(笔记 1-1),现在我们可以使用表达式的形式重写一遍之前的代码:
【例子:重写“信号灯”变化】
public class EnumSwitch {
enum Signal {
GREEN,
YELLOW,
RED
}
static Signal color = Signal.RED;
public static void change() {
color = switch (color) {
case RED -> Signal.GREEN;
case GREEN -> Signal.YELLOW;
case YELLOW -> Signal.RED;
};
}
public static void main(String[] args) {
for (int i = 0; i < 7; i++) {
change();
System.out.println(color);
}
}
}
程序执行的结果是:
编译器会对使用了枚举的switch进行检测。因此若我们没有考虑某条路径,编译器就会报错:
因此,使用枚举可以使我们的switch语句更加安全。
新特性:模式匹配
JEP 394对instanceof的功能进行了增强。虽说官方的名称是“instanceof的模式匹配”,但更准确的说法应该是“模式匹配辅助”。
智能转型
下面的例子将要展示的,是其中用于支持模式匹配的一个特性,它在其他的一些语言中被称为“智能转型”。
【例子:用于支持模式匹配的特性】
public class SmartCasting {
// 旧的写法:
static void dumb(Object x) {
if (x instanceof String) {
String s = (String) x;
if (s.length() > 0) {
System.out.format(
"%d %s%n", s.length(), s.toUpperCase());
}
}
}
// 新的写法(省略了类型转换):
static void smart(Object x) {
if (x instanceof String s && s.length() > 0) {
System.out.format(
"%d %s%n", s.length(), s.toUpperCase());
}
}
// 错误的写法:
static void wrong(Object x) {
// 在这里,“或”这一逻辑并不成立
// 编译器不允许这一写法,因此s会被视作无法解析的符号
// if (x instanceof String s || s.length() > 0) {
// }
}
public static void main(String[] args) {
dumb("dumb");
smart("smart");
}
}
程序执行的结果是:
smart()展示了这一特性的实际应用:一旦确定了类型,我们就可以跳过转型的步骤。在这里,x instanceof String s会自动为我们创建一个新的变量s(s的正式名称应该是【模式变量】)。
这个特性可以被作为模式匹配的构建块使用。
还需注意一点,模式变量的作用域与一般而言的作用域并不完全重合。JEP 394中说明,模式变量的作用域设计来自于流的概念,只有当编译器确定该变量能够被安全赋值时,模式变量才会成立(这也解释了为什么在上述例子中无法使用【||】,因为编译器无法保证程序能够运行到那里)。
这个特性会导致一些看起来十分费解的极端情况:
【例子:奇怪的作用域范围】
public class OddScoping {
static void f(Object o) {
if (!(o instanceof String s)){
System.out.println("这个变量不是String类型");
throw new RuntimeException();
}
// s在(看起来)超出作用域的地方发挥了它的作用
System.out.println(s.toUpperCase());
}
public static void main(String[] args) {
f("Ab aB");
f(null);
}
}
程序执行的结果是:
如果不抛出异常,就会引发报错:
若不抛出异常,就意味着当if语句内部处理完类型不匹配的情况后,程序将会调用类型未知的变量s。编译器无法保证变量s的类型,因此此处的s实际上无法被使用。
因此,也可能会因为这个特性的使用而造成一些费解的Bug。
模式匹配
正如之前所述,“智能替换”是为模式匹配服务的。与继承的多态类似,模式匹配也能够实现基于类型的行为。但与继承不同的是,模式匹配不会要求所有的类型具有相同的接口,或是处于相同的继承结构中。
违反里式替换原则
||| 里式替换原则:所有引用基类的地方必须能透明地使用其子类的对象。
在里式替换原则中,子类全部具有相同相同的基类,并且只会使用公共基类中定义的方法。例如:
【例子:遵守里式替换原则的情况】
import java.util.stream.Stream;
// 符合里式替换原则:子类和接口拥有的方法完全一致
// 定义一个接口:生命具有的行为
interface LifeForm {
String move();
String react();
}
// 子类:蠕虫
class Worm implements LifeForm {
@Override
public String move() {
return "Worm::move()";
}
@Override
public String react() {
return "Worm::react()";
}
}
// 子类:长颈鹿
class Giraffe implements LifeForm {
@Override
public String move() {
return "Giraffe::move()";
}
@Override
public String react() {
return "Giraffe::react()";
}
}
public class NormalLiskov {
public static void main(String[] args) {
Stream.of(new Worm(), new Giraffe())
.forEach(lifeForm -> System.out.println(
lifeForm.move() + " " + lifeForm.react()));
}
}
程序执行的结果是:
这种做法存在一个漏洞:“蠕虫”和“长颈鹿”终究是不同的生物。它们独特的行为(像蠕虫的爬行或长颈鹿的奔跑)难以在基类中进行描述。
Java的集合库就遇到了这样的问题,他们的解决方式是添加一些“可选”的方法,让子类自行决定是否实现(然而,最终得到的成果并不尽人意)。
当然,在实际使用Java中可以发现,Java也会允许我们写出如下的这种代码:
【例子:在继承结构中扩展子类】
public class Pet {
void feed() {
}
}
class Dog extends Pet {
void walk() {
}
}
class Fish extends Pet {
void changeWater() {
}
}
这种程序的设计思路来源于SmallTalk:利用已有的类增加方法,实现代码的复用。然而,这种借鉴是存在局限性的,因为Java是一门基于静态类型的语言,而SmallTalk是动态的。
SmallTalk具有动态类型检查,这意味着在进行类型相关的一些操作时,SmallTalk更不容易引发安全问题。
模式匹配的存在允许Java编写出违反里式替换原则,并且更加安全的代码。下面的例子会为每一个可能的类型进行检测,并且进行不同的处理:
【例子:更加安全的模式匹配】
import java.util.List;
public class PetPatternMatch {
static void careFor(Pet p) {
switch (p) { // 选择器表达式p
case Dog dog -> dog.walk();
case Fish fish -> fish.changeWater();
// case Pet必须存在,用于覆盖所有的可能值
case Pet sp -> sp.feed();
}
}
static void petCare() {
List.of(new Dog(), new Fish())
.forEach(p -> careFor(p));
}
}
在模式匹配出现之前,switch语句只接受基本类型和对应的包装类。换言之,模式匹配扩展了switch语句可以持有的类型范围。
这种做法和动态绑定的不同之处在于,switch将对不同类型的操作交由case语句处理。
事实上,在上面的例子中关于基类Pet的case语句(case Pet)并不是必要的。为了安全性,编译器会要求我们使用Pet覆盖所有的可能值。如果要解释这种行为,是因为Pet也可能被其他的文件使用,甚至在其他文件中存在未知的子类。
换言之,我们可以密封基类接口(使用sealed关键字做到这一点),来证明其的安全性:
【例子:通过密封接口优化程序】
import java.util.List;
// 密封的接口:
sealed interface Pet {
void feed();
}
final class Dog implements Pet {
@Override
public void feed() {
}
void walk() {
}
}
final class Fish implements Pet {
@Override
public void feed() {
}
void changeWater() {
}
}
public class PetPatternMatch2 {
static void careFor(Pet p) {
// sealed关键字可以确保Pet接口只在本文件内使用
// 因此可以保证不会出现未发现的Pet子类
switch (p) {
case Dog d -> d.walk();
case Fish f -> f.changeWater();
}
}
static void petCare() {
List.of(new Dog(), new Fish())
.forEach(p -> careFor(p));
}
}