Java Generics — Advanced Cases
Semyon Kirekov
Posted on September 20, 2021
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;
}
}
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);
}
}
Surprisingly this code doesn’t compile.
error: incompatible types: Object cannot be converted to int
for (int num : container.getNumbers()) {
^
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);
}
}
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";
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
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);
}
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);
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);
}
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);
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);
...
}
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);
...
}
One may notice that
SortingService.sort
acceptsList<T>
instead
ofList<? 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. BecauseSortingService.sort
method parameterizes itself, there is no sense to restrictList
with additional bounds. On the other hand, ifSortingService
had a generic type,? extends T
would have its value.
interface SortingService<T> {
void sort(List<? extends T> list, Comparator<? super T> comparator);
}
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);
}
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 👍
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
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);
}
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
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>();
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);
}
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);
...
}
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();
}
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();
}
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
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.
Recursive generics come in handy in this situation.
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
Posted on September 20, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 29, 2024