为什么当Lambda表达式抛出运行时异常时,它的重载类型会改变?

47
请耐心看完,介绍有点冗长,但这是一个有趣的难题。
我有这段代码:
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(); }
        }
    }
}

我正在尝试将任务添加到队列并按顺序运行它们。我原以为所有三种情况都会调用add(Runnable)方法; 然而,实际发生的是,第二种情况被解释为抛出异常后返回CompletionStageSupplier<CompletionStage<Void>>,因此触发了“这不应该发生”的代码块,第三种情况从未运行。
通过使用调试器逐步执行代码,我确认第二种情况正在调用错误的方法。 为什么第二种情况没有调用Runnable方法? 显然,这个问题只在Java 10或更高版本上发生,所以请务必在这个环境下进行测试。 更新:根据JLS §15.12.2.1.确定潜在适用的方法和更具体地说是JLS §15.27.2. Lambda Body,似乎() -> { throw new RuntimeException(); }属于“void-compatible”和“value-compatible”两个类别。因此,在这种情况下存在一些歧义,但我当然不明白为什么SupplierRunnable更适合重载。这并不意味着前者会抛出后者没有的任何异常。
我对规范了解不够,无法确定此情况应该发生什么。
我已经提交了一个错误报告,可在https://bugs.openjdk.java.net/browse/JDK-8208490中查看。

3
显然这个问题只会在Java 10或更高版本中出现,所以请确保在这种环境下进行测试。...我在Java-8中遇到了同样的行为,你确定标签没错吗? - Naman
2
这是一个与Java-8相关的重现示例的链接:https://tio.run/##y0osS9TNL0jNy0rJ/v@/oDQpJzNZITknsbhYwTcxM0@hmosTKlhcklgCpMryM1MUcoFSGsElRZl56dGxColF6cWaIJWcwZXFJam5evmlJXoFQMmSnDwNJY/UnJx8HYXw/KKcFEUlTWsuzlqu2v//AQ。如果我漏掉了什么,请指出来。 - Naman
4
这很有趣,但我不认同 Runnable 重载应该被选择的直觉。在我看来,例如 () -> { throw new RuntimeException(); } 应该与返回值函数类型兼容,如果它是兼容的,那么实际选择哪个重载可能是任意的。(而且因为这个原因,即使对于某些人来说可能不直观,我怀疑这不是一个 bug。)将抛出异常的 lambda 传递给例如函数或供应商偶尔是有用的 (尤其是对于测试),如果它不兼容,我们将不得不使用一些非常愚蠢的解决方法。 - Radiodef
1
@nullpointer 你说得对,这个问题在Java 8及以上版本中是可以重现的。感谢你的提醒。 - Gili
4
例外是一个红鲱鱼,因为您可以使用() -> { for(;;); }来获得相同的结果。 - Holger
显示剩余8条评论
5个回答

20
问题在于有两种方法: void fun(Runnable r)void fun(Supplier<Void> s)。 还有一个表达式 fun(() -> { throw new RuntimeException(); })。 哪个方法会被调用? 根据JLS §15.12.2.1,lambda主体既是void兼容的,也是值兼容的: 如果T的函数类型具有void返回,则lambda主体要么是语句表达式(§14.8),要么是void兼容块(§15.27.2)。 如果T的函数类型具有(非void)返回类型,则lambda主体要么是表达式,要么是值兼容块(§15.27.2)。 因此,这两种方法都适用于lambda表达式。 但是有两种方法,Java编译器需要找出哪种方法更具体。 在JLS §15.12.2.5中。它说: 函数接口类型S对于表达式e比函数接口类型T更具体,如果以下所有条件都成立:
MTS的返回类型RS已适配到MTT的类型参数,MTT的返回类型为RT。以下情况之一必须成立:
RT为void。
因此,S(即Supplier)比T(即Runnable)更具体,因为Runnable中方法的返回类型为void。
因此,编译器选择Supplier而不是Runnable。

1
这并没有解释为什么第一个 lambda 表达式不匹配 Supplier。按照同样的逻辑,它难道不应该匹配吗? - Gili
1
@Gill 第一个 lambda 只适用于 void。 - zhh
1
也许我错过了什么,但是您所提到的段落并没有说它仅适用于既可以与void兼容又可以与值兼容的lambda表达式。 - Gili
1
我觉得你可能有所发现,但这确实不直观 :) 我们将看看 Oracle 的人会说什么(我已经提交了正式的错误报告)。 - Gili
我已经更新了问题,并附上了错误报告的链接。 - Gili
显示剩余4条评论

11

首先,根据§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.5Supplier<T>(其中T是引用类型)比Runnable更具体:

让:

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

以便:

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

并且:

  • S不是T的超级接口或子接口
  • MTsMTt具有相同的类型参数(没有)
  • 没有形式参数,因此项目3也是正确的
  • e是显式类型化的lambda表达式,而Rtvoid

2
我发现您的答案比zhh的更易于理解,但是您好像错过了他提到的一个关键点:§15.12.2.5说如果Rt是void,那么Rs比Rt更具体。因此,SupplierRunnable更具体。 - Gili
谢谢@Gili,我觉得我的结果有点奇怪。我已经更新了答案。 - duvduv
啊!现在我明白了。谢谢你。15.27.2 是关键点,它说在 Lambda 表达式中不能正常完成时,每个 return 必须是形如 return Expression 的形式,如果没有返回,则这显然是平凡的。我本来会加上“必须至少有一个返回Expression”,但他们显然想允许只能异常返回的值Lambda表达式。我承认这对我来说似乎不是很有用。 - David Conrad

7
似乎在抛出异常时,编译器会选择返回引用的接口。
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的引用不明确,Main.Calls中的方法add(java.util.function.IntSupplier)和Main.Calls中的方法add(java.util.function.Supplier)都匹配

最后

interface Calls {
    void add(Runnable run);

    void add(Supplier<Integer> supplier);
}

编译正常。

很奇怪的是:

  • voidint 是模棱两可的
  • intInteger 是模棱两可的
  • voidInteger 不是模棱两可的。

所以我认为这里有些问题。

我已向Oracle发送了错误报告。


我不理解你上一句话中的"第二个lambda必须返回引用。返回void或primitive会导致调用模糊不清"。如果有歧义,编译器不应该会报错吗?https://dev59.com/g2Ag5IYBdhLWcg3wlLuh 暗示着会报错。 - Gili
@Gili 我使用了不同的接口来返回void或返回int,这被视为不明确。 - Peter Lawrey
1
除非使用非常旧的编译器(Java 8,在更新20之前),否则voidint返回函数之间没有歧义。从8u20到10的所有javac版本都更喜欢非void函数。即使旧编译器在intInteger方面显示一致的行为,也会报告任一情况下的歧义。但是你的错误消息开头,“Error:(24,14)java:”看起来不像是javac输出... - Holger

5

首先:

关键点是,在相同参数位置上使用不同的函数接口重载方法或构造函数会导致混淆。因此,请勿在相同参数位置上重载方法以接受不同的函数接口。

Joshua Bloch,- Effective Java.

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

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

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

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

在上面展示的情况下,lambda体从未正常完成,这增加了困惑:如果lambda是隐式类型的,则选择哪个重载(void兼容或value兼容)?因为在这种情况下,两种方法都适用,例如,您可以编写:
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兼容又能与值兼容时,潜在适用性的定义不仅涉及基本的arity检查,还考虑了函数式接口目标类型的存在和形状。

2
引用语“你的第一件事”应该足以解决这个问题。忽略这个建议就是在自找麻烦。即使是专家也很难确定哪个重载会被选择。@Gili要为那个程序的读者着想——除非你的目标是为顶尖0.1%的专家创建一个测验。 - Stephan Herrmann
你的Effective Java引用来自哪个版本和条目号?我买了第三版,但还没有时间阅读。我想现在我应该抽出时间来看看 :) - Gili
2
@Gili 第8章,第52项:明智地使用重载。 - Oleksandr Pyrohov
3
无论使用函数式接口与否,重载方法不应该像那个问题的代码一样做根本上不同的事情,即将参数包装成 CompletableFuture.runAsync 或直接将其加入队列。举一个正面例子,ExecutorService 做得很好,submit(Callable)submit(Runnable) 都将函数包装成一个 future,而 execute(Runnable) 是专门用于将 Runnable 直接加入队列的方法。因此,调用“错误”的 submit 方法永远不会产生负面影响。 - Holger
1
感谢您引用Josh Bloch的话,这非常有启发性。 - David Conrad
3
@Holger 我同意一个危险的特性如果被严格地使用会变得不那么危险。尽管如此,我认为Joshua Bloch在他引用的那段建议中非常谦虚。为了更严厉地警告过度重载,我推荐阅读 https://gbracha.blogspot.com/2009/09/systemic-overload.html ——早在Java 8之前,重载就已经被用于相当晦涩难懂的编程了。 - Stephan Herrmann

2

我曾经错误地认为这是一个bug,但根据§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中的一个规则,特别是这条规则:

如果块lambda体无法正常完成(§14.21),并且块中的每个return语句都具有形式return Expression;,则该块lambda体是值兼容的。

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

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

但在这种情况下,编译器只需要确保没有返回与显式返回类型相矛盾的内容;它不需要推断类型。然而,JLS中的规则是为了允许块lambda与普通方法具有相同的自由度。也许我应该更早地看到这一点,但我没有。我向Oracle提交了一个错误报告,但后来更新了它,引用了§15.27.2,并声明我认为我的原始报告有误。

2
为什么你称它为 bug? - Andrew Tobilko
1
@Andrew 我称之为一个 bug,因为编译器绝不应该将一个不返回任何东西的 lambda 视为任何东西的“供应者”(Supplier)。它在这里明显推断出了错误的类型。 - David Conrad
2
根据“编译器不应该将不返回任何内容的lambda视为任何供应商”的逻辑,@DavidConrad,public Integer getInteger() { throw new RuntimeException();}(以及Supplier<Integer> s = this::getInteger)也应该是非法的。 - crizzis
3
就翻译而言,"FWIW: javac and ecj agree on how to handle Bug.java. This could indicate that this behavior is mandated by JLS and hence you would not be challenging javac but JLS, i.e., you expect a different language than what JLS defines." 可以翻译为:顺带一提:javac和ecj在处理Bug.java时达成了一致。这可能意味着此行为是由JLS规定的,因此您挑战的不是javac而是JLS,也就是说,您期望的是与JLS定义不同的语言。 - Stephan Herrmann
2
你正在挑战一个非常古老的规则,因为int foo() { for(;;); }一直都是一个有效的方法定义... - Holger
显示剩余10条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接