0%

Java语言新特性漫谈:Java 7篇

本文是《Java语言新特性漫谈》系列文章中的一篇,该系列文章主要探讨各 Java 版本的语言特性方面的增强更新。

都有哪些语言新特性?

Java 7 版本是 Java 的一个里程碑,它包含了大量的更新,相比之下语言类特性反而不算多。

文末的“参考”章节列出了 Java 7 版本发布说明链接,该说明列出了所有 Java 7 更新。

Java 7 的新增的语言特性有以下这些:

  • 二进制字面量
  • 在数值字面量中使用下划线
  • switch 语句支持字符串
  • TWR 语句
  • 多重异常捕获及重抛异常类型检查的改进
  • 用于创建泛型实例的类型推断
  • 可变参数相关的编译警告改进

二进制字面量

在 Java 7 之前,只支持八进制、十进制以及十六进制的数值字面量,而 Java 7 增加了二进制字面量。

二进制字面量可以表示 byteshortintlong 等各种整型值。其表示形式是前缀 0b0B

1
2
3
4
byte aByte = (byte)0b00100001;
short aShort = (short)0b1010000101000101;
int anInt1 = 0b10100001010001011010000101000101;
long aLong = 0b1010000101000101101000010100010110100001010001011010000101000101L;

与其他进制的字面量类似,其默认类型是 int,所以表示 byteshort 型时需要强转,而表示 long 型时需要后缀 L

某些场景下,使用二进制表示数值会更直观,可读性更强。

在数值字面量中使用下划线

在数值字面量中,任何数字间都可以插入一个甚至是多个下划线,用以分组提高可读性。

错误位置

换句说,插入其他位置则可能引发错误,Oracle 文档 列了 4 种错误插入位置:

  • 在一个数的开头或结尾
  • 比邻一个浮点数的小数点
  • FL 后缀前(实际还应包括 D 后缀)
  • 在需要一串数字的位置(In positions where a string of digits is expected)

首先,我们应该了解数值字面量到底有哪些表示形式,常用的数值字面量大致有以下一些:

1
2
3
4
5
6
7
8
9
int x0 = 123456; // 纯数字整型
double x1 = 3.6E3; // 指数计数法
long x2 = 2468013579L; // 长整型
double x3 = 3.1415926; // 双精度
double x4 = 3.1415926D; // 双精度
float x5 = 3.1415926F; // 单精度
int x6 = 0b0101; // 二进制
int x7 = 0520; // 八进制
int x8 = 0x5A2B; // 十六进制

要注意的是,前缀下划线的数值可能被错误地解析为变量名(虽然通常不太可能这样命名),因而不会被视为下划线使用错误,比如:

1
2
int _314 = 520; // _314 是一个合法的标识符,可作为变量名
int i = _314; // _314 被解析为变量名,因此,编译正确,i = 520

如果未声明变量 _314 也不会被认为是一个下划线使用错误,而应是一个“找不到符号”错误,即表示尝试引用一个未声明的变量。

另外,官方文档中未提及的错误插入位置还有两种:

  • 指数计数法的 E 前后
  • 二进制前缀 b 及十六进制前缀 x 前后

注意,在数值字面量中,“数字”这个概念的范围并不限于十进制,它还包括十六进制的 A~F。

也许你会困惑,最后那条“在需要一串数字的位置(In positions where a string of digits is expected)”指的是什么?
Oracle 文档中这条规则是“孤立”的,上下文没有相关阐述以及示例,其他对该特性的说明文档或博客中也未提及该点。
Stack Overflow 中有人提了该问题,但是置顶的回答并没有清楚解答。反而是后续回答中,有一个比较合理的推测。
这可能在说,用于解析的字符串“数值字面量”不应使用下划线。比如,Integer.parseInt("123_456"); 会导致运行时解析错误。

小结

小结一下,以上我们列出了几乎所有的错误插入位置,它们包括:

  • 在一个数的开头或结尾
  • 比邻一个浮点数的小数点
  • F/ L/D 后缀前
  • 指数计数法 E 前后
  • 二进制前缀 b/B 及十六进制前缀 x/X 前后
  • 在需要一串数字的位置(In positions where a string of digits is expected)

就原理而言,该特性只是编译器级别的修改,编译器会在编译时去掉下划线,只保留原始数字。

还有一个问题值得探究:为什么使用下划线作为分隔符?为什么不使用逗号或连字符(-)?因为,这可能引发歧义。

switch 语句支持字符串

switch 语句不支持最常用的字符串类型,一直都受人诟病,Java 7 总算是支持了。

从效率上说,switch 语句生成的字节码,比使用 if-else 语句比较字符串更高效。

TWR 语句

TWR 是 try-with-resources 的首字母缩写。它用于:

  • 自动关闭资源
  • 改善错误跟踪能力

Java 程序中所使用的各种资源是需要关闭的,Java 7 之前通常需要手动关闭,但是,百分百正确手动关闭资源很困难。TWR 特性则是为了自动关闭资源而做的改进。

手动关闭资源代码很繁复,极易出错,曾经 Sun 的官方操作指南都是错的,可见一斑。
通常,手动关闭资源的代码会伴有嵌套的异常捕获,结构很深;并且 catch 块中通常不做处理,否则异常处理代码还要处理异常情况;finally 块中关闭资源的代码也可能出现异常,同样需要处理异常……
这简直是“墨菲定律”的经典演示代码!

1
2
3
4
5
static String readFirstLineFromFile(String path) throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader(path))) {
return br.readLine();
}
}

该特性依靠一个新定义的接口 java.lang.AutoCloseable 实现,try 从句中出现的资源类都必须实现该接口。

注意:java.lang.AutoCloseable 是新增的一个顶级接口,而 java.io.Closeable 反而是它的子接口。
因此,前者的 close() 方法抛出的是更宽泛的 Exception,后者抛出的是更具体的 IOException
考虑一个问题:继承关系为什么不是反过来的呢?反过来不是更符合演化的逻辑么?——先有父接口再有子接口。
这样做的有一个好处是,不需要太多的修改。所有的资源类不需要修改以增加实现 AutoCloseable 接口,因为它们都已经实现了 Closeable 接口。

要确保 TWR 生效,正确的用法是为各个资源声明独立变量。否则,在某些特定场景下,资源可能无法关闭。典型例子是充满包装模式的 IO 类,包装类创建失败,可能导致被包装类无法正确关闭。

1
2
3
try ( ObjectInputStream in = new ObjectInputStream(new FileInputStream("oneFile")) ) {
// ...
}

当文件并非 ObjectInput 类型文件时,ObjectInputStream 创建失败,这将导致内层的 FileInputStream 无法关闭。

TWR 的另一项好处是改善了错误跟踪能力,能够更准确地跟踪堆栈中的异常。

使用 try-catch-finally 语句关闭资源时,try 块中的异常可能被 catchfinally 中的异常覆盖而丢失,这称为“异常被抑制”。

而使用 TWR 时,被抑制的异常仍然可能被提取,不会丢失。这得益于 Throwable 类新增的方法 getSuppressed() 可返回被抑制的异常:

1
public final synchronized Throwable[] getSuppressed() {}

注意:Throwable 构造器有参数可以控制抑制的使能的。

多重异常捕获及重抛异常类型检查的改进

这包括两项改进:

  • 多重异常捕获(multicatch)
  • 重抛类型检查

对应的英文原名是:

  • Handling More Than One Type of Exception
  • Rethrowing Exceptions with More Inclusive Type Checking

多重异常捕获

Java 7 之前,由于一个catch 语句只能捕获一种异常,如果一段代码会抛出多种异常,那么就会有一长串的 catch 语句,使得代码复杂度上升,可读性较差。

并且通常异常处理代码也是相同,这导致代码重复。为了避免重复,这会诱导程序员捕获一个更宽泛的异常。

从生成的字节码而言,多重异常捕获比多个仅捕获一个异常的 catch 块生成的字节码更小,因为,它没有重复的异常处理程序。

Java 7 中,一个 catch 语句可以捕获多种异常,但注意,捕获的异常只要多于一个,catch 语句的异常变量就是隐式 final (implicitly final)的。

重抛类型检查

Java 7 之前的版本,编译器对重抛的异常类型推断并不精确,重抛的异常类型根据 catch 的异常类型确定。引用官方示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static class FirstException extends Exception { }
static class SecondException extends Exception { }

public void rethrowException(String exceptionName) throws Exception {
try {
if (exceptionName.equals("First")) {
throw new FirstException();
} else {
throw new SecondException();
}
} catch (Exception e) {
throw e;
}
}

示例中,显然重抛的异常类型只可能是 FirstExceptionSecondException 两者之一,但是由于捕获的是更宽泛的 Exception 类型的异常,因此,Java 7 之前的编译器要求方法声明抛出的异常必需是 Exception 类型。

针对这种情况,Java 7 增强了编译器对异常类型的精确分析能力。从而,编译器通过分析明确知道重抛的异常类型只可能是两种之一。因此,方法声明抛出的异常类型可以是精确的类型,而不必是捕获的宽泛类型。需要注意的是,该精确分析能力仅在 catch 异常参数没有重新赋值的情况下有效,一旦重新赋值将不能进行精确分析。

另,编译器校验重抛异常类型应满足以下条件:

  • try 块能抛出它
  • 没有其他的先导 catch 块处理它
  • catch 子句的异常参数之一的子类型或超类型

用于创建泛型实例的类型推断

创建泛型实例常见于使用集合类时,比如官方示例:

1
Map<String, List<String>> myMap = new HashMap<String, List<String>>();

可以看到,泛型的形式类型参数(formal type parameter) <String, List<String>> 重复指定了两次,该信息是冗余的,显然可以通过消除此种重复来提高编程效率。

Java 7 的改进方案是:省略泛型构造器中的形式类型参数的指定,改由编译器根据上下文进行推断。因此,上面的代码可简化为:

1
Map<String, List<String>> myMap = new HashMap<>();

由于 <> 的外形,这被称为“菱形语法”或“钻石语法”(英文为 diamond)。

为此,编译器采用了新的类型推断形式。要注意,这不是简单替换成定义完整类型的文本。

显然,<> 是不可省略的,否则实际使用的是原始类型。

对于 Java 7 而言,类型推断是受限的。这意味着,它只能应用于构造器的形式类型参数,并且通常需要在赋值语句中——编译器需要通过被赋值的变量类型来推断省略的构造器形式类型参数。

也许你希望在方法参数中进行类型推断,或许你会失望——至少 Java 7 是这样。比如:

1
2
List<String> list = new ArrayList<>();
list.addAll(new ArrayList<>()); // Java 7 下编译错误

看起来通过上下文,能推断第 2 行应为 new ArrayList<String>() 。但“受限”也在该场景体现出来了,对于 Java 7 编译器来说,它仅能推断为 new ArrayList<Object>(),因此编译错误。

Java 8 推断能力有所增强,这种场景是可以正确推断的。

可变参数相关的编译警告改进

这是一个关于编译警告的改进,该警告仅在方法签名中同时出现变参和泛型时才出现,其中涉及堆污染可变参数两个概念。

堆污染

我们先通过一个示例来了解堆污染

1
2
3
4
List list = new ArrayList<Number>();
List<String> strings = list; // Unchecked assignment: 'java.util.List' to 'java.util.List<java.lang.String>'
list.add(0, 42); // Unchecked call to 'add(int, E)' as a member of raw type 'java.util.List'
String s = strings.get(0);

前两行代码通过一原始类型的 List 变量作为桥梁,进行了不兼容的赋值。这是合法的——因为编译器会进行类型擦除。这就是堆污染,即当参数化类型的变量引用的对象不是该参数化类型的对象时,就会发生堆污染。

注意,堆污染不是错误,仅仅预示着可能存在风险——上例中直到最后获取元素时才会在运行时抛出类型转换错误——而编译器不会对此风险无动于衷,它会产生警告。

可变参数

了解了堆污染,我们考虑下下面的例子:

1
2
3
4
5
public static <T> void join(T... items) {
for(T t : items) {
System.out.println(t);
}
}

显然,该方法具有可变参数,但是,其可变参数的类型是泛型的参数化类型 T。可变参数最终会被编译器转换为数组,理论上,将得到一个 T[] 类型数组。但是,Java 不允许创建参数化类型的数组,即这里的 T[] 类型数组。而又由于类型擦除的原因,实际上最终得到的是一个 Object[] 类型数组。继而,对于上例,我们可以传入任意类型的参数。

是不是感觉似曾相识?回想下堆污染的定义——这其实也是堆污染。

改进了什么?

那本项改进到底改进了什么呢?

Java 7 之前,该警告是在方法调用时触发的,但是,对于调用 API 的程序员而言,除了忽略或抑制该警告别无选择。因此,Java 7 将该警告改由 API 触发了。这样做的意义在于,将该种风险的控制由调用者转移到了 API 提供者。

抑制警告

在确保提供的方法不会进行相关不当操作时,应该抑制该警告,Java 7 提供了三种方法:

  • @SafeVarargs 注解
  • @SuppressWarnings({"unchecked", "varargs"}) 注解
  • 编译器选项 -Xlint:varargs

参考