Почему изменение лямбды перегружается, когда оно генерирует исключение во время выполнения?

Потерпите меня, введение немного затянуто, но это интересная головоломка.

У меня есть этот код:

public class Testcase {
    public static void main(String[] args){
        EventQueue queue = new EventQueue();
        queue.add(() -> System.out.println("case1"));
        queue.add(() -> {
            System.out.println("case2");
            throw new IllegalArgumentException("case2-exception");});
        queue.runNextTask();
        queue.add(() -> System.out.println("case3-never-runs"));
    }

    private static class EventQueue {
        private final Queue<Supplier<CompletionStage<Void>>> queue = new ConcurrentLinkedQueue<>();

        public void add(Runnable task) {
            queue.add(() -> CompletableFuture.runAsync(task));
        }

        public void add(Supplier<CompletionStage<Void>> task) {
            queue.add(task);
        }

        public void runNextTask() {
            Supplier<CompletionStage<Void>> task = queue.poll();
            if (task == null)
                return;
            try {
                task.get().
                    whenCompleteAsync((value, exception) -> runNextTask()).
                    exceptionally(exception -> {
                        exception.printStackTrace();
                        return null; });
            }
            catch (Throwable exception) {
                System.err.println("This should never happen...");
                exception.printStackTrace(); }
        }
    }
}

Я пытаюсь добавить задачи в очередь и запускать их в порядке. Я ожидал, что все 3 случая будут ссылаться на метод add(Runnable); однако на самом деле происходит то, что случай 2 интерпретируется как Supplier<CompletionStage<Void>> который генерирует исключение, прежде чем возвращать CompletionStage поэтому "это никогда не должно происходить" запускается кодовый блок, и случай 3 никогда не запускается.

Я подтвердил, что случай 2 вызывает неправильный метод, перейдя через код с помощью отладчика.

Почему не Runnable метод Runnable для второго случая?

По-видимому, эта проблема возникает только на Java 10 или выше, поэтому обязательно проверяйте ее в этой среде.

ОБНОВЛЕНИЕ: Согласно JLS §15.12.2.1. Определить потенциально применимые методы и более конкретно JLS §15.27.2. Lambda Body кажется, что () → { throw new RuntimeException(); } () → { throw new RuntimeException(); } подпадает под категорию "совместимые с void" и "совместимые с стоимостью". Так что в этом случае есть определенная двусмысленность, но я, конечно, не понимаю, почему Supplier более подходит для перегрузки, чем Runnable. Это не так, как если бы первый выдавал исключения, которые последний не делает.

Я недостаточно разбираюсь в спецификации, чтобы сказать, что должно произойти в этом случае.

Я отправил отчет об ошибке, который отображается на https://bugs.openjdk.java.net/browse/JDK-8208490.

Ответы

Ответ 1

Во-первых, согласно § 15.27.2 выражение:

() -> { throw ... }

Является ли void -compatible и значением -compatible, поэтому он совместим (§15.27.3) с Supplier<CompletionStage<Void>>:

class Test {
  void foo(Supplier<CompletionStage<Void>> bar) {
    throw new RuntimeException();
  }
  void qux() {
    foo(() -> { throw new IllegalArgumentException(); });
  }
}

(см., что он компилируется)

Во-вторых, согласно §15.12.2.5 Supplier<T> (где T является ссылочным типом) более специфичен, чем Runnable:

Позволять:

  • S: = Supplier<T>
  • T: = Runnable
  • e: = () → { throw... }

Чтобы:

  • MTs: = T get() ==> Rs: = T
  • MTt: = void run() ==> Rt: = void

А также:

  • S не является суперинтерфейсом или подпоследователем T
  • MTs и MTt имеют параметры одного и того же типа (нет)
  • Никаких формальных параметров, так что пуля 3 также верна
  • e является явно типизированным лямбда-выражением, а Rt void

Ответ 2

Проблема в том, что существует два метода:

void fun(Runnable r) и void fun(Supplier<Void> s).

И выражение fun(() → { throw new RuntimeException(); }).

Какой метод будет вызываться?

Согласно JLS § 15.12.2.1, лямбда-тело является совместимым с void и совместимым по значению:

Если тип функции T имеет возврат void, то тело лямбда является выражением оператора (§14.8) или блоком, совместимым с void (§15.27.2).

Если тип функции T имеет (непустой) возвращаемый тип, то тело лямбда является либо выражением, либо совместимым по значению блоком (§15.27.2).

Таким образом, оба метода применимы к лямбда-выражению.

Но есть два метода, поэтому java-компилятор должен выяснить, какой метод более конкретный

В JLS §15.12.2.5. В нем говорится:

Функциональный тип интерфейса S более специфичен, чем тип функционального интерфейса T для выражения e, если выполняются все следующие условия:

Одно из следующего:

Пусть RS - тип возврата MTS, адаптированный к параметрам типа MTT, и RT - возвращаемый тип MTT. Одно из следующего должно быть правдой:

Одно из следующего:

RT недействителен.

Таким образом, S (т. Supplier) более специфичен, чем T (т.е. Runnable), потому что возвращаемый тип метода в Runnable является void.

Поэтому компилятор выбирает Supplier вместо Runnable.

Ответ 3

Похоже, что при бросании Исключения компилятор выбирает интерфейс, который возвращает ссылку.

interface Calls {
    void add(Runnable run);

    void add(IntSupplier supplier);
}

// Ambiguous call
calls.add(() -> {
        System.out.println("hi");
        throw new IllegalArgumentException();
    });

тем не мение

interface Calls {
    void add(Runnable run);

    void add(IntSupplier supplier);

    void add(Supplier<Integer> supplier);
}

жалуется

Ошибка: (24, 14) java: ссылка на add неоднозначна как метод add (java.util.function.IntSupplier) в Main.Calls и метод add (java.util.function.Supplier) в матче Main.Calls

наконец

interface Calls {
    void add(Runnable run);

    void add(Supplier<Integer> supplier);
}

компилирует штраф.

Так странно;

  • void vs int неоднозначно
  • int vs Integer неоднозначно
  • void vs Integer НЕ является двусмысленным.

Поэтому я считаю, что здесь что-то нарушено.

Я отправил отчет об ошибке в oracle.

Ответ 4

Первое:

Ключевым моментом является то, что методы перегрузки или конструкторы с разными функциональными интерфейсами в одной и той же позиции аргумента приводят к путанице. Поэтому не перегружайте методы, чтобы использовать разные функциональные интерфейсы в одной и той же позиции аргумента.

Джошуа Блох, - Эффективная Java.

В противном случае вам понадобится бросок, чтобы указать правильную перегрузку:

queue.add((Runnable) () -> { throw new IllegalArgumentException(); });
              ^

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

queue.add(() -> { for (;;); });

В случаях, показанных выше, тело лямбда никогда не заканчивается нормально, что добавляет к путанице: какая перегрузка для выбора (совместима с void или совместима с ценностью), если лямбда неявно напечатана? Поскольку в этой ситуации применяются оба метода, например, вы можете написать:

queue.add((Runnable) () -> { throw new IllegalArgumentException(); });

queue.add((Supplier<CompletionStage<Void>>) () -> {
    throw new IllegalArgumentException();
});

void add(Runnable task) { ... }
void add(Supplier<CompletionStage<Void>> task) { ... }

И, как указано в этом ответе, наиболее конкретный метод выбирается в случае двусмысленности:

queue.add(() -> { throw new IllegalArgumentException(); });
                       ↓
void add(Supplier<CompletionStage<Void>> task);

В то же время, когда тело лямбда завершается нормально (и только совместимо с void):

queue.add(() -> { for (int i = 0; i < 2; i++); });
queue.add(() -> System.out.println());

выбирается метод void add(Runnable task), потому что в этом случае нет никакой двусмысленности.

Как указано в JLS § 15.12.2.1, когда лямбда-тело является одновременно совместимым с void и совместимым с ценностью, определение потенциальной применимости выходит за рамки базовой проверки достоверности, чтобы также учитывать наличие и форму целевых типов функционального интерфейса.

Ответ 5

Я ошибочно считал это ошибкой, но она кажется правильной в соответствии с §15.27.2. Рассматривать:

import java.util.function.Supplier;

public class Bug {
    public static void method(Runnable runnable) { }

    public static void method(Supplier<Integer> supplier) { }

    public static void main(String[] args) {
        method(() -> System.out.println());
        method(() -> { throw new RuntimeException(); });
    }
}
javac Bug.java
javap -c Bug
public static void main(java.lang.String[]);
  Code:
     0: invokedynamic #2,  0      // InvokeDynamic #0:run:()Ljava/lang/Runnable;
     5: invokestatic  #3          // Method add:(Ljava/lang/Runnable;)V
     8: invokedynamic #4,  0      // InvokeDynamic #1:get:()Ljava/util/function/Supplier;
    13: invokestatic  #5          // Method add:(Ljava/util/function/Supplier;)V
    16: return

Это происходит с jdk-11-ea + 24, jdk-10.0.1 и jdk1.8u181.

Ответ zhh заставил меня найти этот еще более простой тестовый пример:

import java.util.function.Supplier;

public class Simpler {
    public static void main(String[] args) {
        Supplier<Integer> s = () -> { throw new RuntimeException(); };
    }
}

Однако Дувдув указал на п. 15.27.2, в частности, это правило:

Блок лямбда-блока является совместимым по стоимости, если он не может нормально функционировать (§14.21), и каждый оператор return в блоке имеет форму return Expression ;.

Таким образом, блок лямбда тривиально совместим по стоимости, даже если он вообще не содержит оператора возврата. Я бы подумал, потому что компилятор должен вывести свой тип, чтобы потребовалось хотя бы одно выражение Expression ;. Холгар и другие отметили, что это не обязательно с помощью обычных методов, таких как:

int foo() { for(;;); }

Но в этом случае компилятор должен только убедиться, что нет возврата, который противоречит явному типу возврата; ему не нужно выводить тип. Тем не менее, правило в JLS написано для того, чтобы допускать такую же свободу с блочными лямбдами, как с обычными методами. Возможно, я должен был это увидеть раньше, но я этого не сделал.

Я подал ошибку с Oracle, но с тех пор отправил ей обновление, ссылаясь на §15.27.2 и заявив, что считаю, что мой исходный отчет был ошибочным.