Java: Easy Notes of Generics

MrAndroid
8 min readFeb 23, 2023

--

Generics, available since JSE 5.0, have made using the Java Collection Framework easier, more convenient, and safer. Type misuse errors are now detected at compile time.

As it was before the advent of Generics:

List list = new ArrayList();
list.add("Hello");
String value = (String) list.get(0);

In Java, generic types without a parameter type are called Raw Types.

Such a language construct is valid, but in most cases results in a compiler warning.

But what if we try to cast the type to a number?

List list = new ArrayList();
list.add("Hello");
String value = (String) list.get(0);
Integer iValue = (Integer) list.get(0); // error

We get an error in runtime:

java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Integer

There are only three cases when it is correct to use a generic type without a parameter:

  • Java version < 5.0 in the project;
  • In a class literal: List<String>.class won’t work, you need to write List.class;
  • In the instanceof operator: instead instanceof List<Integer> must be instanceof List.
if(object instanceof List){
....
}

With the advent of Generics, the need for type checking and casting has disappeared.

When the compilation running of the project, the compiler removes information about generic types from the bytecode of the class file. This process is called type erasure.

In the bytecode, we will see List instead of List<String>. This decision made it possible to maintain backward compatibility without recompiling Java 4 code.

For example we have the following function:

public class MyClass {
public void print(List<String> list){
}
}

After compiling:


public class MyClass {
public void print(List list){
}
}


public class MyClass {

// ...

public print(Ljava/util/List;)V
L0
LINENUMBER 8 L0
RETURN
L1
LOCALVARIABLE this Ltestss/MyClass; L0 L1 0
LOCALVARIABLE list Ljava/util/List; L0 L1 1
// signature Ljava/util/List<Ljava/lang/String;>;
// declaration: list extends java.util.List<java.lang.String>
MAXSTACK = 0
MAXLOCALS = 2
// ...

Types erasure consists of three steps:

1)If the parameters are bounded, instead of the type of the parameter in the places of use, the upper bound is substituted, otherwise Object;

public class Node<T> {

private T data;
private Node<T> next;

public Node(T data, Node<T> next) {
this.data = data;
this.next = next;
}

public T getData() { return data; }
// ...
}

We don’t have bounded type, so the compiler will replace it with Object:

public class Node {

private Object data;
private Node next;

public Node(Object data, Node next) {
this.data = data;
this.next = next;
}

public Object getData() { return data; }
// ...
}

In the following example, the generic Node class uses a bounded type parameter:

public class Node<T extends Number> {

private T data;
private Node<T> next;

public Node(T data, Node<T> next) {
this.data = data;
this.next = next;
}

public T getData() { return data; }
// ...
}

Compiler will replace it with Number:

public class Node {

private Number data;
private Node next;

public Node(Number data, Node next) {
this.data = data;
this.next = next;
}

public Number getData() { return data; }
// ...
}

The preceding example illustrates the use of a type parameter with a single bound, but a type parameter can have multiple bounds:

<T extends B1 & B2 & B3>

A type variable with multiple bounds is a subtype of all the types listed in the bound. If one of the bounds is a class, it must be specified first. For example:

Class A { /* ... */ }
interface B { /* ... */ }
interface C { /* ... */ }
class D <T extends A & B & C> { /* ... */ }

If bound A is not specified first, you get a compile-time error:

class D <T extends B & A & C> { /* ... */ }  // compile-time error

2)In places where the value of the type-parameter is assigned to a variable of an ordinary type, a cast is added to this type;

Let’s look at the first example when we use unbounded type:

public <T> void doSomeWork(T value) { /* ... */ }

Because T is unbounded, the Java compiler replaces it with Object:

public void doSomeWork(Object value) { /* ... */ }

If we use bounded type:

public <T extends Number> void doSomeWork(T value) { /* ... */ }

Compiler replaces T with Number:

public void doSomeWork(Number value) { /* ... */ }

3)Bridge methods are generated:

In Java, when we override a method, its type must match the types of the method’s parameters in the parent class.

When a Generic parameter is instantiated in a descendant, methods with arguments of this generic type no longer match in bytecode: in the heir, the type is concrete, and in the parent, the type is erased to the upper bound.

Given the following two classes:

public class Node<T> {

public T data;

public Node(T data) { this.data = data; }

public void setData(T data) {
System.out.println("Node.setData");
this.data = data;
}
}

public class MyNode extends Node<Integer> {
public MyNode(Integer data) { super(data); }

public void setData(Integer data) {
System.out.println("MyNode.setData");
super.setData(data);
}
}

After type erasure, the Node and MyNode classes become:

public class Node {

public Object data;

public Node(Object data) { this.data = data; }

public void setData(Object data) {
System.out.println("Node.setData");
this.data = data;
}
}

public class MyNode extends Node {

public MyNode(Integer data) { super(data); }

public void setData(Integer data) {
System.out.println("MyNode.setData");
super.setData(data);
}
}

The problem is solved by a simple and safe custom. The compiler generates a new method that has the same signature as the parent method. In its body, the parameter is cast and the call is delegated to a custom method. This is called the bridge method.

class MyNode extends Node {

// This is a "bridge method"
// Bridge method generated by the compiler
public void setData(Object data) {
setData((Integer) data);
}

public void setData(Integer data) {
System.out.println("MyNode.setData");
super.setData(data);
}

// ...
}

The bridge method MyNode.setData(object) delegates to the original MyNode.setData(Integer) method. As a result, the n.setData("Hello"); statement calls the method MyNode.setData(Object), and a ClassCastException is thrown because "Hello" can't be cast to Integer.

Initially, we call the setData(Object data) method and pass the string “Hello” there, but already inside the method, when we try to cast the string to a number, we get an error.

Bridge method can be seen with reflection. Its name is the same as the original method, but the parameter is of the type that the parent’s generic will be erased into. This method will be marked with the synthetic flag, which means that it is not written by a programmer, but by a compiler.

Let’s see how we can find out what method synthetic:

MyNode node = new MyNode(5);
Method[] methods = node.getClass().getDeclaredMethods();
for (Method method : methods) {
if (method.isSynthetic() || method.isBridge()) {
System.out.println("Method Name = " + method.getName());
System.out.println("Method isBridge = " + method.isBridge());
System.out.println("Method isSynthetic = " + method.isSynthetic());
}
}




Method Name = setData
Method isBridge = true
Method isSynthetic = true

Attempting to write the same method manually will result in a compilation error.

Wildcards:

An entry of the form ? extends … or ? super … — is called a wildcard, with an upper bound (extends) or a lower bound (super).

Wildcards allow you to set boundaries for a family of types defined by some generic class.

List<? extends Number> can contain objects whose class is Number or inherits from Number.

List<? super Number> can contain objects, whose class is Number or whose Number is a successor (supertype of Number).

If the container is declared with wildcard ? extends, then you can only read the values.

List<Integer> ints = new ArrayList<Integer>();
ints.add(1);
ints.add(2);
List<? extends Number> nums = ints;
nums.add(6.16); // compile-time error

Nothing can be added to the list except null. In order to add an object to the list, we need a different type wildcard :? super .

The wildcard feature with upper and lower bounds provides additional features related to type safety. You can only read from one type of variable, and only write into another type (the exception is the ability to write null for extends and read Object for super).

If you need to read from a container, then use a wildcard with an upper bound ? extends.

public static void print(List<? extends String> items) {
for (String item : items) {
System.out.println(item);
}
}

If you need to write to a container, then use a wildcard with a lower border ? super.

public static void addItems(List<? super String> items) {
items.add("Hello");
}

Don't use a wildcard if you need to both write and read.

A bit of theory: Covariance, contravariance and invariance

Covariance — is keeping the inheritance hierarchy of source types in derived types in the same order.

For example, if Integer — is a subtype of Number, then List<Integer> — is a subtype of List<Number>. Therefore, taking into account the principle of substitution, we can perform the following assignment:

List<Number> = List<Integer>

Arrays in Java are covariant. The type S[] is a subtype of T[] if S is a subtype of T.

Assignment example:

String[] strings = new String[] {"a", "b", "c"};
Object[] objects = strings;

We have assigned a reference to the strings array to the variable objects, whose type is — “array of objects”.

The program will compile and run without errors.

But if we try to change the contents of the array through the objects variable and write the number 1 there, we will get an ArrayStoreException at the program execution stage, since 1 is not a string, but a number.

arr[0] = 1; // ArrayStoreException

Contravariance — is the reversal of the source type hierarchy in derived types.

For example, if Integer — is a subtype of Number, then List<Number> — is a subtype of List<Integer>. Therefore, taking into account the principle of substitution, we can perform the following assignment:

List<Integer> = List<Number>

Invariance — no inheritance between derived types. If Integer — is a subtype of Number, then List<Integer> is not a subtype of List<Number> and List<Integer> is not a subtype of List<Number>.

Another example: List<String> is not equal to List<Object> and vice versa.

Generics are invariant:

Let’s take an example:

List<Integer> integers = Arrays.asList(0,1,2);
List<Number> numbers = integers; // compile-time error List<Integer> cannot be converted to List<Number>

That is, a list of integers is a subtype of itself and is not a subtype of, for example, List<Numbers>. List<Integer> — is List<Integer> and nothing else.

The compiler will make sure that the variable integers, declared as a list of objects of class Integer, contains only objects of class Integer and nothing else.

At the compilation stage, a check is made, and nothing will fall in our runtime.

Are Generics always invariants?

The answer is no.

covariance:

List<Integer> ints = new ArrayList<Integer>();
List<? extends Number> nums = ints;

List<Integer> — subtype List<? extends Number>.

Contravariance:

List<Number> nums = new ArrayList<Number>();
List<? super Integer> ints = nums;

List<Number> subtype of List<? super Integer>.

Reifiable:

Reifiable type is a type about which information is fully available at runtime.

Such types include:

  • Primitive types: int, long, boolean;
  • Non parameterized types: String, Integer;
  • Parameterized types whose parameters are represented as unbounded wildcard (unlimited wildcards) : List<?>, Collection<?>;
  • Raw types: List, ArrayList, Set;
  • Arrays whose components — Reifiable types: int[], Number[], List<?>[], List[];

--

--