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

Difference in behaviour of the ternary operator on JDK8 and JDK10

考虑以下代码

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

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 中更改的,但我不能告诉你为什么。

我认为这是一个似乎已修复的错误。根据 JLS,抛出 NullPointerException 似乎是正确的行为。

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

该行为应取决于 get 方法的 return 类型,如以下摘录自 §15.26:

所指定
  • If both the second and the third operand expressions are numeric expressions, the conditional expression is a numeric conditional expression.

    For the purpose of classifying a conditional, the following expressions are numeric expressions:

    • […]

    • A method invocation expression (§15.12) for which the chosen most specific method (§15.12.2.5) has a return type that is convertible to a numeric type.

      Note that, for a generic method, this is the type before instantiating the method's type arguments.

    • […]

  • Otherwise, the conditional expression is a reference conditional expression.

[…]

The type of a numeric conditional expression is determined as follows:

  • […]

  • If one of the second and third operands is of primitive type T, and the type of the other is the result of applying boxing conversion (§5.1.7) to T, then the type of the conditional expression is T.

换句话说,如果两个表达式都可以转换为数值类型,并且一个是原始类型,另一个是装箱类型,那么三元条件的结果类型就是原始类型。

(Table 15.25-C 还方便地向我们展示了三元表达式 boolean ? double : Double 的类型确实是 double,这再次意味着拆箱和抛出是正确的。)

如果 get 方法的 return 类型不能转换为数字类型,则三元条件将被视为 "reference conditional expression" 并且不会发生拆箱。

此外,我认为注释 "for a generic method, this is the type before instantiating the method's type arguments" 不应该适用于我们的案例。 Map.get 没有声明类型变量,so it's not a generic method by the JLS' definition. However, this note was added in Java 9 (being the only change, see JLS8),所以它可能与我们今天看到的行为有关。

对于HashMap<String, Double>get的return类型应该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);
        }
    }
}

The output of that program on Java 8 是:

a == null
java.lang.NullPointerException

换句话说,尽管 e.nullAsNumber()e.nullAsDouble() 具有相同的实际 return 类型,但只有 e.nullAsDouble() 被视为 "numeric expression"。这些方法之间的唯一区别是类型变量绑定。

可能还有更多调查可以完成,但我想 post 我的发现。我尝试了很多东西,发现错误(即没有 unboxing/NPE)似乎只发生在表达式是 return 类型的类型变量的方法时。


有趣的是,我发现 the following program also throws 在 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"));
    }
}

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

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