4

Why is there no compile error, that addListener method was called with argument, which is a method reference with an interface NotAnEvent, which has nothing in common with Event class?

public class TestClass {
    public static void main(String[] args) {
        addListener(TestClass::listener1);
        addListener(TestClass::listener2);
    }

    public static <T extends Event> void addListener(Consumer<T> listener) {

    }

    public static void listener1(ActualEvent event) {

    }

    public static void listener2(NotAnEvent event) {

    }

    public static class Event {
    }

    public static class ActualEvent extends Event {
    }

    public interface NotAnEvent {
    }
}

Code above compiles successfully, at least with Intellij Idea 2020.3 Ultimate and JDK 8 (and with OpenJDK 11 too), but it predictably crashes on launch:

Exception in thread "main" java.lang.BootstrapMethodError: call site initialization exception
    at java.lang.invoke.CallSite.makeSite(CallSite.java:341)
    at java.lang.invoke.MethodHandleNatives.linkCallSiteImpl(MethodHandleNatives.java:307)
    at java.lang.invoke.MethodHandleNatives.linkCallSite(MethodHandleNatives.java:297)
    at ru.timeconqueror.TestClass.main(TestClass.java:8)
Caused by: java.lang.invoke.LambdaConversionException: Type mismatch for lambda argument 0: class ru.timeconqueror.TestClass$Event is not convertible to interface ru.timeconqueror.TestClass$NotAnEvent
    at java.lang.invoke.AbstractValidatingLambdaMetafactory.validateMetafactoryArgs(AbstractValidatingLambdaMetafactory.java:267)
    at java.lang.invoke.LambdaMetafactory.metafactory(LambdaMetafactory.java:303)
    at java.lang.invoke.CallSite.makeSite(CallSite.java:302)
    ... 3 more
  • This is what you are looking for https://stackoverflow.com/a/57756409/2714055 – Bryan Acuña Núñez Dec 21 '20 at 15:42
  • I you look at your bytecode, then you should see the difference between both calls – Bryan Acuña Núñez Dec 21 '20 at 15:43
  • 1
    @BryanAcuñaNúñez no it is not; you can reproduce the problem stated in this question on JDK14's javac; the SO answer you linked to says 'fixed in JDK9'. – rzwitserloot Dec 21 '20 at 15:53
  • This is either a bug in javac, or possibly a 'bug' in the java lang spec, in that it follows spec, but the spec is wrong for allowing this. – rzwitserloot Dec 21 '20 at 15:54
  • Still fails (in that this compiles when it should not) on the early access JDK16 release. – rzwitserloot Dec 21 '20 at 15:55
  • 2
    Interesting - even ecj (eclipse's compiler, which is entirely separate) allows this. Normally with spec/bugs like this, ecj gets it right and javac gets it wrong. I haven't found the right spot in the JLS yet, but this suggests that it's up to spec (and thus, that the spec needs fixing). – rzwitserloot Dec 21 '20 at 15:56

2 Answers2

2

It’s correct that this code gets accepted by the compiler, as it is sound regarding the generic type system. While the interface NotAnEvent is not a subtype of Event, there could be a type extending Event and implementing NotAnEvent and it is valid to pass a consumer of that type to your method addListener.

See also Generic return type upper bound - interface vs. class - surprisingly valid code

We could even fix your example to work at runtime:

import java.util.function.Consumer;

public class TestClass {
    public static <X extends Event&NotAnEvent> void main(String[] args) {
        addListener(TestClass::listener1);
        TestClass.<X>addListener(TestClass::listener2);
    }

    public static <T extends Event> void addListener(Consumer<T> listener) {}
    public static void listener1(ActualEvent event) {}
    public static void listener2(NotAnEvent event) {}
    public static class Event {}
    public static class ActualEvent extends Event {}
    public interface NotAnEvent {}
}

This fixed version uses a type variable to assign a name to the hypothetical type (that still isn’t an actual class), so we can refer to it in the invocation of addListener. Since we can provide an explicit solution for the type constraints, the type inference was right in assuming that the constraints can be fulfilled.

The reason why one version works and the other fails at runtime, is connected with subtle differences in the way the code is generated. When we look at the bytecode, we’ll see that in both cases, a synthetic helper method generated, instead of passing a reference of listener2 directly to the LambdaMetafactory.

question’s code:

  private static void lambda$main$0(TestClass$NotAnEvent);
    Code:
       0: aload_0
       1: invokestatic  #73                 // Method listener2:(LTestClass$NotAnEvent;)V
       4: return

working version:

  private static void lambda$main$0(java.lang.Object);
    Code:
       0: aload_0
       1: checkcast     #73                 // class TestClass$NotAnEvent
       4: invokestatic  #75                 // Method listener2:(LTestClass$NotAnEvent;)V
       7: return

After type erasure took place, it’s normal for types with multiple bounds to see one bound as declared type and a type cast to another bound. For a correct generic program, those casts will never fail. In your case, the method addListener can’t invoke the accept method with anything but null, as it doesn’t know what T is.

The interesting point in the case of the question’s code is that the helper method’s declared parameter type is the same as the listener2 method’s, which makes the entire helper method pointless. The method has to take the other bound (Event) or just Object, as the second case, to make it work. This seems to be a bug in the compiler.

Holger
  • 285,553
  • 42
  • 434
  • 765
0

It makes some sort of sense, though one can definitely argue that this is undesirable.

The problem is PECS rules (Producers Extend, Consumers Super). Imagine we flip this around:

public class TestClass {
    public static void main(String[] args) {
        addListener(TestClass::listener2);
    }

    public static <T extends Event> void addListener(Supplier<T> listener) {}
    public static NotAnEvent listener2() {return null;}

    public static class Event {}
    public static class ActualEvent extends Event {}
    public interface NotAnEvent {}
}

This does not compile. Which is a bit odd; it's 100% the same except this time we have a supplier and not a consumer.

However, it makes some convoluted sort of sense. We can trivially use that supplier: Event x = supplier.get(); - and we get a classcastexception without a cast, had this code compiled.

But, your Consumer cannot actually be used here. Except with null, which trivially works fine and no runtime exceptions occur due to typing errors (an NPE might, of course). You can't pass an expression of type Event to the consume call of a Consumer<T> where T is defined as T extends Event. After all, what if you have a Consumer<ChildEvent> and the expression resolved to an instance of class SomeEvent extends Event - which clearly isn't a ChildEvent?

So, without getting a T all ready to go for you, you cannot do anything useful with this consumer, and somehow java figures it out.

There are 2 ways we can 'try to fix this', but both result in compiler errors (caveat: I only tested with ecj):

public class TestClass {
    public static void main(String[] args) {
        addListener(TestClass::listener2, new Event());
        addListener(TestClass::listener2, new NotAnEvent() {});
    }

    public static <T extends Event> void addListener(Consumer<T> listener, T elem) {}
    public static void listener2(Consumer<NotAnEvent> c) {}

    public static class Event {}
    public static class ActualEvent extends Event {}
    public interface NotAnEvent {}
}

However, here both of the addListener calls are a compiler error. We can make this work, but it's a bit bizarre as to how:

public class Weird extends Event implements NotAnEvent {}

...

addListener(TestClass::listener2, new Weird());

and now it compiles and works - and crucially, no runtime exception occurs, because you can pass an instance of Weird to your consumer of NotAnEvent and it works fine.

This partly explains some of the behaviour: NotAnEvent has to be an interface: If the parameter type of your listener2 is either Object or some interface, it compiles, but if it's some class (final or not), it won't. That's presumably because the compiler is thinking: Well, this could perhaps work out later, and no heap corruption can occur because there's no way to get a T safely without passing it in, and then a compiler error would ensue unless you have something like Weird, above.

This gets us to the obvious followup question:

You do get a runtime exception that seems to be based around the typing issue. You say in your question that it 'predictably' crashes, but I don't find this particularly predictable. Your addListener code doesn't do anything, and normally with generics erasure, that's fine. Some linkage process is failing.

So, still, a bug somewhere in some spec, and presumably worth filing at bugs.openjdk.

rzwitserloot
  • 85,357
  • 5
  • 51
  • 72