0

When one function creates a generic result such as List<Integer>, code with a call to another function which accepts a similar but non-matching generic type such as List<? super String> will unexpectedly compile successfully when the result from the first function is passed inline.

Consider the following code:

import java.util.Collections;
import java.util.List;

class ListTest
{
    static void consumeList(List<? super String> lst) {};

    static <T> List<T> createList(T value) {
        return Collections.singletonList(value);
    }

    void doIt() {
        List<Integer> ilst = createList(1);
        consumeList(ilst); // Does not compile. As expected. ("incompatible types: List<Integer> cannot be converted to List<? super String>")

        var ilst2 = createList(1);
        consumeList(ilst2); // Does not compile. As expected. ("incompatible types: List<Integer> cannot be converted to List<? super String>")

        consumeList(createList(1)); // Compiles. Why???
    }
}

The question is: Why does the last line compile? The compile error that is yielded by the other calls to "consumeList()" appears absolutely precise and successfully prevents me from a runtime disaster. Why does the compiler fail to provide the same level of protection with the last call?

Compiler was javac 17.0.7.

  • 3
    The last will result in a `List` being returned by `createList`, not a `List`, because `? super String` accepts a `List` or a `List`, so the compiler will infer `T` of `createList` as `Object`, not `Integer`. – Mark Rotteveel Aug 04 '23 at 10:57
  • I think you’ll find that if `consumeList` were changed to actually consume the list, _that_ would fail to compile, preventing any runtime error. – Tim Moore Aug 04 '23 at 11:26
  • To consume the list and treat the elements as strings, it would need to take a parameter of type `List extends String>`. See https://stackoverflow.com/a/2723538/29470 – Tim Moore Aug 04 '23 at 11:29
  • @TimMoore what's stopping you consuming the list elements as `Object`s? – Andy Turner Aug 04 '23 at 12:13
  • This was a minimalistic example. I stumbled across the issue when irregularly doing something like `assertThat().contentType(is(JSON))` in the context of RestAssured-based tests. Thus changing the implementation is not an option. Sure, I _can_ change the calling code so it works as intended (`assertThat().contentType(JSON)`), but only _after_ having studied subtle runtime errors. If the language really permits inferring `T` in `createList` from anything else than the call argument `T value`, that would be scary, to say the least. And the given example exactly shows why. – Bernd Watermann Aug 04 '23 at 12:15
  • @AndyTurner nothing, but there’s no reason to declare the parameter that way instead of `List>` then. In any case, there’s still no runtime error. – Tim Moore Aug 04 '23 at 12:18
  • 1
    @BerndWatermann I'm afraid I'm not very familiar with RestAssured, so I don't understand what the problem is with the code in your latest comment. I'm guessing that the error wasn't a `ClastCastException` but something else (maybe equality comparison of two different types?). The Java compiler is still inferring `T` from the argument, it's just choosing a less specific type than what you intended. The argument is both an `Integer` and an `Object`. The compiler will always try to infer a type that works before failing. – Tim Moore Aug 04 '23 at 12:48

1 Answers1

1

Answer

The Java compiler infers the actual type for createList's type parameter T not only by inspecting the actual call argument for T value but also considers the context in which the return value List<T> is used (that is, within the same ;-terminated statement). This permits inferring Object instead of Integer in the given example's last line and makes the code compile (Credits go to Tim Moore for his comment.) The effect is not specific to the createList example and can be shown by directly using e.g. Collections.singletonList() as well.

Conclusions

The given minimalistic example as well as similar but more realistic cases reveal a weakness in the type inference rules. Although something different happens technically, it virtually appears as if a SomeGenericClass<Integer> can sometimes implicitly be converted into a SomeGenericClass<Object> which should not and does not happen when the same code is written in a slightly different way. Those differences would retain the exact code semantics in "usual" code and can be established e.g. by common refactorings such as "Introduce variable" or "Inline variable". OTOH, the same rules for type inference have benefits, too. Common expressions like java.util.stream.Collectors.toList() wouldn't work the way they do at present if inferring type arguments from the surrounding context were not permitted.

And my personal two cents: Maybe I've done too much C++ in my life, but to me it still seems that the benefits of Java's type inference rules come at a rather high price. Some sorts of type safety that Java generics try to provide in order to reveal issues at compile time seem to just vanish when you rewrite code that (virtually) has the same semantics as before.