Hi! We continue our series of lessons on generics. We previously got a general idea of what they are and why they are needed. Today we'll learn more about some of the features of generics and about working with them. Let's go!
In the last lesson, we talked about the difference between generic types and raw types. A raw type is a generic class whose type has been removed.
The documentation says, "T - the type of the class modeled by this Class object."
Translating this from the language of documentation to plain speech, we understand that the class of the
List list = new ArrayList();
Here is an example. Here we do not indicate what type of objects will be placed in our List
.
If we try to create such a List
and add some objects to it, we'll see a warning in IDEA:
"Unchecked call to add(E) as a member of raw type of java.util.List".
But we also talked about the fact that generics appeared only in Java 5. By the time this version was released, programmers had already written a bunch of code using raw types, so this feature of the language could not stop working, and the ability to create raw types in Java was preserved.
However, the problem turned out to be more widespread.
As you know, Java code is converted to a special compiled format called bytecode, which is then executed by the Java virtual machine.
But if we put information about type parameters in the bytecode during the conversion process, it would break all the previously written code, because there were no type parameters before Java 5!
When working with generics, there is one very important concept that you need to remember. It is called type erasure.
It means that a class contains no information about a type parameter.
This information is available only during compilation and is erased (becomes inaccessible) before runtime.
If you try to put the wrong type of object in your List<String>
, the compiler will generate an error. This is exactly what the language's creators want to achieve when they created generics: compile-time checks.
But when all your Java code turns into bytecode, it no longer contains information about type parameters.
In bytecode, your List<Cat>
cats list is no different than List<String>
strings. In bytecode, nothing says that cats
is a list of Cat
objects. Such information is erased during compilation — only the fact that you have a List<Object> cats
list will end up in the program's bytecode.
Let's see how this works:
public class TestClass<T> {
private T value1;
private T value2;
public void printValues() {
System.out.println(value1);
System.out.println(value2);
}
public static <T> TestClass<T> createAndAdd2Values(Object o1, Object o2) {
TestClass<T> result = new TestClass<>();
result.value1 = (T) o1;
result.value2 = (T) o2;
return result;
}
public static void main(String[] args) {
Double d = 22.111;
String s = "Test String";
TestClass<Integer> test = createAndAdd2Values(d, s);
test.printValues();
}
}
We created our own generic TestClass
class.
It is quite simple: it's actually a small "collection" of 2 objects, which are stored immediately when the object is created. It has 2 T
fields.
When the createAndAdd2Values()
method is executed, the two passed objects (Object a
and Object b
must be cast to the T
type and then added to the TestClass
object.
In the main()
method, we create a TestClass<Integer>
, i.e. the Integer
type argument replaces the Integer
type parameter.
We are also passing a Double
and a String
to the createAndAdd2Values()
method.
Do you think our program will work? After all, we specified Integer
as the type argument, but a String
definitely cannot be cast to an Integer
!
Let's run the main()
method and check.
Console output:
22.111
Test String
That was unexpected! Why did this happen?
It's the result of type erasure.
Information about the Integer
type argument used to instantiate our TestClass<Integer> test
object was erased when the code was compiled.
The field becomes TestClass<Object> test
.
Our Double
and String
arguments were easily converted to Object
objects (they are not converted to Integer
objects as we expected!) and quietly added to TestClass
.
Here's another simple but very revealing example of type erasure:
import java.util.ArrayList;
import java.util.List;
public class Main {
private class Cat {
}
public static void main(String[] args) {
List<String> strings = new ArrayList<>();
List<Integer> numbers = new ArrayList<>();
List<Cat> cats = new ArrayList<>();
System.out.println(strings.getClass() == numbers.getClass());
System.out.println(numbers.getClass() == cats.getClass());
}
}
Console output:
true
true
It seems we created collections with three different types arguments — String
, Integer
, and our very own Cat
class.
But during the conversion to bytecode, all three lists become List<Object>
, so when the program runs it tells us that we are using the same class in all three cases.
Type erasure when working with arrays and generics
There's a very important point that must be clearly understood when working with arrays and generic classes (such asList
). You should also take it into consideration when choosing data structures for your program.
Generics are subject to type erasure. Information about type parameters is not available at runtime.
By contrast, arrays know about and can use information about their data type when the program is running.
Attempting to put an invalid type into an array will cause an exception to be thrown:
public class Main2 {
public static void main(String[] args) {
Object x[] = new String[3];
x[0] = new Integer(222);
}
}
Console output:
Exception in thread "main" java.lang.ArrayStoreException: java.lang.Integer
Because there's such a big difference between arrays and generics, they might have compatibility issues.
Above all, you cannot create an array of generic objects or even just a parameterized array.
Does that sound a bit confusing?
Let's take a look.
For example, you cannot do any of this in Java:
new List<T>[]
new List<String>[]
new T[]
If we try to create an array of List<String>
objects, we get a compilation error that complains about generic array creation:
import java.util.List;
public class Main2 {
public static void main(String[] args) {
// Compilation error! Generic array creation
List<String>[] stringLists = new List<String>[1];
}
}
But why is this done? Why is creation of such arrays not allowed? This is all to provide type safety.
If the compiler let us create such arrays of generic objects, we could make a ton of problems for ourselves.
Here is a simple example from Joshua Bloch's book "Effective Java":
public static void main(String[] args) {
List<String>[] stringLists = new List<String>[1]; // (1)
List<Integer> intList = Arrays.asList(42, 65, 44); // (2)
Object[] objects = stringLists; // (3)
objects[0] = intList; // (4)
String s = stringLists[0].get(0); // (5)
}
Let's imagine that creating an array like List<String>[] stringLists
is allowed and won't generate a compilation error.
If this were true, here are some things we could do:
In line 1, we create an array of lists: List<String>[] stringLists
. Our array contains one List<String>
.
In line 2, we create a list of numbers: List<Integer>
.
In line 3, we assign our List<String>[]
to an Object[] objects
variable. The Java language allows this: an array of X
objects can store X
objects and objects of all subclasses X
. Accordingly, you can put anything at all in an Object
array.
In line 4, we replace the only element of the objects()
array (a List<String>
) with a List<Integer>
.
Thus, we put a List<Integer>
in an array that was only intended to store List<String>
objects!
We will encounter an error only when we execute line 5. A ClassCastException
will be thrown at runtime.
Accordingly, a prohibition on the creation of such arrays was added to Java. This lets us avoid such situations.
How can I get around type erasure?
Well, we learned about type erasure. Let's try to trick the system! :) Task: We have a genericTestClass<T>
class. We want to write a createNewT()
method for this class that will create and return a new T
object.
But this is impossible, right? All information about the T
type is erased during compilation, and at runtime we cannot determine what type of object we need to create.
There is actually one tricky way to do this.
You probably remember that Java has a Class
class. We can use it to determine the class of any of our objects:
public class Main2 {
public static void main(String[] args) {
Class classInt = Integer.class;
Class classString = String.class;
System.out.println(classInt);
System.out.println(classString);
}
}
Console output:
class java.lang.Integer
class java.lang.String
But here's one aspect that we have not talked about. In the Oracle documentation, you'll see that the Class class is generic!
https://docs.oracle.com/javase/8/docs/api/java/lang/Class.html
Integer.class
object is not just Class
, but rather Class<Integer>
. The type of the String.class
object is not just Class
, but rather Class<String>
, etc.
If it's still not clear, try adding a type parameter to the previous example:
public class Main2 {
public static void main(String[] args) {
Class<Integer> classInt = Integer.class;
// Compilation error!
Class<String> classInt2 = Integer.class;
Class<String> classString = String.class;
// Compilation error!
Class<Double> classString2 = String.class;
}
}
And now, using this knowledge, we can bypass type erasure and accomplish our task!
Let's try to get information about a type parameter. Our type argument will be MySecretClass
:
public class MySecretClass {
public MySecretClass() {
System.out.println("A MySecretClass object was created successfully!");
}
}
And here is how we use our solution in practice:
public class TestClass<T> {
Class<T> typeParameterClass;
public TestClass(Class<T> typeParameterClass) {
this.typeParameterClass = typeParameterClass;
}
public T createNewT() throws IllegalAccessException, InstantiationException {
T t = typeParameterClass.newInstance();
return t;
}
public static void main(String[] args) throws InstantiationException, IllegalAccessException {
TestClass<MySecretClass> testString = new TestClass<>(MySecretClass.class);
MySecretClass secret = testString.createNewT();
}
}
Console output:
A MySecretClass object was created successfully!
We just passed the required class argument to the constructor of our generic class:
TestClass<MySecretClass> testString = new TestClass<>(MySecretClass.class);
This allowed us to save the information about the type argument, preventing it from being entirely erased. As a result, we were able to create a T
object! :)
With that, today's lesson comes to an end. You must always remember type erasure when working with generics. This workaround doesn't look very convenient, but you should understand that generics weren't part of the Java language when it was created. This feature, which helps us create parameterized collections and catch errors during compilation, was tacked on later.
In some other languages that included generics from the first version, there is no type erasure (for example, in C#).
By the way, we're not done studying generics! In the next lesson, you will get acquainted with a few more features of generics. For now, it would be good to solve a couple of tasks! :)
GO TO FULL VERSION