mattone
dopo mattoneTHE BOOK SERIES
IT/EN
← Java guide

Java 25 · 15/39

15. Generics and Type Checking

Java 25 · Complete guide · Draft under review

This guide retains the book’s draft status. Editorial review and comprehension checks with independent readers remain to be completed.

Search the whole guide →

The Stack That Accepted Everything

Let us return to the stack that accompanies the book. The version in C08 contains only int: it is simple and allowed us to study state, invariants and exceptions. If we want a stack of titles, copying it and replacing every int with String produces two almost identical classes. We can try to generalize by using Object, the supertype of objects. The idea avoids duplication, but transfers a problem to whoever uses the stack: pop() returns Object, so obtaining a String requires a cast. If someone accidentally inserts an Integer, the program can compile and fail only when it extracts the value.

Generics allow us to declare a relationship between types. Stack<T> means that the type of the elements is chosen when the class is used: Stack<String> accepts strings and returns strings; Stack<Integer> does the same with integers represented as objects. T is a type parameter, a name that appears in the declaration and is conceptually replaced by the type argument chosen by the caller. String in Stack<String> is a type argument. This similarity to method parameters and arguments helps us read the syntax, but here the compiler checks types, rather than values passed during a call.

Key concept – Static safety. In the ordinary case, inserting an incompatible value causes a compiler error before execution. Some forms of code reduce the checks available, however. A raw type uses a generic class without its type argument, for example List instead of List<String>; an unchecked cast requests a generic conversion that the compiler cannot fully verify. We may encounter these forms especially at boundaries with older APIs, and we will examine them later in the chapter. The guarantee therefore has precise conditions: consistent use of parameterized types moves many errors from the moment of use to the moment of writing, without making every runtime error impossible.

The complete program builds a small Stack<T> with linked nodes. Choosing nodes allows us to avoid generic arrays and casts in the main path. Each node stores a value T and the reference to the previous node. push creates a new top; pop returns the value at the top and advances the reference. When the stack is empty, it maintains the contract we have already seen: it throws IllegalStateException rather than inventing a sentinel value.

static final class Stack<T> {
    private static final class Node<T> {
        final T value;
        final Node<T> previous;

        Node(T value, Node<T> previous) {
            this.value = value;
            this.previous = previous;
        }
    }

    private Node<T> top;

    void push(T value) {
        top = new Node<>(value, top);
    }

    T pop() {
        if (top == null) {
            throw new IllegalStateException("empty stack");
        }
        T value = top.value;
        top = top.previous;
        return value;
    }
}

In main, Stack<String> titles = new Stack<>(); uses the diamond <>: the compiler derives the type argument from the left-hand side. After titles.push("Java"), we can assign titles.pop() directly to a String and call toUpperCase(). The output is JAVA. Writing titles.push(3) is not a variant that will fail later: it is code that cannot compile, because 3 becomes an Integer through boxing and is not a String.

Multiple Types, Names and Primitive Values

One type parameter can describe a role; several parameters keep different roles distinct within the same class. Think of a key–value pair: Pair<K, V> uses K for the key and V for the value. If we construct Pair<String, Integer>, a method returning K provides a String, while a method returning V provides an Integer; no cast is needed. Names such as T (type), E (element), K (key) and V (value) are recognizable conventions in Java APIs. They are not reserved words: we could declare Stack<Elements> with Elements as the parameter name, but a conventional letter makes repeated signatures easier to recognize. If there are so many parameters that we cannot tell which data they represent, we should stop and clarify the class’s responsibilities.

The argument of a parameterized type must be a reference type. We do not write Stack<int>: for integers we use Stack<Integer>. Autoboxing makes inserting 3 natural, because the compiler produces the conversion to Integer; this does not mean that boxing has no cost or that null behaves like zero. In a very large numerical structure, a container of wrappers and primitive arrays have different memory and access properties. Choosing a generic solves the problem of static type checking; it is not automatically the most efficient choice for every workload. The chapter on collections will return to this distinction when we compare structures.

Before the diamond, the argument was repeated on both sides, for example new Stack<String>(); with new Stack<>(), the compiler uses the target type already declared. The symbol <> does not mean “any type” and is not equivalent to the raw type Stack. A raw variable loses information and generates warnings in unchecked operations. If an older project requires that boundary, we contain the interoperability, check the contents and do not let the raw type spread throughout the program. This recovers the reason for modern syntax: reducing repetition without giving up the signature's promise.

Why List<Integer> Is Not List<Number>

In Chapter 9 we learned that an Integer is a Number. Would it then be legal to assign List<Integer> to List<Number>? No. If it were allowed, through the second reference we could insert a Double into the list that the first reference promises contains only Integer values. Java generic types are normally invariant: the relationship between type arguments does not automatically transfer to parameterized types.

Figure 15.1 separates the relationship between elements from the relationship between containers. It is a diagram of types, rather than an arrangement of objects in memory.

Invariance of generic lists
Figure 15.1 – Integer is a subtype of Number, but List<Integer> is not a subtype of List<Number>. The wildcard describes the variation allowed by the individual method.

When a method only needs to read numbers from lists of different subtypes, it can receive List<? extends Number>. The question mark is a wildcard, an unnamed type that respects the Number bound. We can read a Number from the parameter, but cannot arbitrarily insert a Number: the actual list might be a List<Integer>. When a method needs to write Number values into a list, it can receive List<? super Number>: the actual list can be, for example, List<Number> or List<Object>. Reading from it does not promise a more precise type than Object.

In the program, copyNumbers traverses a List<Integer> through a List<? extends Number> parameter and adds the values to a List<Number> through List<? super Number>. The output is [2, 3]. The rule often remembered as PECS (producer extends, consumer super) describes who produces values to read and who consumes them through writing. It does not replace reading the contract: the same collection can be read and written, and in that case a wildcard might not be suitable.

Definition – Type parameter and wildcard. T is a name that can link several positions in the same signature: a method <T> T first(List<T> values) promises to return the same type as the elements received. ? represents an unknown type and is used when that relationship does not need to be expressed with a name. Always replacing T with ? would lose information for the caller.

A method can declare its own type parameters without the class being generic, for example static <T> T first(List<T> values). An interface can be generic: Comparable<T> expresses which type of value can be compared. A type parameter can have a bound, for example <T extends Number>; the word extends here applies to both class and interface types. These forms do not force us to use complicated hierarchies: they are useful when the method must preserve a verifiable relationship between input and output.

Erasure and Real Limits

Java implements generics through erasure, or the removal of types, to maintain compatibility with earlier code. The compiler checks generic relationships and produces a .class file in which some type arguments are not available for ordinary runtime operations. This does not mean that “generics do not exist”: signatures can retain generic metadata readable through reflection, and above all compile-time checking has already changed which programs are allowed. It means that in the general case we cannot write new T() or new T[10], or distinguish List<String> from List<Integer> using instanceof.

When a generic class implements or overrides a method with a signature that, after erasure, requires an adaptation, the compiler can generate a bridge method. It is a synthetic method in the bytecode that preserves the expected polymorphic behavior. We do not need to write it by hand, but knowing that it exists helps when examining a class with javap or reflection. The JLS 25 on generics describes parameterized types, wildcards and erasure.

In the BridgeDemo.java program, Store<T> declares set(T). The subclass fixes T to String and overrides set(String), adding normalization. A variable declared as Store<String> points to the TitleStore object; the call must reach the override and print Java, without spaces at the edges.

public final class BridgeDemo {
    static class Store<T> {
        private T value;

        void set(T next) {
            value = next;
        }

        T read() {
            return value;
        }
    }

    static final class TitleStore extends Store<String> {
        @Override
        void set(String next) {
            super.set(next.strip());
        }
    }

    private BridgeDemo() { }

    public static void main(String[] args) {
        Store<String> store = new TitleStore();
        store.set("  Java  ");
        System.out.println(store.read());
    }
}

Compiling with javac --release 25 -Xlint:all -d build BridgeDemo.java, then running java -cp build BridgeDemo, we observe Java. After erasure, the signature of the method in the base class uses Object; the one written in the subclass uses String. If we inspect the subclass with javap -p -classpath build 'BridgeDemo$TitleStore', we see both set(java.lang.String) and set(java.lang.Object). The second is the bridge method generated by the compiler: it receives an Object, checks it as a String, and forwards the call to the subclass method. A call through the base-class contract therefore still reaches the override that removes the spaces. This is dispatch, the choice of implementation to execute. The bridge preserves that choice after erasure; it does not recreate erased type arguments at runtime.

A raw type, such as List without an argument, is mainly useful for interoperability with older APIs. It can lose checks and produce warnings. If we use a raw type to insert an incompatible value into a parameterized list, we obtain heap pollution: the actual contents do not respect the type promised by the source. Arrays also depend on which type information remains available during execution. A type is reifiable when it is completely available at runtime: String is, whereas List<String> does not retain the argument String in the same way. Because an array checks its component type, we cannot directly create new List<String>[10]. Varargs, parameters accepting a variable number of arguments, use an array and can encounter the same limit with generic types. @SafeVarargs suppresses some warnings and asserts that the author has checked the safety of the body; it does not perform that check for the author or make an exposed or polluted array safe.

Further reading – Wildcard capture. A method receiving List<?> can read the elements as Object, but does not know which specific type has been captured by the question mark. Sometimes a small private generic method can give that type a temporary name and perform a safe operation, such as swapping two positions in the same list. The wildcard is not “any object”: it is a precise type that is unknown at that point in the source.

Three Signatures That Look Similar

Consider three methods that find the first element of a list. Object first(List<?> values) can read an element, but returns only Object to the caller: information about the specific type does not pass through the signature. <T> T first(List<T> values) instead links the element type to the returned type. If we pass List<String>, the result is a String for the compiler. <T extends Number> T firstNumber(List<T> values) preserves the same relationship, but limits the allowed types to subtypes of Number. The more restrictive signature is justified only if the body or contract really needs numerical operations.

A wildcard ? extends Number is not a shorter way of saying that the method is generic. It is suitable when the method reads numbers and does not need to return to the caller the same specific type it received. If the result must maintain that relationship, a named parameter is needed. This is why standard APIs contain both type parameters and wildcards: they solve different problems. A signature that is too general forces the caller to cast; one that is too narrow rejects perfectly safe uses.

When Making a Method Generic Is Enough

A generic static method lets us link types in its signature without making the entire class generic. A method static <T> boolean areEqual(T first, T second) declares its T before the return type; that T belongs to the method call, rather than to an instance of a generic class. We can call it with two strings and the compiler infers a compatible type. We can also write a test with Integer; we have not created two different classes. However, if the body's only operation is an equality comparison, we can use Objects.equals(first, second) and ask whether the generic signature really adds a useful constraint. If the method does not link positions meaningfully, genericity can be noise.

We can make the type argument explicit in the call, for example Tools.<String>areEqual("a", "b"), but in most cases inference makes that repetition unnecessary. Explicit specification becomes useful when inference would choose too broad a type or when we want to clarify a complex intention. The question remains the same: what verifiable relationship between the arguments and the result are we promising?

When the Constructor Has Its Own Type

A class can be non-generic and still have a generic constructor. Imagine a receipt that must store only a textual description, but can originate from a number, a string or another value. The caller provides both the value and the function that knows how to describe that very type. The constructor declares <T> before the class name: <T> Receipt(T source, Function<T, String> describe). The same T links the two arguments of the individual construction. It is not a type parameter of the entire class and does not remain available in its other methods.

In the complete program, the first receipt originates from the integer 42 and the second from the string "java". The compiler infers a compatible type for each call and checks that the function accepts that value. The printed result is n=42 and then JAVA. If we passed a function accepting only String together with a value declared as Integer, the contract would not be satisfied: the error emerges at compile time. This is the concrete benefit of the type parameter, which Object would not express with the same precision.

import java.util.function.Function;

public final class GenericConstructorDemo {
    static final class Receipt {
        private final String description;

        <T> Receipt(T source, Function<T, String> describe) {
            description = describe.apply(source);
        }

        String description() {
            return description;
        }
    }

    private GenericConstructorDemo() { }

    public static void main(String[] args) {
        Receipt number = new Receipt(42, n -> "n=" + n);
        Receipt word = new Receipt("java", String::toUpperCase);
        System.out.println(number.description());
        System.out.println(word.description());
    }
}

Further reading – Two Different Lifetimes for T. In Stack<T>, the parameter is declared by the class and connects methods of the same instance. In <T> Receipt(...), it belongs only to the constructor and connects the arguments of that call. A generic constructor is useful when this connection avoids casts or late checks; to simply convert any object using toString(), an Object parameter may be enough. The Java 25 specification confirms that the class can be generic or not independently of its constructor.

For a sum, <T> without a bound is not enough, because an arbitrary T does not offer doubleValue(). <T extends Number> allows us to read that method and accepts Integer, Double and other subtypes of Number, but excludes String at compile time. If the body also requires the values to be comparable, we can express several bounds, with the class first and interfaces after it: <T extends Number & Comparable<T>>. Two classes such as Integer & Double do not form a meaningful bound and are rejected. We do not choose bounds to describe the examples used in the test; we choose them from the operations the implementation must be able to perform. It is the connection between problem and signature that makes a generic API readable.

Inference and Target Type

In the code new Stack<>(), the compiler uses the type of the variable on the left to work out the argument omitted in the diamond. Type inference also appears in calls to generic methods: the compiler combines the arguments passed with the expected type of the result. It does not mean that a variable changes type during execution. After Stack<String> titles, the contract of the methods remains the one for strings. If inference produces a broader type than expected, an explicit declaration or a more precise method name can make the code more readable without fighting the compiler.

Bounds can be multiple, for example a parameter that must extend a class and implement an interface. The syntax uses & after extends; if a class bound appears, it comes first. This is a useful capability in generic libraries, but it should not appear in the main path without a concrete operation requiring it. The principle from C13 applies here too: first the necessary contract, then the syntax that expresses it.

What Reflection Can Read

Although an object new Stack<String>() does not retain a runtime label allowing us to ask it directly “are you a stack of strings?”, a declaration such as a field Stack<String> titles can retain generic information in the compiled file's signature. Reflection APIs represent these forms with Type and specializations such as ParameterizedType, TypeVariable and WildcardType. Reading a declaration and querying an instance are different operations: erasure limits the latter, but does not necessarily remove every trace of the former.

This clarification avoids two extremes. We cannot perform an instanceof Stack<String> check on an arbitrary stack, because the type argument is not reified in that way. Nor is it true that the compiler “forgets” every generic and that .class files cannot document parameterized signatures. When a library uses reflection to resolve a generic type, we must read exactly where that type was declared and which metadata is available.

One Stack, Three Type Contracts

Let us make the stack evolve through several forms, without skipping the reason for each step. A stack using int[] stores only primitive integers; if we also need a stack of titles, copying all the code duplicates the top invariant and the handling of the empty case. A stack storing Object eliminates duplication but accepts "Java" and 42 together, without being able to promise the caller which type will come out. The cast (String) stack.pop() does not repair the invariant: it merely asks the JVM to check later whether the caller's assumption was right.

With Stack<T>, the relationship enters the declaration. void push(T value) and T pop() use the same parameter T; when we write Stack<String>, the compiler connects both positions to String. This is the essential part, more important than the class name or the choice between arrays and nodes. The diamond new Stack<>() avoids repeating String in the constructor, but does not create a “typeless” stack. The target type on the left provides the constraint; if we use a raw type Stack, we lose it and reopen precisely the risk we wanted to close.

A generic interface can declare the same contract, for example interface Store<T> { void push(T value); T pop(); }. A class can implement it while keeping the parameter, class Stack<T> implements Store<T>, or fixing it, class TitleStack implements Store<String>. In the latter case the concrete class is not generic, but its methods must respect the specialized signatures: the argument and result are strings. This variant corrects a common misunderstanding: a generic interface does not require every class implementing it to remain generically parameterized.

Further reading – The Bound Answers a Question. <T extends Number> is useful if the body must call methods guaranteed by Number. Writing it merely because the examples use numbers would prevent a Stack<String> without providing any advantage: push and pop do not need to add. If an algorithm must sort the elements, it instead needs a comparison contract and an appropriate bound, or a Comparator passed from outside. The bound is chosen from the required operation, rather than from the first use case.

When Static Checks Meet Arrays

A Java array knows its component type at runtime. For this reason an Object[] reference pointing to a real String[] cannot accept an Integer: the attempt is rejected at runtime. A List<String>, on the other hand, does not carry a runtime check of its String argument in the same way; erasure permits interoperability with older code and moves the main check to compilation. Directly combining arrays and non-reifiable parameterized types therefore creates problems: new List<String>[10] is not allowed.

When a varargs method receives List<String>..., the variable parameter is represented through an array. A careless implementation can introduce a list of another type through a less precise alias and produce heap pollution. A compiler warning should not be silenced merely to make the build clean: it indicates that the generic contract and the array representation require additional proof. @SafeVarargs is a declaration of responsibility by the method's author, allowed in the cases specified by the language; it should be applied only after verifying that the body neither exposes nor contaminates that array.

Reading and Writing a Wildcard Without Guessing

Let us return to the copyNumbers method in the complete source. It receives a source List<? extends Number> and a destination List<? super Number>. For the former we call get(0) and obtain a value that we can treat as Number, because whatever actual type is captured by the question mark must be a subtype of Number. However, we cannot call add(3.5) on the source: if it were a list of Integer, the Double 3.5 would violate the element type. From the viewpoint of the generic type, we can add null, but doing so here would make no sense for the algorithm.

In the destination, the reasoning is reversed. We can call add(number) with a Number: the real list is declared for Number or one of its supertypes, so it can hold it. If we then read with get(0), the safe static type of the result is Object; the real list might be List<Object> and also contain values that are not numbers. This is not a compiler quirk: these are two different promises, one about writing and the other about reading. To move the numbers, we use both in the direction in which they are valid.

The word PECS helps us remember the direction, but we can also reconstruct it without the acronym. Let us ask what the parameter must produce for our method and what it must accept. When the source produces values readable as Number, extends is suitable; when the destination consumes Number values, super is suitable. If a method must add and read values while maintaining exactly the same type, a named type parameter T can express the relationship better than two independent wildcards. In that case the signature becomes part of the proof of the method's safety.

The Unknown Type Remains a Type; It Does Not Become Object

We might consider using Stack<Object> as the parameter for a function that does not know the stack’s type. It would be convenient, but Stack<String> cannot be assigned to Stack<Object>, for the same reason that List<Integer> is not List<Number>. If the method only needs to check whether the stack is empty or print a description of it, it can receive Stack<?>: any parameterized stack is compatible with that view. We will be able to use the value from pop() only as Object, because the signature promises nothing more. We cannot, however, call push("new"): the real stack might be a Stack<Integer>. The wildcard therefore lets us express what the method knows and what it cannot promise.

Figure 15.1 shows the difference between the subtype relationship of Integer and Number and the invariance of List<Integer> and List<Number>. We now apply the same reasoning to stacks: Stack<Integer> and Stack<String> do not become subtypes of each other, but we can use both through Stack<?> when the method does not require a precise element type. The question mark preserves a precise type that is unknown at that point in the source. We can read an element as Object, or pass the stack to a generic method that captures and maintains that type in its signature. We cannot insert any arbitrary object into it as we could into a Stack<Object>. The wildcard states the limit of our knowledge; a cast does not remove that limit without requiring an additional check.

Let us try to transfer the model to a catalog of numbers. A method calculating the sum of doubleValue() results can receive List<? extends Number>, because it reads every value as Number and does not need to add any. A method depositing new Integer values can receive List<? super Integer>; a List<Number> and a List<Object> are valid destinations. If instead we must move the same precise type from one structure to another and return an element of that type, <T> links the positions better than two wildcards. Writing three different signatures is not a syntax contest: these are three different contracts that the compiler can enforce.

The reader can verify the choice by deliberately introducing a forbidden operation into each method and reading the error. If the sum method tries to add a Double to the ? extends Number source, the compiler rejects it; if the method depositing into ? super Integer claims to read an Integer directly, it rejects that too. The diagnostic is an operational explanation of the limit. Once we understand why, remembering the acronym PECS becomes easier and, above all, less dangerous to apply from memory.

To Verify

Compile GenericsDemo.java with javac --release 25 -Xlint:all -d build GenericsDemo.java and run the class. Also run BridgeDemo.java and use javap -p on the inner class TitleStore to identify the bridge method generated after erasure. Then try assigning List<Integer> to List<Number> in a copy of the file and read the diagnostic. Finally, modify copyNumbers so that the source is List<? extends Integer>, the value read is Integer and the destination is List<? super Integer>; try passing it first List<Number>, then List<Object>. Both cases must compile, but the choice of bounds changes which values the body can read and add. The activities in C15 will use the stack to bring out the benefits of static checking compared with the old solution using Object and casts.

Try the examples

Running the programs requires JDK 25. Download the individual Java files linked in the chapter or the complete example project, which includes instructions and a launcher script. The explanations also compare expected output: predict it before running the program.

Massimiliano Tarquini · CC BY-NC 4.0

Back to the top ↑