Java Generics: Bounded Wildcards in Method Declarations


Sometimes it is good to review concrete examples of abstractions in practice to remember their importance. Here we’ll look at
bounded wildcards in method declarations, why they may be better than using a generic type without any bound, and why the mnemonic PECS works.

The important thing to remember about generics is that they are invariant. This means the rules of inheritance don’t apply to them the way they do for other classes in Java. List<Number> is not a super class of List<Integer>, even though the class Integer is a covariant of Number. If you define a method with List<Number> as a parameter then you cannot pass List<Integer> as an argument. Only List<Number> will work.

However, there is a way to declare a method that would accept List<Number>, List<Integer>, or a List with a parameter of any sublcass of Number. This is done by declaring the parameter of the method to be List<? extends Number>. This is known as a wildcard with an upper bound, and the class Number is the upper bound.

We start by defining a class with a type parameter to set the type parameters for our methods.

public class MyClass {}
Case 1
Let’s create a method that has a generic type for a parameter with the purpose of creating a combined List. It takes 2 lists as input, combines them by adding the elements from list2 to list1, and prints out the elements along with the class of each element and returns list1.
​    public List<T> combine1(List<T> list1, List<T> list2) {
        for (T t : list2) {
            list1.add(t);
        }

        for (T t : list1) {
            System.out.println(t + " " + t.getClass());
        }

        return list1;
    }
​ Now lets create a program and run it:
    public static void main(String[] args) {
        MyClass<Number> myClass = new MyClass();
        List<Number> numbers1 = new ArrayList<>(List.of(1, 2, 3));
        List<Number> numbers2 = new ArrayList<>(List.of(1, 2.0, 3));
        myClass.combine1(numbers1, numbers2);
}
Output:

1 class java.lang.Integer
2 class java.lang.Integer
3 class java.lang.Integer
1 class java.lang.Integer
2.0 class java.lang.Double
3 class java.lang.Integer
This works because of type erasure, T is erased at compile time and is replaced by Number and our arguments for the call to combine1() are of type List<Number>.
Case 2

For the next step we try to get a little fancy and mix up the types. list1 will still be List<Number>, but list2 will be List<Integer>.


        List<Integer> integers = new ArrayList<>(List.of(1, 2, 3));
        myClass.combine1(numbers1, integers);

This won’t compile. We get the messages as "incompatible types: java.util.List cannot be converted to java.util.List". Remember in the definition of combine1() we are adding elements from list2 to list1. It would seem logical that should be able to add Integers to a List since Integer is a subclass of Number. But the compiler doesn’t know exactly what our method is doing and is trying to prevent a bigger problem. Since Double is also a subclass of Number we could have just as easily done something like this:
        List integers = new ArrayList<>(List.of(1, 2, 3));
        List numbers = new ArrayList<>(List.of(1, 2.0, 3));
        myClass.combine2(integers, numbers2);
The argument numbers contains a Double and the combine1() method is trying to add it to a list of Integers. This is why the compiler stops us.
Case 3
We could be doing something harmless such as printing the values of the elements in the lists. Is there anyway to pass subtypes of Number as the parameter for our method? Yes, by using the extends keyword. Here is a new version of the combine method that just prints the values of each element instead of combining them into a single list.

    public void combine2(List<? extends T> list1, List<? extends T> list2) {
        for (T t : list1) {
            System.out.println(t + " " + t.getClass());
        }

        for (T t : list2) {
            System.out.println(t + " " + t.getClass());
        }
    }

Now:

        
        List<Integer> integers = new ArrayList<>(List.of(1, 2, 3));
List<Number> numbers = new ArrayList<>(List.of(1, 2.0, 3));
myClass.combine2(integers, numbers2);

Output:


1 class java.lang.Integer
2 class java.lang.Integer
3 class java.lang.Integer
1 class java.lang.Integer
2.0 class java.lang.Double
3 class java.lang.Integer

Case 4
So we found a way to pass 2 lists of different subtypes to the combine method that the compiler will allow. Now can we use this same method declaration and use the body of combine1() which added elements from list2 to list1? Let’s try:

    public void combine3(List<? extends T> list1, List<? extends T> list2) {
        for (T t : list2) {
            list1.add(t);
        }

        for (T t : list1) {
            System.out.println(t + " " + t.getClass());
        }
    }

No luck. We get the compiler error: "incompatible types: T cannot be converted to capture#1 of ? extends T". What happens here is when make the type bounded for list1 it becomes an immutable list, precisely to avoid the problem of accidentally adding Doubles into a list of Integers. We could fix it by making list1 List<T>, but then we wouldn’t be able to pass it an argument of List<Integer> in the first place as shown previously in Case 2. This is the same reason why creating an ArrayList<Integer> of type List<Number> doesn’t work.

        List<Number> nums1 = new ArrayList<Integer>();

Won’t compile, but this will:


        List<? extends Number> nums2 = new ArrayList();

After instantiating nums1 as type List<Number>, we could potentially call add() and add Doubles to this list even though it really is fit only for Integers. nums2 is immutable, so we can instantiate it because it is guaranteed not to change therefore there is no possibility of ever trying to add Doubles to it. This guarantee is the basis which allows combine2() to compile. These examples show the PE in PECS: Producer Extends. Producers can also be thought as suppliers, and all they do is provide values and never consume them. Since any upper bound parameterized type is immutable, they are fit for supplying and can supply individual subtypes of the upper bounded type.
Case 5
The CS in PECS stands for Consumers Super. This is the lower bound that can be used on a wildcard with the syntax <? super T>. While List<? extends Number> is immutable, List<? super Number> is not and therefore we can add elements to it. Here’s a demonstration in which we add another List parameter to the combine method to serve as a destination for the elements from list1 and list2. This is how the new method looks:
​​    
public void combine4(List<? extends T> list1, List<? extends T> list2, List<? super T> dest) {

        for (T t : list1) {
            System.out.println(t + " " + t.getClass());
            dest.add(t);
        }

        for (T t : list2) {
            System.out.println(t + " " + t.getClass());
            dest.add(t);
        }
    }
​​

Now to run it:


        List<Number> numConsumer = new ArrayList<>();
myClass.combine4(integers, numbers2, numConsumer);
Output:

1 class java.lang.Integer
2 class java.lang.Integer
3 class java.lang.Integer
1 class java.lang.Integer
2.0 class java.lang.Double
3 class java.lang.Integer
It even works if we pass a List<Object> as an argument for dest:

1 class java.lang.Integer
2 class java.lang.Integer
3 class java.lang.Integer
1 class java.lang.Integer
2.0 class java.lang.Double
3 class java.lang.Integer
But if we didn’t use a lower bounded wildcard for dest, the program would not compile for the List<Object> argument:

public void combine4(List<? extends T> list1, List<? extends T> list2, List<T> dest)

        List<Object> objConsumer = 
        new ArrayList<>();
        myClass.combine4(integers, numbers2, objConsumer);
Output: "incompatible types: java.util.List<java.lang.Object> cannot be converted to java.util.List<java.lang.Number>"

Even an explicit cast to List<Number> on this argument won’t work:


myClass.combine4(integers, numbers2, (List<Number>)objConsumer);

Output:

"Inconvertible types; cannot cast 'java.util.List' to 'java.util.List'"

The lower bound works by allowing the type to be any class that T inherits from. In this case T becomes Number, which inherits from Object. Integers and Doubles are subclasses of Number, and Object, so they can safely be added to either List<Number> or List<Object>. This is why the super keyword can act as a consumer. However, it is important to note that it cannot act as a producer.

Case 6
We modify the combine method now to make dest act as both a consumer and producer. Elements will be added to it as before, and then we will iterate and print them out as if dest was a producer:
    public void combine5(List<? extends T> list1, List<? extends T> list2, List<? super T> dest) {

        for (T t : list1) {
            System.out.println(t + " " + t.getClass());
            dest.add(t);
        }

        for (T t : list2) {
            System.out.println(t + " " + t.getClass());
            dest.add(t);
        }

        for (T t : dest) {
            System.out.println(t);
        }
    }
​

It won’t compile because of the last for loop:

        for (T t : dest) {
            System.out.println(t);
        }

"Required type:
capture of ? super T
Provided:
T"
The compiler stops us here. If we pass List<Object>, which would be a legal argument for dest, and T is set as Number like when we instantiated the class, then we would potentially be returning something like a String, which is a subtype of Object, but obviously not of Number. The lower bound cannot give us any guarantee on the types of objects we read from the list so it cannot act as a producer. If we need a generic type to act as both a producer and consumer, then we cannot use any bound at all. In the case of List it needs to be List<T>. This provides the flexibility of being a producer or consumer, but we lose the flexibility of of using inheritance with the type parameters. In general, it is better practice to use a lower or upper bound as the parameterized type in the method if we know if it will be acting as either a producer or consumer and not both.

A quick review in summary:

  • PECS = Producer-Extends, Consumer-Super
  • No bounds on the type parameter such as List<T> allows it to be a producer or consumer. The downside is we can’t utilize the inheritance hierarchy on T.
  • List<? extends T> allows subclasses of T to be used as an argument. The downside is that the list becomes immutable.
  • List<? super T> allows T and any super class to be used as an argument. The downside is that there are no guarantees on the type contained so we can only put elements into it and cannot fetch them inside our method definition.

Leave a Reply

Your email address will not be published. Required fields are marked *