Generics in Java part 2 of 3
This article is the second part of the series Generics in Java
.
Link to the first part of the series:
Now that you know some basic syntax for Generics, as well as the motivation behind it, we can continue our journey to further expand our knowledge of Generics.
In this part, we are going to introduce the concept of type erasure, invariance, and restrictions of generics.
Raw Generic Types
We already mentioned that Generics is a purely compile-time feature of the Java programming language, meaning the Generic types do not exist in the runtime.
To keep Java backward compatible with the old versions, Generics allows you the usage of so-called Raw types.
Imagine that you were going to use a library that is written in Java 8, but your code is written in Java 4. We know that Generics appeared in Java 5, so you cannot use Generic types directly.
In this case, you can use Generic types without type arguments and still be able to include that library.
Raw type is a Generic type without type arguments.
Let’s show this with an example.
We know that the Collections framework has Generic types. One such example is the List
interface, which takes E
as a type parameter
.
You already know by now how to create List
of Integer
element type:
List<Integer> list = new ArrayList<>();
But you are not required to provide a type argument
, you can just leave it empty like this:
List list = new ArrayList();
What happens here is that the Object
type will be used as a type argument
.
If you have multiple type parameters for your Generic class, you cannot omit just one
type argument
. You must either provide alltype arguments
, or omit them all (use Raw Generic type).
This brings us back to our first solution in making class generic — by using the Object
type in a class. Compile time type safety is lost by using Raw Generics. Needless to say, this approach should be avoided.
Using raw types is dangerous!
Exceptions to not using Raw types
We already said that Raw types should be avoided, but sometimes there are cases in which you don’t have a choice but to use them:
- class literals (there is no
List<String>.class
per se, because generic types do not exist in runtime, so we must useList.class
instead) instanceof
operator (same reason as for class literals)
Type Erasure
Finally, we are going to look at how generics really work under the hood. What does it mean that generics are only compile-time features and they do not exist in runtime?
During compilation, the Java compiler validates types in our code to make sure we don’t use Generic types in the wrong way. This is the point where we would get a compilation error if we misuse types.
After thetype check
step, all type arguments
are erased. This leaves us with an Object
as a type where we had type parameters, just as we would have in a case we used a Raw type
.
In our Tuple
example, after the compilation the class would essentially behave like this:
public class Tuple {
private final Object firstMember;
private final Object secondMember;
public Tuple() {
firstMember = null;
secondMember = null;
}
public Tuple(final Object firstMember, final Object secondMember) {
this.firstMember = firstMember;
this.secondMember = secondMember;
}
public Object getFirstMember() {
return firstMember;
}
public Object getSecondMember() {
return secondMember;
}
}
Now you may ask but if types are replaced by Object
type, what happens to all places in the code where we used this Generic type? Are we going to lose the information about the type argument
that is used when this class was instantiated?
Well, it turns out that the compiler will insert an explicit type cast in all places that were using our generics type, so it will behave like this:
public static void firstConsumer() {
Tuple tuple = generateStringDateTuple();
String stringValue = (String) tuple.firstMember;
java.util.Date date = (java.util.Date) tuple.secondMember;
}
To summarize, the compiler feature Type Erasure
is doing two things after the compilation:
- erases all type parameters of generics, leaving
Object
as the type where type arguments were used - inserts explicit casting in places where generic types were used
In the final part of this series we will show that we can force Java compiler to use some type other than
Object
when doing type erasure by something calledbounds
.
Invariance in Java Generics
This section contains a bit of theory on the type systems, so feel free to skip it if you want. Here are the key takeaways from this section:
- Java Generics are
invariant
, meaning you cannot substitute supertype or subtype of the type argument. - Java Arrays are
covariant
, thus allowing for some type casting errors to slip to runtime - Because of these facts, it’s recommended to use Generics instead of Arrays in Java when dealing with collections
If you want to learn more details about invariance
and covariance
, just keep on reading 🙂.
Otherwise, you can skip to the section Restrictions of Generics
A brief overview of Variance
Let’s now discuss an important topic of variance in Java. This will get more theoretical, but you will find it quite rewarding as it deepens your understanding of the nature of the Generics
in Java.
As you already know, a type system in Java supports subtyping. It allows us to substitute a type with any of its subtypes.
But what happens if we have some type transformations, for example, arrays
and generics
? Do transformed types keep the same relations as their underlying types? Or do they somehow change that relation?
That behavior is defined by the variance
. Each type transformation
can have different variance
and we'll show an example soon.
It’s important to note that not all programming languages have the same
variance
for the sametype transformations
, but the concept is the same accross all languages. To give an example, in the OCaml programming language thelist
type constructor iscovariant
, whereas in Java theList
generic type constructor isinvariant
.
Types of variance
Let’s say that we have a type A
and it is a subtype of type B
. Also, we'll denote subtype relation with a≤
symbol.
If f
is a type transformation
, we can say the following:
f
iscovariant
ifA ≤ B
implies thatf(A) ≤ f(B)
f
iscontravariant
ifA ≤ B
implies thatf(B) ≤ f(A)
f
isbivariant
if both of these apply (i.e., ifA ≤ B
, implies thatf(A) ≡ f(B)
)f
isvariant
iff
iscovariant
,contravariant
orbivariant
f
isinvariant
ornonvariant
if notvariant
We’ll cover just what is relevant to our understanding of the Generics since this topic is huge and would require a series of articles for itself.
Java Array vs Java Generic
In Java, we have the following:
Generics
areinvariant
Arrays
arecovariant
Generics are invariant
Let’s walk through an example to understand the implications of these statements.
We’ll take Animal
and Dog
types.
Dog≤Animal
- we read this: Dog
is subtype of Animal
.
Since Generics
are invariant
, we know that:
List<Dog>
is not a subtype of aList<Animal>
List<Animal>
is not a subtype of aList<Dog>
Let’s see what would happen if the Generics were not invariant:
void go(ArrayList<Animal> animals) {
animals.add(new Cat());
}
go (new ArrayList<Dog>());
The ClassCast Exception
would be thrown in runtime, because we cannot cast Cat
to a Dog
, as we could to Animal
.
However, for the same type argument
, inheritance of generic types is allowed:
ArrayList<Dog> ≤ List<Dog>
- ArrayList<Dog>
is a subtype of List<Dog>
, meaning that ArrayList<Dog>
can be substituted where List<Dog>
is expected.
If you think about it, it makes perfect sense. Generic types are transformed to Raw types after compilation and they should behave in the same way as non-Generic types, meaning they should be covariant
as simple types are covariant
.
Since type parameters for Generics are invariant in Java, we can say that Generics have much stricter type safety than Arrays, which we’ll see in a moment.
Arrays are covariant
On the other hand, since Arrays
are covariant
:
Dog[] ≤ Animal[]
- an array of theDog
objects is a subtype of an array of Animal
objects
Because of that, we can do:
Animal[] animals = new Dog[1];
The same example as earlier:
void go(Animal[] animals) {
animals.add(new Cat());
}
go (new Dog[1]);
This will throw ArrayStoreException
(if the wrong type of object is stored in an array) in runtime.
If you wonder why Java Array is covariant, it’s a decision made by language designers to allow a different kind of polymorphism back in the days when there were no Generics.
Due to the
invariance
property of theGeneric
and thecovariance
property of theArray
in Java, it is strongly recommended to preferList
overArray
in Java code.
Restrictions of Generics
Let’s switch back to the practical aspect of Generics. Let’s have a look at some restrictions on Generics.
Type argument
cannot be a primitive
It’s easy to understand why, once you understand how Type Erasure
works. As we already showed, the Java compiler is doing Type Erasure during compilation, which means it replaces Generic Types with Object
(or some of its subclasses, as we'll show in the next part of the series).
Hence, primitive types could not be used as type arguments
.
Type parameters cannot be used in a static context
The easiest way to understand this statement is to go through an example:
public class Inventory<T> {
private static T inventoryType;
}
This code will fail to compile. Let’s understand why is that. Say we would like to make 2 Inventory instances, one of Packet
class and the other of Letter
class:
Inventory<Packet> packetInventory = new Inventory<>();
Inventory<Letter> letterInventory = new Inventory<>();
What should the static field of Inventory
class be? Is it Packet
or Letter
?
And what if we didn’t create an instance of Inventory
class at all?
Now it should be obvious that the type parameter T
could not be resolved correctly in the case of the static field.
For the same reason, type parameters cannot be used in static methods and static initializers.
Method overloading with different type arguments
This is impossible due to Type Erasure:
public List<String> get();
public List<Integer> get();
After type erasure, we would effectively get:
public List get();
public List get();
And it’s invalid to have two methods with exactly the same signature
Method overloading with type parameter and Object type
This is invalid due to type erasure, as methods would have exactly the same signature:
class GenericsExample <T> {
public T get(T t){
return t;
}
public Object get(Object t) {
return t;
}
}
You should be able by now to figure out why this won’t compile 🙂.
Method overloading with type parameter and concrete type
Lastly, one tricky example.
This class definition is valid since after type erasure we don’t have a clash of method signatures
class GenericsExample <T> {
public T get(T t){
return t;
}
public String get(String t) {
return t;
}
public String getExtended(String t) {
return t + " extended";
}
}
If you instantiate this class with an Integer
type argument, everything will be fine. Java compiler will effectively produce two get
methods with Object
and String
as a parameter and return type. Also, every call to the get
method of the Object
type is explicitly type-casted with the Integer
class. There is a clear distinction between calls to those methods.
But let’s take a look at the case when you instantiate this class with String
type argument.
Since type erasure will replace T
with Object
there is no method signatures collision as we've seen before, as we'll have:
public Object get(Object t){
return t;
}
public String get(String t) {
return t;
}
And those method signatures are different.
But if there is an invocation of the get
method in your code, a compiler will try to do explicit casting and at that point, it realizes that both Object
and String
versions of theget
method would be eligible for this code.
So the compiler will give you a compilation error exactly in places where you use the `get` method as it is ambiguous.
Nonetheless, as we said you can instantiate this class with the String
type argument and use other methods that are not ambiguous at the compile time, such as getExtended
in our example.
To summarize:
- this class definition is valid
- instantiation with any
type argument
is valid, even if that type is seemingly causing method signature collision - you can use all the methods from such
Generic
class, except for the one that causes method signature collision
It is strongly recommended not to use such class definitions that causes compilation errors for certain type arguments only. At least you should provide distinct method names to mitigate the risk of falling into this kind of situations.
Link to the third and final part of the series:
Originally published at https://borkorajkovic.com on October 5, 2022.