JDK8和JDK10中三目运算符的行为差异

59

考虑以下代码

public class JDK10Test {
    public static void main(String[] args) {
        Double d = false ? 1.0 : new HashMap<String, Double>().get("1");
        System.out.println(d);
    }
}

在JDK8上运行此代码会打印null,而在JDK10上将导致NullPointerException

Exception in thread "main" java.lang.NullPointerException
    at JDK10Test.main(JDK10Test.java:5)
编译器生成的字节码几乎相同,除了JDK10编译器生成的两条与自动装箱有关的附加指令,似乎是导致NPE的原因。
15: invokevirtual #7                  // Method java/lang/Double.doubleValue:()D
18: invokestatic  #8                  // Method java/lang/Double.valueOf:(D)Ljava/lang/Double;

这种行为是JDK10中的一个错误还是故意增强了行为的改变?


JDK8:  java version "1.8.0_172"
JDK10: java version "10.0.1" 2018-04-17

我对这段代码在IntelliJ中运行没有问题。 Java(TM) SE 运行时环境 18.3 (build 10.0.1+10) 2018-04-17 - arseniyandru
3
对于我来说,false ? 1.0 : (Double) null 在两个 JDK 版本中(JDK 1.8.0_144 和 JDK 10.0.1)都会抛出 NPE 异常,而 new HashMap<...>... 版本只会在 JDK 10.0.1 中抛出异常。 - Turing85
2
我认为抛出异常是正确的行为,所以我猜这是/曾经是一个bug。请参见表15.25-D下的§15.25:结果类型应该是double,因此应该取消装箱。 - Radiodef
2
@Radiodef,在Java 8规范中也是这样。 - Jacob G.
3
最终答案,https://twitter.com/BrianGoetz/status/1005781178807439362 - SerCe
2个回答

49

我相信这是一个bug,似乎已经被修复了。按照JLS的规定,抛出NullPointerException似乎是正确的行为。

我认为这里发生的情况是,由于某种原因,在版本8中,编译器考虑的是方法返回类型中提到的类型变量的边界,而不是实际的类型参数。换句话说,它认为...get("1")返回Object。这可能是因为它正在考虑方法的擦除,或者其他原因。

行为应该取决于get方法的返回类型,正如下面从§15.26摘录的所示:

如果第二个和第三个操作数表达式都是数字表达式,则条件表达式是数字条件表达式。为了分类条件,以下表达式是数字表达式:
- 具有可转换为数字类型的返回类型的已选择最具体方法(§15.12.2.5)的方法调用表达式(§15.12)。 - 其他表达式。
否则,条件表达式是引用条件表达式。数字条件表达式的类型如下确定:
- 如果第二个和第三个操作数中的一个是原始类型T,并且另一个的类型是应用装箱转换(§5.1.7)到T的结果,则条件表达式的类型为T。
换句话说,如果两个表达式都可以转换为数字类型,并且其中一个是原始类型,另一个是装箱类型,则三元条件表达式的结果类型是原始类型。
如果get方法的返回类型不能转换为数字类型,那么三元条件运算符将被视为“引用条件表达式”,不会发生拆箱操作。
另外,我认为注释“对于泛型方法,这是实例化方法类型参数之前的类型”不适用于我们的情况。Map.get没有声明类型变量,因此按照JLS的定义它不是一个泛型方法。但是,这个注释在Java 9中被添加了(是唯一的改变,参见JLS8),所以它可能与我们今天看到的行为有关。
对于一个HashMap<String, Double>get方法的返回类型应该Double
下面是支持我的理论的MCVE,即编译器考虑类型变量边界而不是实际类型参数:
class Example<N extends Number, D extends Double> {
    N nullAsNumber() { return null; }
    D nullAsDouble() { return null; }

    public static void main(String[] args) {
        Example<Double, Double> e = new Example<>();

        try {
            Double a = false ? 0.0 : e.nullAsNumber();
            System.out.printf("a == %f%n", a);
            Double b = false ? 0.0 : e.nullAsDouble();
            System.out.printf("b == %f%n", b);

        } catch (NullPointerException x) {
            System.out.println(x);
        }
    }
}
在Java 8上运行该程序的输出结果为:
a == null
java.lang.NullPointerException

换句话说,尽管e.nullAsNumber()e.nullAsDouble()实际返回类型相同,但只有e.nullAsDouble()被视为“数字表达式”。两种方法之间唯一的区别是类型变量绑定。
可能还可以进行更多的调查,但我想发布我的发现。我尝试了很多事情,并发现当表达式是具有返回类型中的类型变量的方法时,该错误(即无拆箱/NPE)似乎只会发生。

有趣的是,我发现在Java 8中以下程序也会抛出异常

import java.util.*;

class Example {
    static void accept(Double d) {}

    public static void main(String[] args) {
        accept(false ? 1.0 : new HashMap<String, Double>().get("1"));
    }
}

那表明编译器的行为实际上是不同的,取决于三元表达式是分配给本地变量还是方法参数。
(最初我想使用重载来证明编译器正在给予三元表达式的实际类型,但是鉴于以上差异,看起来似乎不可能。不过,可能还有其他我没有想到的方法。)

14

看起来JLS 10没有对条件运算符进行任何更改,但我有一个理论。

根据JLS 8和JLS 10,如果第二个表达式 (1.0) 的类型是 double 并且第三个表达式 (new HashMap<String, Double>().get("1")) 的类型是 Double,那么条件表达式的结果类型为 double。Java 8中的JVM似乎已经足够聪明,因为你返回的是 Double,所以没有理由先将 HashMap#get 的结果拆箱到 double 再装箱成 Double (因为你指定了 Double)。

为了证明这一点,在你的示例中将 Double 改为 double,就会抛出一个 NullPointerException (在JDK 8中);这是因为现在发生了拆箱,null.doubleValue() 显然会引发 NullPointerException

double d = false ? 1.0 : new HashMap<String, Double>().get("1");
System.out.println(d); // Throws a NullPointerException

看起来这在10中已经改变了,但我无法告诉你为什么。


条件表达式的结果类型应该是Double,即包装类型,而不是原始类型。换句话说,1.0被装箱,null没有被拆箱。 - Abhijit Sarkar
6
根据JLS 8和JLS 10,在这种情况下条件表达式的返回类型是 double,但因为OP指定了它,所以它被装箱为 Double - Jacob G.

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接