Разница в поведении тернарного оператора на JDK8 и 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

Ответ 1

Я считаю, что это ошибка, которая, похоже, исправлена. По мнению JLS, бросание NullPointerException кажется правильным.

Я думаю, что здесь происходит то, что по какой-то причине в версии 8 компилятор рассматривал границы переменной типа, указанные методом возвращаемого типа метода, а не фактические аргументы типа. Другими словами, он думает ...get("1") возвращает Object. Это может быть связано с тем, что он рассматривает стирание метода или какую-то другую причину.

Поведение должно основываться на возвращаемом типе метода get, как указано в приведенных ниже отрывках из § 15.26:

  • Если и второе, и третье выражения операнда представляют собой числовые выражения, условное выражение представляет собой числовое условное выражение.

    Для классификации условных выражений следующие выражения представляют собой числовые выражения:

    • [...]

    • Выражение вызова метода (§15.12), для которого выбранный наиболее специфический метод (§15.12.2.5) имеет тип возврата, который можно преобразовать в числовой тип.

      Обратите внимание, что для универсального метода это тип перед созданием аргументов типа метода.

    • [...]

  • В противном случае условное выражение является условным условным выражением.

[...]

Тип числового условного выражения определяется следующим образом:

  • [...]

  • Если один из второго и третьего операндов имеет примитивный тип T, а тип другого - результат применения преобразования бокса (п. 5.1.7) в T, то тип условного выражения равен T

Другими словами, если оба выражения конвертируются в числовой тип, а один - примитивен, а другой - в коробке, то результатом типа тернарного условного является примитивный тип.

(Таблица 15.25-C также удобно показывает нам, что тип тройного выражения boolean? double: Double действительно будет double, опять же означающим, что unboxing и throwing верны.)

Если возвращаемый тип метода 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() рассматривается как "числовое выражение". Единственное различие между методами - это ограничение переменной типа.

Там, вероятно, больше исследований, которые можно было бы сделать, но я хотел опубликовать свои выводы. Я попробовал немало вещей и обнаружил, что ошибка (т.е. 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, если второе выражение (1.0) имеет тип double и третий (new HashMap<String, Double>().get("1")) имеет тип Double, тогда результат условное выражение имеет тип double. JVM в Java 8, кажется, достаточно умен, чтобы знать, что, поскольку вы возвращаете Double, нет причин, чтобы сначала распаковать результат HashMap#get в double а затем вернуть его обратно в Double (потому что вы указали Double).

Чтобы доказать это, измените Double в double в вашем примере и вызывается NullPointerException (в JDK 8); это связано с тем, что теперь происходит распаковка, а null.doubleValue() явно бросает null.doubleValue() NullPointerException.

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

Кажется, что это было изменено в 10, но я не могу сказать вам, почему.