Java, as a strongly typed language, provides a generic type system. Designing how your classes will make use of generic types could be hard and tedious, but it offers significant advantages. The resulting code is cleaner since you will no longer need most cast operations. Also, the code will be safer, since more type validations will be done at compile time, thus avoiding ClassCastException
errors at runtime.
But, as said before, generic types could be complicated. As generics is a big topic, reading additional Java documentation is a good idea.
Dealing with the Invariant Nature of Generics
Let’s start explaining the meaning of variance. In object-oriented (OO) programming, it is natural to think of superclasses as more generic types than subclasses. We can think of superclasses as “bigger” types since they could include more classes than specific subclasses. For example, java.lang.Number
, could be considered as a bigger type than java.lang.Integer
, since it can also contain java.lang.Double
, java.lang.Float
, and so on.
While this is quite simple in the standard OO inheritance, it could become a little harder when dealing with more complex type systems, such as the one defined by Java generics. Specific concepts needs to be defined to specify which type is bigger than the other.
According to Wikipedia, four categories can be used:
* Type A is covariant to B if A >= B. This mean, a variable of type A can hold instances of B.
* Type A is contravariant to B if A <= B. This is almost the opposite to previous point.
* A type is bivariant if it is both covariant and contravariant at the same time. This is not supported by Java.
* A type is invariant if it does not fit on any of the previous definitions.
In Java, generics are invariant by default (in contrast to arrays, which are covariant). This kind of incoherence can lead to confusion. For example, this code is valid in Java:
String stringArray[] = { "Hi", "how", "is", "it", "going?" };
Object objectArray[] = stringArray;
To explain this a bit: String[]
is a subtype of the Object[]
. The problem with this approach is that it could lead to runtime exceptions. For example, executing the following line will produce an java.lang.ArrayStoreException
exception:
objectArray[0] = 123;
To avoid this, generics types are not covariant by default and, this way, the compiler can prevent these kinds of errors. The following code will produce a compile-time error on the second line:
List<String> stringList = new ArrayList<>();
List<Object> objectList = stringList;
But what happens if we need to use a more generic or more specific type parameter? Here is where extends
and super
keywords come into play.
With extends
you can indicate that a type parameter can be any class extending the specified one. For example:
List<Integer> integerList = new ArrayList<>();
List<? extends Number> numberList = integerList;
This code block is valid. Will this have the same problem as arrays? No, because the Java compiler can check type parameters at compile time. So, while integerList.add(Integer.valueOf(123));
is valid, numberList.add(Integer.valueOf(123));
will produce a compilation error. However, reading an element and asking for a method from a Number
class is perfectly valid:
numberList.get(0).intValue();
This is valid because, even when the compiler does not know exactly which class is parameterized, it knows that it must be a Number
subclass. So, any method found in this class can be called.
In a similar way, the super
keyword indicates that the class parameter could be any superclass of the specified one.
Dealing With Raw Types and Type Erasure
Another topic that is worth taking into account is raw types and type erasure. When generics were introduced with Java 1.5, Java implemented a workaround called “type erasure” to maintain backward compatibility with older versions of Java. This means that information about generics are removed by the compiler from variable types, method parameter types, and method return types. The compiler also automatically adds cast operations when needed. You can find out more about how type erasure works in the online Java tutorial
Type erasure enables the use of raw types. Raw types are used when declaring a variable. With this kind of declaration, you can just omit the generic type declaration. For example, the following code compiles (with warnings):
List<Integer> integerList = new ArrayList<>();
List rawList = integerList;
Note that rawList
lacks a generic type definition because it is a raw type. The use of raw types is discouraged because it could lead to problems similar to the ones that could happen when working with arrays. The following line of code is valid but will produce a java.lang.ClassCastException
exception.
rawList.add("Hi");
Most of the time you will not notice type erasure, but sometimes you will need to be aware of it. For example, some frameworks, like Guice, force you to use subclassing to keep generic type information at runtime, to do generic-type bindings. In Guice, if you need to specify a binding using a generic type, you need to extend the TypeLiteral
class:
bind(new TypeLiteral<SomeInterface<String>>(){})
.to(SomeImplementation.class);
The reason to do this is type erasure. As explained before, Java removes generic information from method parameter types, but it is kept when subclassing, and thus is available through reflection.
Note that .NET designers took a different approach when implementing generics on such a platform. There is no type erasure there. For example, Ninject (a .NET dependency injector) allows setting bindings like this:
this.Bind<IWeapon>().To<Sword>();
This is because information about type parameter IWeapon
is maintained at runtime when making the call to the Bind
method.