三元算子在 JDK8 和 JDK10 上的行为差异

2022-08-31 16:43:46

请考虑以下代码

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 上运行时,此代码会打印,而在 JDK10 上,此代码会导致nullNullPointerException

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 中的 bug 还是为使行为更严格而进行的有意更改?

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

答案 1

我相信这是一个似乎已经修复的错误。根据JLS的说法,抛出a似乎是正确的行为。NullPointerException

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

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

  • 如果第二个和第三个操作数表达式都是数值表达式,则条件表达式是数值条件表达式。

    出于对条件进行分类的目的,以下表达式是数值表达式:

    • [...]

    • 一种方法调用表达式 (§15.12),为其选择的最具体方法 (§15.12.2.5) 具有可转换为数值类型的返回类型。

      请注意,对于泛型方法,这是实例化方法的类型参数之前的类型。

    • [...]

  • 否则,条件表达式是引用条件表达式。

[...]

数值条件表达式的类型确定如下:

  • [...]

  • 如果第二个和第三个操作数中的一个是基元类型,而另一个操作数的类型是将装箱转换 (§5.1.7) 应用于 的结果,则条件表达式的类型为 。TTT

换句话说,如果两个表达式都可以转换为数值类型,并且一个是基元的,另一个是盒装的,则三元条件的结果类型是基元类型。

(表 15.25-C 也方便地向我们表明,三元表达式的类型确实是 ,再次意味着拆箱和投掷是正确的。boolean ? double : Doubledouble

如果该方法的返回类型不可转换为数值类型,则三元条件将被视为“引用条件表达式”,并且不会发生取消装箱。get

另外,我认为注释“对于泛型方法,这是实例化方法的类型参数之前的类型”不应该适用于我们的情况。 不声明类型变量,因此根据 JLS 的定义,它不是泛型方法。但是,此注释是在Java 9中添加的(这是唯一的更改,请参阅JLS8),因此它可能与我们今天看到的行为有关。Map.get

对于 ,的返回类型为 。HashMap<String, Double>getDouble

这里有一个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()

可能还有更多的调查可以做,但我想发布我的发现。我尝试了很多事情,发现错误(即没有unboxing/ 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"));
    }
}

这表明编译器的行为实际上是不同的,这取决于三元表达式是分配给局部变量还是方法参数。

(最初我想使用重载来证明编译器为三元表达式提供的实际类型,但考虑到上述差异,这似乎是不可能的。不过,可能还有另一种我没有想到的方法。


答案 2

JLS 10似乎没有指定对条件运算符的任何更改,但我有一个理论。

根据 JLS 8 和 JLS 10,如果第二个表达式 () 是类型表达式 ,则条件表达式的结果为 类型 。Java 8 中的 JVM 似乎足够聪明,可以知道这一点,因为您返回的是 a ,因此没有理由先将结果拆箱到 a,然后再将其装箱回 a(因为您指定了 )。1.0doublenew HashMap<String, Double>().get("1")DoubledoubleDoubleHashMap#getdoubleDoubleDouble

为了证明这一点,在你的示例中更改为,并抛出a(在JDK 8中);这是因为现在正在进行拆箱,并且显然会抛出.DoubledoubleNullPointerExceptionnull.doubleValue()NullPointerException

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

似乎这在10中发生了变化,但我不能告诉你为什么。


推荐