Почему изменение лямбды перегружается, когда оно генерирует исключение во время выполнения?
Потерпите меня, введение немного затянуто, но это интересная головоломка.
У меня есть этот код:
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 и заявив, что считаю, что мой исходный отчет был ошибочным.