为什么 lambda 在抛出运行时异常时会更改重载?

Why does a lambda change overloads when it throws a runtime exception?

请耐心等待,介绍有点冗长,但这是一个有趣的谜题。

我有这个代码:

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 之前抛出异常,因此 "this should never happen" 代码块被触发而案例 3 永远不会 运行 s.

我通过使用调试器单步执行代码确认案例 2 调用了错误的方法。

为什么第二种情况不调用 Runnable 方法?

显然这个问题只出现在Java 10或更高版本上,所以一定要在这个环境下测试。

更新:根据JLS §15.12.2.1. Identify Potentially Applicable Methods and more specifically JLS §15.27.2. Lambda Body() -> { throw new RuntimeException(); } 似乎属于"void-compatible" 和"value-compatible" 的类别。很明显,在这种情况下存在一些歧义,但我当然不明白为什么 SupplierRunnable 更适合重载。这并不是说前者抛出任何后者不抛出的异常。

我对规范了解不够,无法说明在这种情况下应该发生什么。

我提交了一份错误报告,可在 https://bugs.openjdk.java.net/browse/JDK-8208490

中查看

我错误地认为这是一个错误,但根据 §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(); };
    }
}

但是,duvduv 指出了 §15.27.2,特别是这条规则:

A block lambda body is value-compatible if it cannot complete normally (§14.21) and every return statement in the block has the form return Expression;.

因此,块 lambda 是平凡的值兼容的,即使它根本不包含 return 语句。我本以为,因为编译器需要推断其类型,所以它至少需要一个 return Expression;。 Holgar 和其他人指出,这对于普通方法是不必要的,例如:

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

但在那种情况下,编译器只需要确保没有 return 与显式 return 类型相矛盾;它不需要推断类型。然而,JLS 中的规则被编写为允许块 lambda 与普通方法一样自由。也许我应该早点看到,但我没有。

我提交了 a bug with Oracle,但此后发送了一份更新,引用了 §15.27.2 并声明我认为我的原始报告有误。

似乎在抛出异常时,编译器会选择 returns 引用的接口。

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);
}

抱怨

Error:(24, 14) java: reference to add is ambiguous both method add(java.util.function.IntSupplier) in Main.Calls and method add(java.util.function.Supplier) in Main.Calls match

最后

interface Calls {
    void add(Runnable run);

    void add(Supplier<Integer> supplier);
}

编译正常。

太奇怪了;

  • void vs int 是模棱两可的
  • int vs Integer 是模棱两可的
  • voidInteger 没有歧义。

所以我觉得这里有东西坏了。

我已向 Oracle 发送错误报告。

问题是有两种方法:

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

还有一个表达式fun(() -> { throw new RuntimeException(); }).

将调用哪个方法?

根据 JLS §15.12.2.1,lambda 主体既兼容空值又兼容值:

If the function type of T has a void return, then the lambda body is either a statement expression (§14.8) or a void-compatible block (§15.27.2).

If the function type of T has a (non-void) return type, then the lambda body is either an expression or a value-compatible block (§15.27.2).

所以这两种方法都适用于lambda表达式。

但是有两种方法,所以java编译器需要找出哪种方法更具体

JLS §15.12.2.5。它说:

A functional interface type S is more specific than a functional interface type T for an expression e if all of the following are true:

以下其中一项是:

Let RS be the return type of MTS, adapted to the type parameters of MTT, and let RT be the return type of MTT. One of the following must be true:

以下其中一项是:

RT is void.

所以S(即Supplier)比T(即Runnable)更具体,因为Runnable中方法的return类型是void.

所以编译器选择 Supplier 而不是 Runnable

首先,根据§15.27.2表达式:

() -> { throw ... }

void 兼容又值兼容,因此它与 Supplier<CompletionStage<Void>>:

兼容 (§15.27.3)
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
  • 的超级接口或子接口
  • MTsMTt 具有相同的类型参数 (none)
  • 没有形式参数所以项目符号 3 也是正确的
  • e 是一个显式类型的 lambda 表达式,Rtvoid

要事第一:

The key point is that overloading methods or constructors with different functional interfaces in the same argument position causes confusion. Therefore, do not overload methods to take different functional interfaces in the same argument position.

Joshua Bloch, - Effective Java.

否则,您将需要一个强制转换来指示正确的重载:

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

当使用无限循环而不是运行时异常时,同样的行为是显而易见的:

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

在上面显示的情况下,lambda 主体从未正常完成,这增加了混乱:选择哪个重载(void-compatiblevalue- compatible) 如果 lambda 是隐式类型的?因为在这种情况下两种方法都适用,例如你可以写:

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);

同时,当 lambda 主体正常完成时(并且仅与 void 兼容):

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

选择方法void add(Runnable task),因为在这种情况下没有歧义。

JLS §15.12.2.1 所述,当 lambda 体既 void-compatiblevalue-compatible 时,潜在适用性的定义超出了基本的数量检查,还考虑了功能接口目标类型的存在和 形状