DEV Community

Cover image for Java Generics — Advanced Cases
Semyon Kirekov
Semyon Kirekov

Posted on

Java Generics — Advanced Cases

Today we are going to discuss how to implement generics in your code in a most effective way.

Don't Use Raw Types

This statement seems obvious. Raw types break the whole idea of generics. Its usage doesn't allow the compiler to detect type errors. But that’s not the only problem. Suppose we have such class.

class Container<T> {
    private final T value;
    ...
    public List<Integer> getNumbers() {
        return numbersList;
    }
}
Enter fullscreen mode Exit fullscreen mode

Assume that we don’t care about the generic type itself. All we need to do is to traverse numbersList.

public void traverseNumbersList(Container container) {
    for (int num : container.getNumbers()) {
        System.out.println(num);
    }
}
Enter fullscreen mode Exit fullscreen mode

Surprisingly this code doesn’t compile.

error: incompatible types: Object cannot be converted to int
        for (int num : container.getNumbers()) {
                                           ^
Enter fullscreen mode Exit fullscreen mode

The thing is that the raw type's usage erases not only the information about the generic type of a class but even predefined ones. So, List<Integer> becomes just List.

What can we do about it? The answer is straightforward. If you don’t care about the generic type, use the wildcard operator.

public void traverseNumbersList(Container<?> container) {
    for (int num : container.getNumbers()) {
        System.out.println(num);
    }
}
Enter fullscreen mode Exit fullscreen mode

This code snippet works perfectly fine.

Prefer Wildcard-Based Inputs

The main difference between arrays and generics is that arrays are covariant while generics are not. It means that Number[] is a supertype for Integer[]. And Object[] is a supertype for any array (except primitive ones). That seems logical, but it may lead to bugs at runtime.

Number[] nums = new Long[3];
nums[0] = 1L;
nums[1] = 2L;
nums[2] = 3L;
Object[] objs = nums;
objs[2] = "ArrayStoreException happens here";
Enter fullscreen mode Exit fullscreen mode

This code does compile but it throws an unexpected exception. Generics were brought to solve this problem.

List<Number> nums = new ArrayList<Number>();
List<Long> longs = new ArrayList<Long>();
nums = longs;   // compilation error
Enter fullscreen mode Exit fullscreen mode

List<Long> cannot be assigned to List<Number>. Though it helps us to avoid ArrayStoreException, it also puts bounds that can make API not flexible and too strict.

interface Employee {
    Money getSalary();
}

interface EmployeeService {
    Money calculateAvgSalary(Collection<Employee> employees);
}
Enter fullscreen mode Exit fullscreen mode

Everything looks good, isn’t it? We have even put Collection providently as an input parameter. That allows us to pass List, Set, Queue, etc. But don’t forget that Employee is just an interface. What if we worked with collections of particular implementations? For example, List<Manager> or Set<Accountant>? We couldn't pass them directly. So, it would require to shift the elements to the collection of Employee type each time.

Or we can use the wildcard operator.

interface EmployeeService {
    Money calculateAvgSalary(Collection<? extends Employee> employees);
}

List<Manager> managers = ...;
Set<Accountant> accountants = ...;
Collection<SoftwareEngineer> engineers = ...;

// All these examples compile successfully
employeeService.calculateAvgSalary(managers);
employeeService.calculateAvgSalary(accountants);
employeeService.calculateAvgSalary(engineers);
Enter fullscreen mode Exit fullscreen mode

As you can see, the proper generic usage makes the life of a programmer much easier. Let’s see another example.

Suppose we need to declare an API for the sorting service. Here is the first naive attempt.

interface SortingService {
    <T> void sort(List<T> list, Comparator<T> comparator); 
}
Enter fullscreen mode Exit fullscreen mode

Now we’ve got a different kind of a problem. We have to be sure that Comparator was created exactly for the T type. But that’s not always true. We could build a universal one for Employee which wouldn't work for either Accountant or Manager in this case.

Let’s make the API a bit better.

interface SortingService {
    <T> void sort(List<T> list, Comparator<? super T> comparator); 
}

// universal comparator
Comparator<Employee> comparator = ...;

List<Manager> managers = ...;
List<Accountant> accountants = ...;
List<SoftwareEngineer> engineers = ...;

// All these examples compile successfullly
sortingService.sort(managers, comparator);
sortingService.sort(accountants, comparator);
sortingService.sort(engineers, comparator);
Enter fullscreen mode Exit fullscreen mode

You know, the constraints are a little bit confusing. All these ? extends T and ? super T seems overcomplicated. Thankfully there is an easy rule that can help to identify the correct usage — PECS (producer-extends, consumer-super). It means that a producer should be of type ? extends T while consumer of ? super T one.

Let’s take a look at particular examples. The method MoneyService.calculateAvgSalary that we described earlier accepts a producer. Because the collection produces elements that are used for further computations.

Another example comes right from the JDK standard library. I’m talking about Collection.addAll method.

interface Collection<E> {
    boolean addAll(Collection<? extends E> c);
    ...
}
Enter fullscreen mode Exit fullscreen mode

Defining upper bound generic allows us to concatenate Collection<Employee> and Collection<Manager> or any other classes that share the same interface.

What about consumers? Comparator that we used in SortingService is a perfect example. This interface has one method that accepts a generic type and returns a concrete one. A typical example of a consumer. Other ones are Predicate, Consumer, Comparable, and many others from java.util package. Mostly all of these interfaces should be used with ? super T bound.

There is also a unique one that is a producer and a consumer at the same time. It’s java.util.Function. It converts the input value from one type to another. So, the commonFunction usage is Function<? super T, ? extends R>. That may look scary but it really helps to build robust software. You can find out that all mapping functions in Stream interface follow this rule.

interface Stream<T> extends BaseStream<T, Stream<T>> {
    <R> Stream<R> map(Function<? super T, ? extends R> mapper);
    <R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper);
    IntStream flatMapToInt(Function<? super T, ? extends IntStream> mapper);
    ...
}
Enter fullscreen mode Exit fullscreen mode

One may notice that SortingService.sort accepts List<T> instead
of List<? extends T>. Why is it so? This is a producer after
all. Well, the thing is that upper and lowerbounds make sense in
comparing to the predefined type. Because SortingService.sort
method parameterizes itself, there is no sense to restrict List
with additional bounds. On the other hand, if SortingService had a generic type, ? extends T would have its value.

interface SortingService<T> {
    void sort(List<? extends T> list, Comparator<? super T> comparator); 
}
Enter fullscreen mode Exit fullscreen mode

Don't Return Bounded Containers

Upper Bounds

Some developers that discovered the power of bounded generic types may consider that it’s a silver bullet. That can lead to the code snippets like the next one.

interface EmployeeRepository {
    List<? extends Employee> findEmployeesByNameLike(String nameLike);
}
Enter fullscreen mode Exit fullscreen mode

What’s wrong here? Firstly, List<? extends Employee> cannot be assigned to List<Employee> without casting. More than that, this upper bound puts restrictions which are not obvious.

For example, values of what type can we put inside the collection returned by EmployeeRepository.findEmployeesByNameLike(String)? You may suggest that it’s something like Accountant, Manager, SoftwareEngineer, and so on. But it’s a wrong assumption.

List<? extends Employee> employees = 
employeeRepository.findEmployeesByNameLike(nameLike);

employees.add(new Accountant());        // compile error
employees.add(new SoftwareEngineer());  // compile error
employees.add(new Manager());           // compile error
employees.add(null);                    // passes successfully 👍
Enter fullscreen mode Exit fullscreen mode

This code snippet looks counter-intuitive but in reality, everything works just fine. Let’s deconstruct this case. First of all, we need to determine what collections can be assigned to List<? extends Employee>.

List<? extends Employee> accountants = new ArrayList<Accountant>();
List<? extends Employee> managers    = new ArrayList<Manager>();
List<? extends Employee> engineers   = new ArrayList<SoftwareEngineer>();
// ...any other type that extends from Employee 
Enter fullscreen mode Exit fullscreen mode

So, basically list of any type that inherits from Employee can be assigned to List<? extends Employee>. This makes adding new items tricky. The compiler cannot be aware of the exact type of the list. That’s why it forbids to add any items in order to eliminate potential heap pollution. But null is a special case. This value does not have its own type. It can be assigned to anything (except primitives). It is the reason why null is the only allowed value to add.

What about retrieving items from the list?

List<? extends Employee> employees = ...;

// passes successfully 👍
for (Employee e : employees) {
    System.out.println(e);
}
Enter fullscreen mode Exit fullscreen mode

Emloyee is a supertype for any potential element the list may contain. No caveats here.

Lower Bounds

What element can we add to List<? super Employee>? The logic tells us that it's either Object or Employee. And it fools us again.

List<? super Employee> employees = ...;

employees.add(new Accountant());       // passes successfully 👍
employees.add(new Manager());          // passes successfully 👍
employees.add(new SoftwareEngineer()); // passes successfully 👍
employees.add(
    new Employee(){/*implementation*/} // passes successfully 👍
);     
employees.add(new Object());           // compile error
Enter fullscreen mode Exit fullscreen mode

Again, to figure out this case let’s find out what collections can be assigned to List<? super Employee>.

List<? super Employee> employees = new ArrayList<Employee>();
List<? super Employee> objects   = new ArrayList<Object>();
Enter fullscreen mode Exit fullscreen mode

The compiler knows that the list can consist of either Object types or Employee ones. That’s why Accountant, Manager, SoftwareEngineer, and Employee can be safely added. They all implement Employee interface and inherits from Object class. At the same time, Object cannot be added because it does not implement Employee.

On the contrary, reading from List<? super Employee> is not so easy.

List<? super Employee> employees = ...;

// compile error
for (Employee e : employees) {
    System.out.println(e);
}
Enter fullscreen mode Exit fullscreen mode

The compiler cannot be sure that returned item is of Employee type. Perhaps it is an Object. That’s why the code does not compile.

Upper-Lower Bounds Conclusion

We can resume that upper bound make a collection read-only while lower bound make it write-only. Does it mean that we can use them as return types in order to restrict client’s access to data manipulation? I wouldn’t recommend to do it.

Upper bound collections are not completely read-only because you can still add null to them. Lower bound collections are not completely write-only because you can still read values as an Object. I consider that it’s much better to use special containers that shall give the required access to an instance. You can either apply standard JDK utilities like Collections.unmodifiableList or use libraries that will do the job (Vavr, for instance).

Upper and lower bound collections act much better as input parameters. You should not mix them with return types.

Recursive Generics

We’ve already mentioned recursive generics in this article. It’s the Stream interface. Let’s take a look again.

interface Stream<T> extends BaseStream<T, Stream<T>> {
    <R> Stream<R> map(Function<? super T, ? extends R> mapper);
    <R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper);
    IntStream flatMapToInt(Function<? super T, ? extends IntStream> mapper);
    ...
}
Enter fullscreen mode Exit fullscreen mode

As you can see, Stream extends from BaseStream that is parameterized with Stream itself. What’s the reason for it? Let’s dive into BaseStream to find out.

public interface BaseStream<T, S extends BaseStream<T, S>> extends AutoCloseable {  
    S sequential();
    S parallel();
    S unordered();
    S onClose(Runnable closeHandler);
    Iterator<T> iterator();
    Spliterator<T> spliterator();
    boolean isParallel();
    void close();
}
Enter fullscreen mode Exit fullscreen mode

BaseStream is a typical example of the fluent API but instead of returning the type itself methods return S extends BaseStream<T, S>. Let’s imagine that BaseStream was designed without this feature.

public interface BaseStream<T> extends AutoCloseable {  
    BaseStream<T> sequential();
    BaseStream<T> parallel();
    BaseStream<T> unordered();
    BaseStream<T> onClose(Runnable closeHandler);
    Iterator<T> iterator();
    Spliterator<T> spliterator();
    boolean isParallel();
    void close();
}
Enter fullscreen mode Exit fullscreen mode

How would it affect the whole Stream API?

List<Employee> employees = ...;
employees.stream()
         .map(Employee::getSalary)
         .parallel()
         .reduce(0L, (acc, next) -> acc + next);
          // compile error ⛔: cannot find symbol
Enter fullscreen mode Exit fullscreen mode

The reduce method belongs to Stream but not to BaseStream interface. Therefore parallel method returns BaseStream. So, reduce cannot be found. This becomes clearer in the schema below.

image

Recursive generics come in handy in this situation.

image

This approach allows us to segregate interfaces that leads to better maintainability and readability.

Conclusion

I hope that you’ve learned something new about Java generics. If you have any questions or suggestions, please leave your comments down below. Thanks for reading!

Resources

Discussion (0)