47. Exemplify erasure vs. overloading
Before we join them in an example, let’s quickly tackle erasure and overloading separately.
Erasure in a nutshell
Java uses type erasure at compile time in order to enforce type constraints and backward compatibility with old bytecode. Basically, at compilation time, all type arguments are replaced by Object
(any generic must be convertible to Object
) or type bounds (extends
or super
). Next, at runtime, the type erased by the compiler will be replaced by our type. A common case of type erasure implies generics.
Erasure of generic types
Practically, the compiler erases the unbound types (such as E
, T
, U
, and so on) with the bounded Object
. This enforces type safety, as in the following example of class type erasure:
public class ImmutableStack<E> implements Stack<E> {
private final E head;
private final Stack<E> tail;
...
The compiler applies type erasure to replace E
with Object
:
public class ImmutableStack<Object> implements Stack<Object> {
private final Object head;
private final Stack<Object> tail;
...
If the E
parameter is bound, then the compiler uses the first bound class. For instance, in a class such as class Node<T extends Comparable<T>> {...}
, the compiler will replace T
with Comparable
. In the same manner, in a class such as class Computation<T extends Number> {...}
, all occurrences of T
would be replaced by the compiler with the upper bound Number
.
Check out the following case, which is a classical case of method type erasure:
public static <T, R extends T> List<T> listOf(T t, R r) {
List<T> list = new ArrayList<>();
list.add(t);
list.add(r);
return list;
}
// use this method
List<Object> list = listOf(1, "one");
How does this work? When we call listOf(1, "one")
, we are actually passing two different types to the generic parameters T
and R
. The compiler type erasure has replaced T
with Object
. In this way, we can insert different types in the ArrayList
and the code works just fine.
Erasure and bridge methods
Bridge methods are created by the compiler to cover corner cases. Specifically, when the compiler encounters an implementation of a parameterized interface or an extension of a parameterized class, it may need to generate a bridge method (also known as a synthetic method) as part of the type erasure phase. For instance, let’s consider the following parameterized class:
public class Puzzle<E> {
public E piece;
public Puzzle(E piece) {
this.piece = piece;
}
public void setPiece(E piece) {
this.piece = piece;
}
}
And, an extension of this class:
public class FunPuzzle extends Puzzle<String> {
public FunPuzzle(String piece) {
super(piece);
}
@Override
public void setPiece(String piece) {
super.setPiece(piece);
}
}
Type erasure modifies Puzzle.setPiece(E)
as Puzzle.setPiece(Object)
. This means that the FunPuzzle.setPiece(String)
method does not override the Puzzle.setPiece(Object)
method. Since the signatures of the methods are not compatible, the compiler must accommodate the polymorphism of generic types via a bridge (synthetic) method meant to guarantee that sub-typing works as expected. Let’s highlight this method in the code:
/* Decompiler 8ms, total 3470ms, lines 18 */
package modern.challenge;
public class FunPuzzle extends Puzzle<String> {
public FunPuzzle(String piece) {
super(piece);
}
public void setPiece(String piece) {
super.setPiece(piece);
}
// $FF: synthetic method
// $FF: bridge method
public void setPiece(Object var1) {
this.setPiece((String)var1);
}
}
Now, whenever you see a bridge method in the stack trace, you will know what it is and why it is there.
Type erasure and heap pollution
Have you ever seen an unchecked warning? I’m sure you have! It’s one of those things that is common to all Java developers. They may occur at compile-time as the result of type checking, or at runtime as a result of a cast or method call. In both cases, we talk about the fact that the compiler cannot validate the correctness of an operation, which implies some parameterized types. Not every unchecked warning is dangerous, but there are cases when we have to consider and deal with them.
A particular case is represented by heap pollution. If a parameterized variable of a certain type points to an object that is not of that type, then we are prone to deal with a code that leads to heap pollution. A good candidate for such scenarios involves methods with varargs
arguments.
Check out this code:
public static <T> void listOf(List<T> list, T... ts) {
list.addAll(Arrays.asList(ts));
}
The listOf()
declaration will cause this warning: Possible heap pollution from parameterized vararg type T. So, what’s happening here?
The story begins when the compiler replaces the formal T...
parameter into an array. After applying type erasure, the T...
parameter becomes T[]
, and finally Object[]
. Consequently, we opened a gate to possible heap pollution. But, our code just added the elements of Object[]
into a List<Object>
, so we are in the safe area.
In other words, if you know that the body of the varargs
method is not prone to generate a specific exception (for example, ClassCastException
) or to use the varargs
parameter in an improper operation, then we can instruct the compiler to suppress these warnings. We can do it via the @SafeVarargs
annotation as follows:
@SafeVarargs
public static <T> void listOf(List<T> list, T... ts) {...}
The @SafeVarargs
is a hint that sustains that the annotated method will use the varargs
formal parameter only in proper operations. More common, but less recommended, is to use @SuppressWarnings({"unchecked", "varargs"})
, which simply suppresses such warnings without claiming that the varargs
formal parameter is not used in improper operations.
Now, let’s tackle this code:
public static void main(String[] args) {
List<Integer> ints = new ArrayList<>();
Main.listOf(ints, 1, 2, 3);
Main.listsOfYeak(ints);
}
public static void listsOfYeak(List<Integer>... lists) {
Object[] listsAsArray = lists;
listsAsArray[0] = Arrays.asList(4, 5, 6);
Integer someInt = lists[0].get(0);
listsAsArray[0] = Arrays.asList("a", "b", "c");
Integer someIntYeak = lists[0].get(0); // ClassCastException
}
This time, the type erasure transforms the List<Integer>...
into List[]
, which is a subtype of Object[]
. This allows us to do the assignment: Object[] listsAsArray = lists;
. But, check out the last two lines of code where we create a List<String>
and store it in listsAsArray[0]
. In the last line, we try to access the first Integer
from lists[0]
, which obviously leads to a ClassCastException
. This is an improper operation of using varargs
, so it is not advisable to use @SafeVarargs
in this case. We should have taken the following warnings seriously:
// unchecked generic array creation for varargs parameter
// of type java.util.List<java.lang.Integer>[]
Main.listsOfYeak(ints);
// Possible heap pollution from parameterized vararg
// type java.util.List<java.lang.Integer>
public static void listsOfYeak(List<Integer>... lists) { ... }
Now, that you are familiar with type erasure, let’s briefly cover polymorphic overloading.
Polymorphic overloading in a nutshell
Since overloading (also known as “ad hoc” polymorphism) is a core concept of Object-Oriented Programming (OOP), I’m sure you are familiar with Java method overloading, so I’ll not insist on the basic theory of this concept.
Also, I’m aware that some people don’t agree that overloading can be a form of polymorphism, but that is another topic that we will not tackle here.
We will be more practical and jump into a suite of quizzes meant to highlight some interesting aspects of overloading. More precisely, we will discuss type dominance. So, let’s tackle the first quiz (wordie
is an initially empty string):
static void kaboom(byte b) { wordie += "a";}
static void kaboom(short s) { wordie += "b";}
kaboom(1);
What will happen? If you answered that the compiler will point out that there is no suitable method found for kaboom(1)
, then you’re right. The compiler looks for a method that gets an integer argument, kaboom(int)
. Okay, that was easy! Here is the next one:
static void kaboom(byte b) { wordie += "a";}
static void kaboom(short s) { wordie += "b";}
static void kaboom(long l) { wordie += "d";}
static void kaboom(Integer i) { wordie += "i";}
kaboom(1);
We know that the first two kaboom()
instances are useless. How about kaboom(long)
and kaboom(Integer)
? You are right, kaboom(long)
will be called. If we remove kaboom(long)
, then kaboom(Integer)
is called.
Important note
In primitive overloading, the compiler starts by searching for a one-to-one match. If this attempt fails, then the compiler searches for an overloading flavor taking a primitive broader domain than the primitive current domain (for instance, for an int
, it looks for int
, long
, float
, or double
). If this fails as well, then the compiler checks for overloading taking boxed types (Integer
, Float
, and so on).
Following the previous statements, let’s have this one:
static void kaboom(Integer i) { wordie += "i";}
static void kaboom(Long l) { wordie += "j";}
kaboom(1);
This time, wordie
will be i
. The kaboom(Integer)
is called since there is no kaboom(int
/long
/float
/double)
. If we had a kaboom(double)
, then that method has higher precedence than kaboom(Integer)
. Interesting, right?! On the other hand, if we remove kaboom(Integer)
, then don’t expect that kaboom(Long)
will be called. Any other kaboom(boxed type)
with a broader/narrow domain than Integer
will not be called. This is happening because the compiler follows the inheritance path based on an IS-A relationship, so after kaboom(Integer)
, it looks for kaboom(Number)
, since Integer
is a Number
.
Important note
In boxed type overloading, the compiler starts by searching for a one-to-one match. If this attempt fails, then the compiler will not consider any overloading flavor taking a boxed type with a broader domain than the current domain (of course, a narrow domain is ignored as well). It looks for Number
as being the superclass of all boxed types. If Number
is not found, the compiler goes up in the hierarchy until it reaches the java.lang.Object
, which is the end of the road.
Okay, let’s complicate things a little bit:
static void kaboom(Object... ov) { wordie += "o";}
static void kaboom(Number n) { wordie += "p";}
static void kaboom(Number... nv) { wordie += "q";}
kaboom(1);
So, which method will be called this time? I know, you think kaboom(Number)
, right? At least, my simple logic pushes me to think that this is a common-sense choice. And it is correct!
If we remove kaboom(Number)
, then the compiler will call the varargs
method, kaboom(Number...)
. This makes sense since kaboom(1)
uses a single argument, so kaboom(Number)
should have higher precedence than kaboom(Number...)
. This logic reverses if we call kaboom(1,2,3)
since kaboom(Number)
is no longer representing a valid overloading for this call, and kaboom(Number...)
is the right choice.
But, this logic applies because Number
is the superclass of all boxed classes (Integer
, Double
, Float
, and so on).
How about now?
static void kaboom(Object... ov) { wordie += "o";}
static void kaboom(File... fv) { wordie += "s";}
kaboom(1);
This time, the compiler will “bypass” kaboom(File...)
and will call kaboom(Object...)
. Based on the same logic, a call of kaboom(1, 2, 3)
will call kaboom(Object...)
since there is no kaboom(Number...)
.
Important note
In overloading, if the call has a single argument, then the method with a single argument has higher precedence than its varargs
counterpart. On the other hand, if the call has more arguments of the same type, then the varargs
method is called since the one-argument method is not suitable anymore. When the call has a single argument but only the varargs
overloading is available, then this method is called.
This leads us to the following example:
static void kaboom(Number... nv) { wordie += "q";}
static void kaboom(File... fv) { wordie += "s";}
kaboom();
This time, kaboom()
has no arguments and the compiler cannot find a unique match. This means that the reference to kaboom()
is ambiguous since both methods match (kaboom(java.lang.Number...)
in modern.challenge.Main
and method kaboom(java.io.File...)
in modern.challenge.Main
).
In the bundled code, you can play even more with polymorphic overloading and test your knowledge. Moreover, try to challenge yourself and introduce generics in the equation as well.
Erasure vs. overloading
Okay, based on the previous experience, check out this code:
void print(List<A> listOfA) {
System.out.println("Printing A: " + listOfA);
}
void print(List<B> listofB) {
System.out.println("Printing B: " + listofB);
}
What will happen? Well, this is a case where overloading and type erasure collide. The type erasure will replace List<A>
with List<Object>
and List<B>
with List<Object>
as well. So, overloading is not possible and we get an error such as name clash: print(java.util.List<modern.challenge.B>) and print (java.util.List<modern.challenge.A>) have the same erasure.
In order to solve this issue, we can add a dummy argument to one of these two methods:
void print(List<A> listOfA, Void... v) {
System.out.println("Printing A: " + listOfA);
}
Now, we can have the same call for both methods:
new Main().print(List.of(new A(), new A()));
new Main().print(List.of(new B(), new B()));
Done! You can practice these examples in the bundled code.