文字块
文字块这个特性,首先在 JDK 13 中以预览版的形式发布。在 JDK 14 中,改进的文字块再次以预览版的形式发布。最后,文字块在 JDK 15 正式发布。其实文字块的概念很简单,它是一个由多行文字构成的字符串。那既然是字符串,为什么还需要文字块这个新概念呢?文字块和字符串又有什么区别呢?
在编写代码时,我们总是或多或少地要和字符串打交道,而有些字符串就很复杂,里面可能有换行、对齐、转义字符、占位符、连接符等。比如,我们要构造一个简单表示 “Hello,World!” 的 HTML 字符串,就需要自行处理好文本对齐、换行字符、连接符以及双引号的转义字符。这使得代码既不美观、也不简约,一点都不自然。
String stringBlock =
"<!DOCTYPE html>\n" +
"<html>\n" +
" <body>\n" +
" <h1>\"Hello World!\"</h1>\n" +
" </body>\n" +
"</html>\n";
这样的字符串不好写,不好看,也不好读。而文字块就是用来改进这种现状的一个重要尝试。文字块是一个由多行文字构成的字符串,它采用了一个新的形式来表达字符串,目的是为了消除换行、连接符、转义字符的影响,使得文字对齐和必要的占位符更加清晰,达到所见及所得,从而简化多行文字字符串的表达。具体如下代码示例:
String textBlock = """
<!DOCTYPE html>
<html>
<body>
<h1>"Hello World!"</h1>
</body>
</html>
""";
可以看到,换行字符、连接字符、转义字符等这些特殊字符从文本块里消失了。由于文字块不再需要这些额外的特殊字符,因此我们可以直接拷贝、粘贴看到的文字,而不再需要特殊处理。这就是所见即所得。
总结一下,文字块由零个或多个内容字符组成,从开始分隔符开始,到结束分隔符结束。开始分隔符是由三个双引号字符 (“””) ,后面跟着的零个或多个空格,以及行结束符组成的序列。结束分隔符也是一个由三个双引号字符 (“””) 组成的序列。需要注意的是,开始分隔符必须单独成行,因此一个文字块至少有两行代码,并且结束分隔符之前的字符,包括换行符,都属于文字块的有效内容。
实际上,文字块是在编译期进行处理的,并在编译期被转换成了常量字符串,然后就被当作常规的字符串了。因此,文字块也能使用字符串支持的各种 API 和操作方法。
空格缩进
此外,我们还可以通过合理放置尾部空格符的位置来达到文字块空格缩进的功能,因为结束分隔符除了用来结束文字块之外,还参与界定共享的前导空格。如果我们想要文字块的尾部保留空格,文字块还引入了另外一个新的转义字符,‘\s’,空格转义符。空格转义符表示一个空格。
// There are 8 leading white spaces in common
String textBlock = """
........<!DOCTYPE html>
........<html>
........ <body>
........ <h1>"Hello World!"</h1>
........ </body>
........</html>
........""";
// There are 4 leading white spaces in common
String textBlock = """
.... <!DOCTYPE html>
.... <html>
.... <body>
.... <h1>"Hello World!"</h1>
.... </body>
.... </html>
....""";
// 前两行会包含有尾部空格
String textBlock = """
........<!DOCTYPE html> \s
........<html> \s
........ <body>
........ <h1>"Hello World!"</h1>
........ </body>
........</html>
........""";
行终止符
我们知道,编码规范一般都会限定每一行的字节数 ,可如果文字块中某一行的长度超出了这个限制,那该如何表达长段落或者长行呢?针对这种情况,文字块引入了一个新的转义字符,‘\’,换行转义符。换行转义符的意思是,如果转义符号出现在一个行的结束位置,这一行的换行符就会被取缔。注意,Hello 后面的空格会被保留。
String textBlock = """
<!DOCTYPE html>
<html>
<body>
<h1>"Hello \
World!"</h1>
</body>
</html>
""";
档案类
档案类(record)首先在 JDK 14 中以预览版的形式发布。在 JDK 15 中,改进的档案类再次以预览版的形式发布。最后,档案类在 JDK 16正式发布。官方描述为 Java 档案类是用来表示不可变数据的透明载体。
1. 不可变数据
// 普通不可变类,通过final关键字,构造函数赋值,无set方法
public final class Square implements Shape {
@Getter
public final double side;
public Square(double side) {
this.side = side;
}
@Override
public double area() {
return side * side;
}
}
// 档案类,完全等价上面的形式
public record Square(double side) implements Shape {
@Override
public double area() {
return side * side;
}
}
可以看到,最常见的 class 关键字不见了,取而代之的是 record 关键字。record 关键字是 class 关键字的一种特殊表现形式,用来标识档案类。类名 Square 后面还有用小括号括起来的参数,类似一个构造方法。事实上,这种形式就是当作构造方法使用的,并且这个参数名就是等价的私有不可变变量,在档案类里我们可以直接访问这个变量的值。此外,档案类还为这个变量生成了一个访问方法,变量的名称就是访问方法的名称。
Square square = new Square(10);
double side = square.side(); // 访问私有不可变变量
double area = square.area();
总结:如果一个 Java 类一旦实例化就不能再修改,那么用它表述的数据就是不可变数据。Java 档案类就是表述不可变数据的。为了强化“不可变”这一原则,避免面向对象设计的陷阱,Java 档案类还做了以下几点限制:
Java 档案类不支持继承(extends)父类,它的隐含父类是 java.lang.Record。不能继承父类,也就意味着我们不能通过修改父类来影响 Java 档案类的行为。
Java 档案类是个终极(final)类,不支持子类,也不能是抽象类。没有子类,也就意味着我们不能通过修改子类来改变 Java 档案类的行为。
Java 档案类声明的变量是不可变的变量,变量一旦实例化就不能再修改。不能声明可变变量,也不能支持实例初始化的方法。这就保证我们只能使用档案类形式的构造方法,避免额外的初始化对可变性的影响。
Java 档案类不能声明本地(native)方法。
2. 透明载体
实际上,档案类除了自动内置构造方法和不可变数据的读取方法,还自动内置了缺省的 equals、hashCode 以及 toString 方法的实现。这不仅大大减少了代码数量,提高了编码的效率;还减少了编码错误。但是档案类也支持我们替换掉这些默认的实现。
import java.util.Objects;
public record Square(double side) implements Shape {
// 自定义构造函数,但是入参是固定的
public Circle(double side) {
this.side = side;
}
@Override
public double area() {
return side * side;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o instanceof Square other) {
return other.side == this.side;
}
return false;
}
@Override
public int hashCode() {
return Objects.hash(side);
}
@Override
public String toString() {
return String.format("Square[side=%f]", side);
}
@Override
public double side() {
return this.side;
}
}
透明载体的意思,就是档案类内置了缺省实现的方法,这些方法可以直接使用,也可以替换掉。比如,我们需要在构造函数中对传入的参数做校验等场景。
封闭类
封闭类(sealed classes)首先在 JDK 15 中以预览版的形式发布。在 JDK 16 中,改进的封闭类再次以预览版的形式发布。最后,封闭类在 JDK 17 正式发布。
JDK 17 之前的 Java 语言,限制住继承的可扩展性只有两个方法,使用私有类或者 final 修饰符。显而易见,私有类不是公开接口,只能内部使用;而 final 修饰符彻底放弃了可扩展性。要么全开放,要么全封闭,可扩展性只能在两个极端游走。全封闭彻底没有了可扩展性,全开放又面临固有的安全缺陷(一个可扩展的类,子类和父类可能会相互影响,从而导致不可预知的行为),这种二选一的状况有时候很让人抓狂。
JDK 17 之后,有了第三种方法。这个办法,就是使用 Java 的 sealed 关键字。使用类修饰符 sealed 修饰的类是封闭类;使用类修饰符 sealed 修饰的接口是封闭接口。封闭类和封闭接口限制可以扩展或实现它们的其他类或接口,通过把可扩展性的限制放在可以预测和控制的范围内。
1. 声明封闭类
封闭类这个概念,涉及到两种类型的类。第一种是被扩展的父类,称为封闭类,第二种是扩展而来的子类,称为许可类。封闭类的声明使用 sealed 类修饰符,然后在所有的 extends 和 implements 语句之后,使用 permits 指定允许扩展该封闭类的子类。 下面的这个例子中,Shape 是一个封闭类,可以扩展它的子类只有两个,分别为 Circle 和 Square。也就是说,这里定义的形状这个类,只允许有圆形和正方形两个子类。
public abstract sealed class Shape permits Circle, Square {
public final String id;
public Shape(String id) {
this.id = id;
}
public abstract double area();
}
由 permits 关键字指定的许可类,必须和封闭类处于同一模块(module)或者包空间(package)里。如果封闭类和许可类是在同一个模块里,那么它们可以处于不同的包空间里,就像下面的例子。
package com.xl.a;
public abstract sealed class Shape permits com.xl.b.Circle, com.xl.c.Square {
//...
}
如果允许扩展的子类和封闭类在同一个源代码文件里,封闭类可以不使用 permits 语句,Java 编译器将检索源文件,在编译期为封闭类添加上许可的子类。如下这种 Shape 封闭类的声明,运行时和上面效果是一样的。不过,我们应总是使用 permits 语句。这样,代码的阅读者不需要去翻找上下文,节省时间以及少犯错误。
public abstract sealed class Shape {
// ...
public static final class Circle extends Shape {
// ...
}
public static final class Square extends Shape {
// ...
}
}
2. 声明许可类
许可类的声明需要满足下面的三个条件:
- 许可类必须和封闭类处于同一模块(module)或包空间(package)里;
- 许可类必须是封闭类的直接扩展类;
- 许可类必须声明是否继续保持封闭:
- 许可类可以声明为终极类(final)从而关闭扩展性;
- 许可类可以声明为封闭类(sealed)从而延续受限制的扩展性;
- 许可类可以声明为解封类(non-sealed)从而支持不受限制的扩展性。
比如在下面的例子中,许可类 Circle 是一个解封类;许可类 Square 是一个封闭类;许可类 ColoredSquare 是一个终极类;而 ColoredCircle 既不是封闭类,也不是许可类。由于许可类必须是封闭类的直接扩展,因此许可类不具备传递性。因此 ColoredSquare 是 Square 的许可类,但不是 Shape 的许可类。
public abstract sealed class Shape {
// 解封类
public static non-sealed class Circle extends Shape {
// ...
}
// 封闭类
public static sealed class Square extends Shape {
// ...
}
// 终极类
public static final class ColoredSquare extends Square {
// ...
}
public static class ColoredCircle extends Circle {
// ...
}
}
类型匹配
Java 的类型匹配是模式匹配的一个规范。类型匹配这个特性,首先在 JDK 14 中以预览版的形式发布。在 JDK 15 里,改进的类型匹配再次以预览版的形式发布。最后,类型匹配在 JDK 16 正式发布。类型匹配能够帮助我们简化使用 instanceof 的逻辑,具体示例如下:
// 之前的匹配逻辑
if (shape instanceof Rectangle) {
Rectangle rect = (Rectangle) shape;
return rect.length == rect.width;
}
// 类型匹配
if (shape instanceof Rectangle rect) {
return rect.length == rect.width;
}
在类型匹配的表达式中,当 shape 变量的类型为 Rectangle 时,rect 变量才会被赋值。这个变量的作用域在整个大括号范围内有效。但如果对类型匹配表达式取反,则 rect 变量的作用域在大括号之外。
// rect变量作用域在大括号范围内
public static boolean isSquareImplA(Shape shape) {
if (shape instanceof Rectangle rect) {
// rect is in scope
return rect.length() == rect.width();
}
// rect is not in scope here
return shape instanceof Square;
}
// rect变量作用域在大括号范围外
public static boolean isSquareImplB(Shape shape) {
if (!(shape instanceof Rectangle rect)) {
// rect is not in scope here
return shape instanceof Square;
}
// rect is in scope
return rect.length() == rect.width();
}
switch 表达式
switch 表达式这个特性,首先在 JDK 12 中以预览版的形式发布。在 JDK 13 中,改进的 switch 表达式再次以预览版的形式发布。最后,switch 表达式在 JDK 14 正式发布。
传统 switch 语句使用示例:
public class DaysInMonth {
public static void main(String[] args) {
Calendar today = Calendar.getInstance();
int month = today.get(Calendar.MONTH);
int year = today.get(Calendar.YEAR);
int daysInMonth;
switch (month) {
case Calendar.JANUARY:
case Calendar.MARCH:
case Calendar.MAY:
case Calendar.JULY:
case Calendar.AUGUST:
case Calendar.OCTOBER:
case Calendar.DECEMBER:
daysInMonth = 31;
break;
case Calendar.APRIL:
case Calendar.JUNE:
case Calendar.SEPTEMBER:
case Calendar.NOVEMBER:
daysInMonth = 30;
break;
case Calendar.FEBRUARY:
if (((year % 4 == 0) && !(year % 100 == 0)) || (year % 400 == 0)) {
daysInMonth = 29;
} else {
daysInMonth = 28;
}
break;
default:
throw new RuntimeException("Calendar in JDK does not work");
}
}
}
这段代码里有两个容易犯错误的地方。第一个就是在 break 关键字的使用上,上面的代码里,如果多使用一个 break 关键字,代码的逻辑就会发生变化;同样少使用一个 break 关键字也会出现问题。由于我们想要复用部分代码逻辑,因此需要反复查验 break 语句的前后语境。毫无疑问,这增加了代码维护的成本,降低了生产效率。
第二个容易犯错的地方,是反复出现的赋值语句。 在上面的代码中,daysInMonth 这个本地变量的变量声明和实际赋值是分开的。赋值语句需要反复出现,以适应不同的情景。如果在 switch 语句里,daysInMonth 变量没有被赋值,编译器也不会报错,缺省的或初始的变量值就会被使用。为了避免这种情况,我们需要通览整个 switch 语句块,确保赋值没有遗漏。这增加了编码出错的几率,也增加了阅读代码的成本。
switch 表达式
对于这种多情景处理的代码块,就催生了 Java 语言的新特性:switch 表达式。下面的这段代码,使用的就是新版的 switch 表达式,它对上面的示例代码进行了改进。
public class DaysInMonth {
public static void main(String[] args) {
Calendar today = Calendar.getInstance();
int month = today.get(Calendar.MONTH);
int year = today.get(Calendar.YEAR);
int daysInMonth = switch (month) {
case Calendar.JANUARY,
Calendar.MARCH,
Calendar.MAY,
Calendar.JULY,
Calendar.AUGUST,
Calendar.OCTOBER,
Calendar.DECEMBER -> 31;
case Calendar.APRIL,
Calendar.JUNE,
Calendar.SEPTEMBER,
Calendar.NOVEMBER -> 30;
case Calendar.FEBRUARY -> {
if (((year % 4 == 0) && !(year % 100 == 0)) || (year % 400 == 0)) {
yield 29;
} else {
yield 28;
}
}
default -> throw new RuntimeException("Calendar in JDK does not work");
};
}
}
可以看到,switch 代码块出现在了赋值运算符的右侧。这也意味着,这个 switch 代码块表示的是一个数值或是一个变量。换句话说,这个 switch 代码块是一个表达式。其次,是多情景的合并,即一个 case 语句可以处理多个情景。这些情景使用逗号分隔开来,共享一个代码块。而传统的 switch 语句一个 case 只能处理一种情景。
下一个变化,是一个新的操作符 “->”,它是一个箭头标识符,用来代替传统的 switch 代码中的冒号标识符。这主要是出于简化代码的考虑。我们依然可以在 switch 表达式里使用冒号标识符,但使用冒号标识符的一个 case 语句只能匹配一个情景。箭头标识符右侧的数值代表的就是该匹配情景下,switch 表达式的数值。注意,箭头标识符右侧可以是表达式、代码块或者异常抛出语句,而不能是其他的形式。
最后一个变化就是出现了一个新的关键字 yield。通常,switch 表达式箭头标识符的右侧是一个数值或者是一个表达式。 如果需要一个或者多个语句,我们就要使用代码块的形式。这时候,我们就需要引入一个新的 yield 语句来产生一个值,这个值就成为这个封闭代码块代表的数值。我们可以把 yield 语句产生的值看成是 switch 表达式的返回值。所以 yield 只能用在 switch 表达式里,而不能用在 switch 语句里。