Module 9 closed with a debt spelled out in full: CatalogClient, MetadataClient and CatalogEnricher repeat the same structure —make the request, check the code, parse the body, translate the error— changing only the type of the result. And you had no way of writing "this is a client of something that returns things of type T" without duplicating the whole class.
There was also an older debt. Since module 5 you have been writing List<Material>, Map<String, Book>, Optional<Card>, CompletableFuture<HttpResponse<String>>, and every time those <...> appeared we told you the same thing: "it is the type of the elements, the theory is in 10-01". This is 10-01.
Generics are Java's answer to a very concrete question: how do I write code that works with any type, without giving up having the compiler check the types for me? Before Java 5 the answer was "use Object and do casts", and the price was that type errors appeared at run time, in production, with a ClassCastException nobody saw coming. With generics, those same errors appear at compile time, on your screen, before anybody suffers them.
This lesson explains what that <T> means exactly, how to write your own generic classes and interfaces, how to bound the accepted types, how wildcards and the PECS principle work, and —very important— what you CANNOT do with generics and why, because type erasure leaves a set of limitations that surprise everybody the first time.
By the end, BiblioTech will have a Repository<T extends Identifiable> that generalises ConcurrentCatalog and SafeLoanRegistry in one stroke, and a generic Result<T> that replaces module 6's Result.
Contents
- The problem: containers of
ObjectandClassCastException - The same scene with generics
- Your own generic classes:
Box<T> - Naming conventions for type parameters
- Instantiation and the diamond operator
<> - Your own generic interfaces
- Generic methods and inference
- Bounds:
<T extends Comparable<T>> - Multiple bounds with
& - Invariance: why
List<String>is not aList<Object> - The contrast with covariant arrays
- Wildcards:
<?>,<? extends X>and<? super X> - PECS: Producer Extends, Consumer Super
- Type erasure: what the compiler really does
- The practical consequences of erasure
- Bridge methods
- Raw types and why they exist
@SuppressWarnings("unchecked")and when it is legitimate- The type token:
Class<T> - Generics and exceptions
- BiblioTech:
Repository<T extends Identifiable> - BiblioTech: generic
Result<T> - Common Mistakes and Tips
- Exercises
- The problem: containers of
Object and ClassCastException
Object and ClassCastExceptionImagine you are on Java 1.4 and you want a list of books. The only list that exists holds Object:
import java.util.ArrayList;
import java.util.List;
public class LegacyCatalog {
public static void main(String[] args) {
List catalog = new ArrayList(); // no generics: holds Object
catalog.add(new Book("Effective Java", "978-0000000001"));
catalog.add(new Book("Design Patterns", "978-0000000002"));
catalog.add("Refactoring"); // WRONG! but the compiler says nothing
for (int i = 0; i < catalog.size(); i++) {
Book book = (Book) catalog.get(i); // cast is mandatory
System.out.println(book.getTitle());
}
}
}This code compiles without a single error. And when you run it:
Effective Java
Design Patterns
Exception in thread "main" java.lang.ClassCastException:
class java.lang.String cannot be cast to class Book
at LegacyCatalog.main(LegacyCatalog.java:14)Notice the three evils, because they are exactly the three that generics solve:
| Evil | Description | Cost |
|---|---|---|
| There is no checking | catalog.add("Refactoring") is valid as far as the compiler is concerned |
The error travels all the way to production |
| Casts everywhere | Every get() forces a (Book) |
Noise and a chance to get it wrong |
| The failure is far from the cause | The wrong add is on line 10; the exception fires on line 14 |
Long debugging |
And there is a fourth, subtler evil: the API documents nothing. If you see a method List loadCatalog(), you do not know what the list holds. You have to read the implementation or trust the name.
- The same scene with generics
import java.util.ArrayList;
import java.util.List;
public class ModernCatalog {
public static void main(String[] args) {
List<Book> catalog = new ArrayList<>();
catalog.add(new Book("Effective Java", "978-0000000001"));
catalog.add(new Book("Design Patterns", "978-0000000002"));
// catalog.add("Refactoring"); // DOES NOT COMPILE
for (Book book : catalog) { // no cast
System.out.println(book.getTitle());
}
}
}If you uncomment the String line, the compiler says:
error: no suitable method found for add(String)
catalog.add("Refactoring");
^
method Collection.add(Book) is not applicable
(argument mismatch; String cannot be converted to Book)The error has moved from 3 in the morning in production to 10 in the morning in your editor. That is the whole value of generics, and it is enormous.
With the generic list, on top of that:
- The casts disappear. The compiler knows that
get(i)returns aBook. - The API documents itself.
List<Book> loadCatalog()leaves no room for doubt. - The IDE autocompletes for you. When you type
book.it offersBook's methods, notObject's.
The central idea in one sentence: a generic is a parameter that is not a value, but a type. Just as a method takes value parameters (
int days), a generic class takes type parameters (<T>), and the compiler checks that the whole usage is coherent.
- Your own generic classes:
Box<T>
Box<T>The syntax is direct: you declare the type parameter between <> right after the class name, and from then on that name is used as if it were a real type throughout the whole body.
package com.nexussoftware.bibliotech.domain;
/**
* Container for a single value of type T.
* T is a TYPE PARAMETER: it does not exist until somebody
* writes Box<Book> or Box<String>.
*/
public class Box<T> {
private T content; // field of the parameterised type
public Box(T content) { // parameter of the parameterised type
this.content = content;
}
public T get() { // return of the parameterised type
return content;
}
public void put(T content) {
this.content = content;
}
public boolean isEmpty() {
return content == null;
}
@Override
public String toString() {
return "Box[" + content + "]";
}
}Usage:
Box<Book> bookBox = new Box<>(new Book("Effective Java", "978-0000000001"));
Book b = bookBox.get(); // no cast: the compiler knows it is a Book
Box<String> textBox = new Box<>("Room 3");
String s = textBox.get();
// bookBox.put("not this"); // DOES NOT COMPILEBox<Book> and Box<String> are different types to the compiler. A Box<Book> cannot be assigned to a Box<String> variable or the other way round. This is what it means for the class to be parameterised, and Box<Book> is called a parameterised type; plain Box is a raw type (section 17).
A class can have several type parameters, separated by commas:
/**
* Pair of values of possibly different types.
* It is, in essence, what Map.Entry<K, V> does.
*/
public class Pair<A, B> {
private final A first;
private final B second;
public Pair(A first, B second) {
this.first = first;
this.second = second;
}
public A first() { return first; }
public B second() { return second; }
/** Returns the pair with its elements swapped. */
public Pair<B, A> swap() {
return new Pair<>(second, first);
}
@Override
public String toString() {
return "(" + first + ", " + second + ")";
}
}Pair<String, Integer> loansPerEmployee = new Pair<>("Marta Ruiz", 4);
Pair<Integer, String> swapped = loansPerEmployee.swap();
System.out.println(loansPerEmployee); // (Marta Ruiz, 4)
System.out.println(swapped); // (4, Marta Ruiz)Look at swap(): it returns Pair<B, A>, that is, type parameters can be combined and reordered freely. The compiler follows all of it.
A note on
record. Arecord(04-07) can also be generic:public record Pair<A, B>(A first, B second) { }does the same thing in one line. You will see it combined withsealedin 10-06.
- Naming conventions for type parameters
Type parameters can be called whatever you want (Box<ContentType> compiles), but there is a universal convention of a single capital letter that is worth respecting because it makes code immediately readable:
| Letter | Meaning | Example from the JDK |
|---|---|---|
T |
Type: any type, the general case | Optional<T>, Class<T> |
E |
Element: element of a collection | List<E>, Set<E> |
K |
Key: key of a map | Map<K, V> |
V |
Value: value of a map | Map<K, V> |
R |
Result: type of a function's result | Function<T, R> |
S, U, V |
Extra types when you have already used T |
BiFunction<T, U, R> |
N |
Number: numeric type | Less common |
Remember that in 04-06 you used Function<T, R>, Predicate<T>, Consumer<T> and Supplier<T> without wondering where those letters came from. Now you know: Function<T, R> literally means "function that takes something of type T and returns something of type R", and that is why Function<Book, String> is the function that extracts the title.
A type parameter is not a variable. It cannot be assigned, it takes no memory, it does not exist at run time (section 14). It is an instruction for the compiler.
- Instantiation and the diamond operator
<>
<>Before Java 7 you had to repeat the type on both sides:
Java 7 introduced the diamond operator <>, which tells the compiler "infer here the same thing that is on the left":
With var (Java 10, which you will see in 10-06) the empty diamond is no use, because there is nothing on the left to infer from:
var catalog = new ArrayList<Book>(); // GOOD: the type goes on the right
// var list = new ArrayList<>(); // infers ArrayList<Object>: almost never what you wantThe diamond also works in anonymous classes as of Java 9:
Comparator<Book> byTitle = new Comparator<>() { // Java 9+
@Override
public int compare(Book a, Book b) {
return a.getTitle().compareTo(b.getTitle());
}
};
- Your own generic interfaces
Interfaces are parameterised just like classes. BiblioTech needs one that marks "this has an identifier of type T":
package com.nexussoftware.bibliotech.domain;
/**
* Everything BiblioTech can store in a repository
* knows how to state its identifier.
*/
public interface Identifiable {
String getId();
}And a genuinely generic interface, with a type parameter:
package com.nexussoftware.bibliotech.service;
/**
* Turns objects of type T into a line of text and back again.
* Replaces the toText/fromText methods repeated in module 7.
*/
public interface Serialiser<T> {
String serialise(T object);
T deserialise(String line);
}When implementing it there are two ways to do it, and the difference is fundamental:
// WAY 1: pin the type down. The implementation is NO LONGER generic.
public class BookSerialiser implements Serialiser<Book> {
@Override
public String serialise(Book book) {
return book.getIsbn() + ";" + book.getTitle();
}
@Override
public Book deserialise(String line) {
String[] parts = line.split(";", 2);
return new Book(parts[1], parts[0]);
}
}// WAY 2: propagate the parameter. The implementation stays generic.
public class LoggingSerialiser<T> implements Serialiser<T> {
private final Serialiser<T> delegate;
public LoggingSerialiser(Serialiser<T> delegate) {
this.delegate = delegate;
}
@Override
public String serialise(T object) {
String result = delegate.serialise(object);
System.out.println("[SER] " + object.getClass().getSimpleName());
return result;
}
@Override
public T deserialise(String line) {
return delegate.deserialise(line);
}
}Way 2 is a decorator (the pattern you saw applied to streams in 07-03 and will formalise in 12-02) and works with any T: new LoggingSerialiser<>(new BookSerialiser()) is a Serialiser<Book>.
Module 4's functional interfaces are exactly this: Function<T, R> is a generic interface with a single abstract method. Nothing magical.
- Generic methods and inference
A method can declare its own type parameters, independent of the class's. They go between the modifiers and the return type:
public class Utilities {
/**
* Swaps two positions of a list of any type.
* modifiers <T> return name
*/
public static <T> void swap(List<T> list, int i, int j) {
T temp = list.get(i);
list.set(i, list.get(j));
list.set(j, temp);
}
/** Returns the first element, or null if the list is empty. */
public static <T> T first(List<T> list) {
return list.isEmpty() ? null : list.get(0);
}
/** Builds a list out of loose elements. */
@SafeVarargs
public static <T> List<T> listOf(T... elements) {
return new ArrayList<>(Arrays.asList(elements));
}
/** Counts how many elements of the list satisfy the predicate. */
public static <T> long count(List<T> list, Predicate<T> criterion) {
long total = 0;
for (T element : list) {
if (criterion.test(element)) {
total++;
}
}
return total;
}
}Inference means you almost never have to write the type:
List<Book> catalog = Utilities.listOf(
new Book("Effective Java", "978-0000000001"),
new Book("Refactoring", "978-0000000003"));
Utilities.swap(catalog, 0, 1); // T is inferred: Book
Book first = Utilities.first(catalog); // T is inferred: Book
long onLoan = Utilities.count(catalog, Book::isOnLoan);When inference is not enough (or you want to be explicit), the syntax for stating the type is awkward but it exists: it goes before the method name, after the dot.
Generic method or generic class? The practical rule:
| Situation | Choice |
|---|---|
| The type appears in several members and defines the object's state | Generic class (Box<T>, Repository<T>) |
| The type is only used inside a method, in its signature | Generic method (static <T> void swap) |
| It is a static utility method | Always a generic method (a static cannot use the class's T) |
That last point surprises people. This does not compile:
public class Box<T> {
// ERROR: non-static type variable T cannot be referenced from a static context
public static T createEmpty() { return null; }
}The class's T belongs to one concrete instance (Box<Book>), and a static method has no instance. The solution is to declare the method generic with its own parameter:
- Bounds:
<T extends Comparable<T>>
<T extends Comparable<T>>An unconstrained T only guarantees that it is an Object: you can call toString(), equals() and hashCode(), and nothing else. If you need more, you have to bound the parameter with extends:
/**
* Returns the largest element of the list.
* T MUST know how to compare itself with itself.
*/
public static <T extends Comparable<T>> T max(List<T> list) {
if (list.isEmpty()) {
throw new IllegalArgumentException("empty list");
}
T largest = list.get(0);
for (T element : list) {
if (element.compareTo(largest) > 0) { // legal ONLY thanks to the bound
largest = element;
}
}
return largest;
}Without the extends Comparable<T>, the line element.compareTo(largest) would not compile: Object has no compareTo.
Two important nuances:
extends here means "is a subtype of", not "inherits from". The same word is used for classes and for interfaces. <T extends Runnable> accepts any class that implements Runnable.
The upper bound has a useful side effect: inside the method, T behaves like its bound. If you write <T extends Material>, inside you can call element.getTitle() with no cast.
/** Concatenates the titles of any collection of materials. */
public static <T extends Material> String titles(List<T> materials) {
StringBuilder sb = new StringBuilder();
for (T m : materials) {
sb.append(m.getTitle()).append("; "); // getTitle() belongs to Material
}
return sb.toString();
}
- Multiple bounds with
&
&A parameter can require several conditions at once, joined with &:
/**
* Sorts and prints elements that on top of being comparable
* know how to serialise themselves.
*/
public static <T extends Comparable<T> & Serializable> void archiveSorted(List<T> items) {
Collections.sort(items);
for (T item : items) {
System.out.println("Archiving: " + item);
}
}Two syntax rules the compiler enforces:
- At most one class, and the rest interfaces (Java has no multiple inheritance of classes).
- If there is a class, it goes first.
<T extends Material & Comparable<T>>is correct;<T extends Comparable<T> & Material>does not compile.
In BiblioTech this has a very natural use: "anything that is a material and that can also be compared".
public static <T extends Material & Comparable<T>> List<T> sortCatalog(List<T> materials) {
List<T> copy = new ArrayList<>(materials);
Collections.sort(copy);
return copy;
}
- Invariance: why
List<String> is not a List<Object>
List<String> is not a List<Object>This is the part that costs most effort, and it deserves patience.
String is a subtype of Object. It seems reasonable to expect List<String> to be a subtype of List<Object>. It is not. Generics in Java are invariant: Box<A> and Box<B> have no inheritance relationship at all, however much A and B do.
graph TD
O["Object"] --> S["String"]
LO["List of Object"] -.->|"NO relationship"| LS["List of String"]
LS -.->|"NO relationship"| LO
Why this apparent rigidity? Because if it were allowed, the type system would break. Look at what would happen if it compiled:
List<String> texts = new ArrayList<>();
List<Object> objects = texts; // suppose this compiled
objects.add(Integer.valueOf(42)); // legal: an Integer IS an Object
String s = texts.get(0); // BOOM: ClassCastException at run timeThe list is the same one; we have only changed the label we look at it through. Through the List<Object> label we put in an Integer, and through the List<String> label we take it out as a String. We would be right back at section 1's problem, which is what generics came to solve.
Invariance is the price of the guarantee. The compiler forbids the assignment because it is the only way to be sure that a List<String> will only ever contain String.
- The contrast with covariant arrays
What is interesting is that arrays are covariant, because they were designed before generics existed and it had to be possible to write Arrays.sort(Object[]). And the result is exactly the disaster that generics avoid:
public class ArrayCovariance {
public static void main(String[] args) {
String[] texts = { "Effective Java", "Refactoring" };
Object[] objects = texts; // COMPILES: arrays are covariant
objects[0] = Integer.valueOf(42); // compiles... and blows up at run time
System.out.println(texts[0]);
}
}Exception in thread "main" java.lang.ArrayStoreException: java.lang.Integer
at ArrayCovariance.main(ArrayCovariance.java:8)The JVM checks at run time the real type of every element stored into an array, and throws ArrayStoreException. That is: arrays pay for a check on every write and even so the error arrives late.
| Aspect | Arrays | Generics |
|---|---|---|
| Variance | Covariant (String[] is an Object[]) |
Invariant (List<String> is not a List<Object>) |
| Type checking | At run time (ArrayStoreException) |
At compile time |
| Information at run time | Reified: the array knows its type | Erased: they do not keep it |
| Allowed elements | Only reifiable types | Any reference type |
This table also explains why arrays and generics do not mix well (section 15): they are two mechanisms with opposing philosophies. The classic recommendation is to prefer List<T> to T[].
- Wildcards:
<?>, <? extends X> and <? super X>
<?>, <? extends X> and <? super X>Invariance is correct but inconvenient. If you write this method:
public static void printTitles(List<Material> materials) {
for (Material m : materials) {
System.out.println(m.getTitle());
}
}...you cannot pass it a List<Book>, even though Book extends Material. And that is absurd: the method only reads.
Wildcards (?) recover the flexibility without losing the safety. There are three forms.
12.1. The unbounded wildcard: <?>
List<?> means "a list of some unknown but specific type".
/** Works for ANY list: it only uses methods that do not depend on T. */
public static void reportSize(List<?> list) {
System.out.println("The list has " + list.size() + " elements");
for (Object o : list) { // the only safe thing is to read as Object
System.out.println(" - " + o);
}
}reportSize(List.of("a", "b")); // List<String>
reportSize(List.of(new Book("Effective Java", "978-0000000001"))); // List<Book>What you CANNOT do with a List<?>: add anything (except null).
public static void tryToAdd(List<?> list) {
// list.add("hello"); // DOES NOT COMPILE
// list.add(new Object()); // DOES NOT COMPILE
list.add(null); // the only one allowed
}And it is logical: if you do not know what type the list is, you cannot know what is safe to put in. It could be a List<Book> and you would be putting in a String.
List<?> is not the same as List<Object>. List<Object> is a list that accepts anything (and that you can indeed add to); List<?> is a list of a specific type that you happen not to know.
12.2. Wildcard with an upper bound: <? extends X>
List<? extends Material> means "a list of Material or of any subtype of it". Now it works:
public static void printTitles(List<? extends Material> materials) {
for (Material m : materials) { // reading as Material: SAFE
System.out.println(m.getTitle());
}
// materials.add(new Book(...)); // DOES NOT COMPILE
}List<Book> books = new ArrayList<>();
List<Magazine> magazines = new ArrayList<>();
List<Material> mixed = new ArrayList<>();
printTitles(books); // OK
printTitles(magazines); // OK
printTitles(mixed); // OKYou can read, you cannot write. Why can you not add a Book? Because List<? extends Material> might really be a List<Magazine>, and putting a Book in it would corrupt it. The compiler does not know which of the subtypes it is, so it forbids all writes.
12.3. Wildcard with a lower bound: <? super X>
List<? super Book> means "a list of Book or of any supertype of it": it could be List<Book>, List<Material> or List<Object>.
public static void addSampleBooks(List<? super Book> target) {
target.add(new Book("Effective Java", "978-0000000001")); // SAFE
target.add(new Book("Refactoring", "978-0000000003"));
// Book b = target.get(0); // DOES NOT COMPILE
Object o = target.get(0); // the only safe thing is Object
}List<Book> booksOnly = new ArrayList<>();
List<Material> materials = new ArrayList<>();
List<Object> anything = new ArrayList<>();
addSampleBooks(booksOnly); // OK
addSampleBooks(materials); // OK
addSampleBooks(anything); // OKYou can write, you can barely read. Adding a Book is always safe: whether the list is of Book, of Material or of Object, a Book fits in all of them. But when reading, the only thing the compiler can guarantee is Object, because the list might be a List<Object> full of String.
- PECS: Producer Extends, Consumer Super
The rule that sums up the previous two sections is known as PECS, coined by Joshua Bloch:
Producer Extends, Consumer Super. If the parameter produces values that you read, use
? extends. If the parameter consumes values that you give it, use? super. If it does both, do not use a wildcard: use the exact type.
The way to decide is to ask yourself: where does the data come from and where does it go?
graph LR
A["Source collection<br/>PRODUCES data<br/>? extends T"] -->|"you read from here"| M["Your method"]
M -->|"you write here"| B["Target collection<br/>CONSUMES data<br/>? super T"]
The canonical example is a copy method:
/**
* Copies elements from source to target.
* source PRODUCES elements -> ? extends T
* target CONSUMES elements -> ? super T
*/
public static <T> void copy(List<? extends T> source, List<? super T> target) {
for (T element : source) {
target.add(element);
}
}With that signature, this works:
List<Book> books = List.of(
new Book("Effective Java", "978-0000000001"),
new Book("Design Patterns", "978-0000000002"));
List<Material> store = new ArrayList<>();
List<Object> log = new ArrayList<>();
Utilities.<Material>copy(books, store); // Book produces, Material consumes
Utilities.<Book>copy(books, log); // Book produces, Object consumesExamples that fail when you choose badly
Mistake 1: using extends where super is needed.
// WRONG: the target declared as a producer
public static <T> void copyWrong(List<? extends T> source, List<? extends T> target) {
for (T element : source) {
target.add(element); // DOES NOT COMPILE
}
}error: no suitable method found for add(T)
target.add(element);
^
method Collection.add(CAP#1) is not applicable
(argument mismatch; T cannot be converted to CAP#1)That CAP#1 is the captured type: the compiler is saying "I do not know what concrete type the wildcard is, so I cannot guarantee that your T fits".
Mistake 2: using super where extends is needed.
// WRONG: the source declared as a consumer
public static <T> void copyWrong2(List<? super T> source, List<? super T> target) {
for (T element : source) { // DOES NOT COMPILE
target.add(element);
}
}Reading from a ? super T gives you Object, not T.
PECS in the JDK
Once you have understood it, you start seeing PECS everywhere. These JDK signatures stop looking like noise:
// Collections: the source produces, the destination consumes
public static <T> void copy(List<? super T> dest, List<? extends T> src)
// The comparator CONSUMES elements in order to compare them
public static <T> void sort(List<T> list, Comparator<? super T> c)
// Stream.map: the function CONSUMES T and PRODUCES R
<R> Stream<R> map(Function<? super T, ? extends R> mapper)
// Optional.ifPresent: the consumer CONSUMES T
public void ifPresent(Consumer<? super T> action)The Comparator<? super T> case is the most useful one to understand. It means: "to sort a list of Book, a Comparator<Book> will do, but so will a Comparator<Material>". And it is obvious: if you know how to compare materials in general, you know how to compare books.
Comparator<Material> byTitle = Comparator.comparing(Material::getTitle);
List<Book> books = new ArrayList<>(...);
books.sort(byTitle); // works thanks to the ? super TWithout that ? super, this line would not compile and you would have to duplicate comparators for every subtype.
Practical tip: apply PECS to the APIs you write for others (public methods, libraries). Inside your application code, with concrete types, you do not need it and it adds noise.
Exception to the rule: never use a wildcard in a public method's return type. It forces the caller to deal with wildcards for no gain at all.
- Type erasure: what the compiler really does
Here is the key to understanding all the limitations that follow.
When Java 5 introduced generics, there were millions of lines of code written against a parameterless List, and trillions of bytes of already-compiled classes. The design decision was: generics exist only at compile time. The compiler uses them to check your code, and then erases them. This is called type erasure.
Specifically, the compiler:
- Replaces every type parameter with its bound (or with
Objectif it has none). - Inserts the necessary casts where they are needed.
- Generates bridge methods when inheritance requires them (section 16).
What you write:
public class Box<T> {
private T content;
public T get() { return content; }
public void put(T c) { this.content = c; }
}What is left in the .class (seen conceptually):
public class Box {
private Object content;
public Object get() { return content; }
public void put(Object c) { this.content = c; }
}And at the point of use, you write:
and the compiler generates:
The casts did not go away: the compiler writes them for you, and only after having checked that they are correct. That is the difference from 1999's code.
With a bound, erasure uses the bound instead of Object:
public static <T extends Material> String titles(List<T> list)
// erases to
public static String titles(List list) // and inside, T is treated as MaterialYou can see it for yourself with javap:
javap shows the generic signature because that information is kept in the class's metadata (the Signature attribute), so that the compiler can check code that uses this class. But the JVM does not use it at run time: the real bytecode operates on Object. With javap -c you would see the instructions on Object and the checkcasts.
An important nuance for 10-03: erasure does not remove all the generic information from the
.classfile. The signatures of classes, fields and methods do keep their declared generic types; what is lost is the type of a concrete instance at run time. AnArrayListobject does not know whether it was born as anArrayList<Book>. Reflection (10-03) can read the former withgetGenericType(), not the latter.
- The practical consequences of erasure
These five limitations follow directly from erasure, and it is worth recognising them instantly when the compiler complains.
15.1. You cannot do new T()
public class Repository<T> {
public T createEmpty() {
// return new T(); // ERROR: type parameter T cannot be instantiated directly
return null;
}
}At run time T does not exist: there is no class from which to build an object. The solution is to pass a Supplier<T> (module 4's) or a type token Class<T> (section 19):
public class Repository<T> {
private final Supplier<T> factory;
public Repository(Supplier<T> factory) {
this.factory = factory;
}
public T createEmpty() {
return factory.get(); // the factory DOES know how to build a T
}
}
// usage
Repository<Book> repo = new Repository<>(Book::new);15.2. You cannot do instanceof List<String>
if (object instanceof List<String>) { } // ERROR: illegal generic type for instanceof
if (object instanceof List<?>) { } // OK: only the raw type is checkableAt run time there is no way of telling a List<String> from a List<Book>: both are a plain ArrayList. You can only check List<?> (or List, with a warning).
15.3. You cannot overload methods that differ only in the generic
public class Reports {
// ERROR: name clash: both erase to process(List)
public void process(List<Book> books) { }
public void process(List<Magazine> magazines) { }
}After erasure, both signatures are process(List). The solution is to give them different names (processBooks, processMagazines), which also reads better.
15.4. You cannot create generic arrays
Arrays are reified (section 11): they need to know their type at run time in order to throw ArrayStoreException. A T[] cannot know it. The two ways out:
// SOLUTION 1 (recommended): use a List
private final List<T> items = new ArrayList<>();
// SOLUTION 2: array of Object with a cast and a suppressed warning (what ArrayList does inside)
@SuppressWarnings("unchecked")
private T[] items = (T[]) new Object[10];The second is the one the JDK's own ArrayList uses (transient Object[] elementData), and it is safe only because the array is private and is never handed out as a T[]. If you did return it, whoever received it would get a ClassCastException when assigning it.
15.5. Parameterised types share the same class
List<Book> books = new ArrayList<>();
List<String> texts = new ArrayList<>();
System.out.println(books.getClass()); // class java.util.ArrayList
System.out.println(books.getClass() == texts.getClass()); // trueBoth are the same Class. The same happens with static members: a generic class has a single set of static members, shared by all its parameterisations. Box<Book> and Box<String> share the same static counter.
- Bridge methods
A technical note that explains an odd detail you may come across in stack traces and in reflection.
When a class implements a generic interface with a concrete type, erasure creates a signature mismatch. Consider:
public class BookComparator implements Comparator<Book> {
@Override
public int compare(Book a, Book b) {
return a.getTitle().compareTo(b.getTitle());
}
}After erasure, Comparator declares int compare(Object, Object), but your class defines int compare(Book, Book). They are different signatures: polymorphism would not work. The compiler solves this by generating an extra method that is invisible to you:
// generated by the compiler, marked as synthetic and bridge
public int compare(Object a, Object b) {
return compare((Book) a, (Book) b); // delegates, with the cast
}Practical consequences:
- If in 10-03 you call
getDeclaredMethods()onBookComparator, you will see twocomparemethods. They are filtered out withmethod.isBridge()ormethod.isSynthetic(). - If somebody passes an object of the wrong type through an unchecked route (a raw type), the
ClassCastExceptionfires inside the bridge method, on a line you did not write. Seeingcompare(Unknown Source)in a trace usually means this.
- Raw types and why they exist
A raw type is a generic type used without its parameters: List instead of List<Book>.
It compiles, with a warning:
Note: Example.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.They exist purely for backward compatibility. When Java 5 introduced generics, all the existing code used List without parameters. Forbidding it would have broken the entire ecosystem overnight. So mixing old and new code was allowed.
The dangerous part is that a raw type switches off generic checking for everything it touches, even upwards:
public static void poison(List raw) { // raw type
raw.add("I am not a Book");
}
public static void main(String[] args) {
List<Book> catalog = new ArrayList<>();
poison(catalog); // compiles, only a warning
for (Book b : catalog) { // ClassCastException here
System.out.println(b.getTitle());
}
}| How it is written | Compiles? | Checking | When to use it |
|---|---|---|---|
List<Book> |
Yes | Full | Always |
List<?> |
Yes | Safe: read-only as Object |
When the type does not matter |
List<Object> |
Yes | Full (a list of anything) | Rarely |
List (raw) |
Yes, with a warning | None | Never in new code |
Rule: compile with -Xlint:unchecked and treat the warnings as errors. If a raw type appears in your code, it is a bug waiting its turn.
@SuppressWarnings("unchecked") and when it is legitimate
@SuppressWarnings("unchecked") and when it is legitimate@SuppressWarnings (which you will see formally in 10-02) silences a compiler warning. It is a necessary tool and, misused, a disaster.
The three golden rules:
- Minimum scope. Never on a whole class; ideally on a local variable.
- A mandatory comment explaining why the operation is safe despite the warning.
- Only when you can prove it, not when you want the compiler to shut up.
Bad:
@SuppressWarnings("unchecked") // over the whole class: it silences future bugs
public class Repository<T> { ... }Good:
public <T> T[] copyInto(T[] target) {
if (target.length < size) {
// SAFE: Arrays.copyOf with the target array's real type
// returns an array of the same type as 'target', which is T[].
@SuppressWarnings("unchecked")
T[] fresh = (T[]) Arrays.copyOf(items, size, target.getClass());
return fresh;
}
System.arraycopy(items, 0, target, 0, size);
return target;
}Notice the trick: a local variable is declared purely so the annotation can go there with the smallest possible scope. It is idiomatic and it appears in the JDK itself.
- The type token:
Class<T>
Class<T>If T does not exist at run time, how do Jackson, Hibernate or Spring manage to build objects of the right type? With a type token: the Class<T> is passed as an ordinary argument, and that object does exist at run time.
package com.nexussoftware.bibliotech.service;
import java.lang.reflect.InvocationTargetException;
/**
* Generic factory that CAN create instances of T,
* because it receives the Class<T> as a type token.
*/
public class EntityFactory<T> {
private final Class<T> type;
public EntityFactory(Class<T> type) {
this.type = type;
}
/** Creates an instance using the no-argument constructor. */
public T create() {
try {
return type.getDeclaredConstructor().newInstance();
} catch (NoSuchMethodException | InstantiationException
| IllegalAccessException | InvocationTargetException e) {
throw new IllegalStateException(
"Could not instantiate " + type.getSimpleName(), e);
}
}
/**
* Converts safely: a cast checked at RUN TIME.
* If the object is not of the expected type it throws ClassCastException
* right here, not three layers further down.
*/
public T convert(Object candidate) {
return type.cast(candidate);
}
public String typeName() {
return type.getSimpleName();
}
}EntityFactory<Book> factory = new EntityFactory<>(Book.class);
Book fresh = factory.create();
System.out.println(factory.typeName()); // BookClass<T> is generic itself: Book.class has type Class<Book>, and that is why type.cast(x) returns a T with no need for a manual cast or a @SuppressWarnings. It is the clean way of recovering at run time what erasure took away.
This pattern is called a typesafe heterogeneous container, and it is how things like a web request's attributes are implemented:
public class TypedContext {
private final Map<Class<?>, Object> values = new HashMap<>();
public <T> void put(Class<T> type, T value) {
values.put(Objects.requireNonNull(type), value);
}
public <T> T get(Class<T> type) {
return type.cast(values.get(type)); // safe cast
}
}TypedContext ctx = new TypedContext();
ctx.put(Employee.class, new Employee("Marta Ruiz"));
ctx.put(String.class, "session-4711");
Employee who = ctx.get(Employee.class); // no cast, no warningLimitation of type tokens: they cannot represent parameterised types. There is no List<Book>.class. Libraries solve this with the super type token technique (an anonymous class that does keep the generic type in its signature, recoverable by reflection); it is what lies behind Jackson's TypeReference, which you will see in 11-07. You will explain the mechanics of why it works in 10-03.
- Generics and exceptions
Two concrete limitations worth knowing about.
You cannot catch a generic type:
public <E extends Exception> void execute(Runnable task, Class<E> type) {
try {
task.run();
} catch (E e) { // ERROR: cannot use the type variable E in a catch clause
// ...
}
}At run time there is no E, and the JVM's catch mechanism compares real types. The alternative is to catch the supertype and check with the token:
public <E extends Exception> void execute(Runnable task, Class<E> type) throws E {
try {
task.run();
} catch (Exception e) {
if (type.isInstance(e)) {
throw type.cast(e); // rethrown already typed
}
throw new BiblioTechException("Unexpected failure in the task", e);
}
}A generic class cannot extend Throwable:
// ERROR: a generic class may not extend java.lang.Throwable
public class TypedError<T> extends BiblioTechException { }For the same reason: the catch could not tell a TypedError<Book> from a TypedError<Loan>.
You CAN use a generic type in the throws clause, and it is a useful trick:
/** Runs the task propagating its typed exception. */
public static <T, E extends Exception> T attempt(FailingSupplier<T, E> task) throws E {
return task.get();
}
@FunctionalInterface
public interface FailingSupplier<T, E extends Exception> {
T get() throws E;
}
- BiblioTech:
Repository<T extends Identifiable>
Repository<T extends Identifiable>Time to settle the debt. ConcurrentCatalog and SafeLoanRegistry do the same thing with different types: store by identifier, look up, list, remove. A single generic repository replaces them.
package com.nexussoftware.bibliotech.service;
import com.nexussoftware.bibliotech.domain.Identifiable;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Predicate;
/**
* Generic, thread-safe repository for any entity
* that knows how to give its identifier.
*
* The bound <T extends Identifiable> allows calling getId()
* inside the class with no cast at all.
*/
public class Repository<T extends Identifiable> {
private final Map<String, T> byId = new ConcurrentHashMap<>();
private final String name;
public Repository(String name) {
this.name = Objects.requireNonNull(name, "name");
}
/** Stores or replaces. Returns the previous value, if there was one. */
public Optional<T> save(T entity) {
Objects.requireNonNull(entity, "entity");
return Optional.ofNullable(byId.put(entity.getId(), entity));
}
/** Stores only if it was absent. true if it was inserted. */
public boolean saveIfAbsent(T entity) {
return byId.putIfAbsent(entity.getId(), entity) == null;
}
public Optional<T> findById(String id) {
return Optional.ofNullable(byId.get(id));
}
public Optional<T> remove(String id) {
return Optional.ofNullable(byId.remove(id));
}
public boolean contains(String id) {
return byId.containsKey(id);
}
public int size() {
return byId.size();
}
/** Unmodifiable view: nobody can alter the repository through the back door. */
public List<T> listAll() {
return List.copyOf(byId.values());
}
/**
* Filters with a predicate.
* Predicate<? super T> by PECS: the predicate CONSUMES elements,
* so a Predicate<Identifiable> will do as well.
*/
public List<T> find(Predicate<? super T> criterion) {
List<T> result = new ArrayList<>();
for (T entity : byId.values()) {
if (criterion.test(entity)) {
result.add(entity);
}
}
return result;
}
/**
* Sorts a copy.
* Comparator<? super T> by PECS: a Comparator<Identifiable> works
* for sorting any subtype.
*/
public List<T> listSorted(Comparator<? super T> order) {
List<T> copy = new ArrayList<>(byId.values());
copy.sort(order);
return copy;
}
/**
* Dumps all the entities into the target.
* ? super T by PECS: the target CONSUMES.
*/
public void dumpInto(Collection<? super T> target) {
target.addAll(byId.values());
}
@Override
public String toString() {
return "Repository[" + name + ", " + byId.size() + " entities]";
}
}Now the entities implement Identifiable:
public abstract class Material implements Identifiable {
private final String isbn;
private final String title;
// ... the rest from module 3
@Override
public String getId() {
return isbn;
}
}public class Loan implements Identifiable {
private final String reference; // e.g. "LN-2026-0041"
@Override
public String getId() {
return reference;
}
}And the usage, with two different types and a single repository class:
package com.nexussoftware.bibliotech;
import com.nexussoftware.bibliotech.domain.*;
import com.nexussoftware.bibliotech.service.Repository;
import java.util.Comparator;
import java.util.List;
public class BiblioTechApp {
public static void main(String[] args) {
Repository<Material> catalog = new Repository<>("catalogue");
Repository<Loan> loans = new Repository<>("loans");
catalog.save(new Book("Effective Java", "978-0000000001"));
catalog.save(new Book("Design Patterns", "978-0000000002"));
catalog.save(new Book("Refactoring", "978-0000000003"));
loans.save(new Loan("LN-2026-0041", "978-0000000001", "Marta Ruiz"));
loans.save(new Loan("LN-2026-0042", "978-0000000003", "Diego Alonso"));
// Not a single cast in everything that follows.
catalog.findById("978-0000000001")
.ifPresent(m -> System.out.println("Found: " + m.getTitle()));
List<Material> sorted =
catalog.listSorted(Comparator.comparing(Material::getTitle));
sorted.forEach(m -> System.out.println(" " + m.getTitle()));
List<Loan> martasLoans = loans.find(l -> l.getEmployee().equals("Marta Ruiz"));
System.out.println("Marta's loans: " + martasLoans.size());
System.out.println(catalog);
System.out.println(loans);
}
}Found: Effective Java
Design Patterns
Effective Java
Refactoring
Marta's loans: 1
Repository[catalogue, 3 entities]
Repository[loans, 2 entities]What has been gained, concretely:
| Before | Now |
|---|---|
ConcurrentCatalog (≈120 lines) |
Repository<Material> (1 declaration line) |
SafeLoanRegistry (≈130 lines) |
Repository<Loan> (1 line) |
| Two implementations to fix in parallel when there is a bug | Only one |
A Repository of Object would force a cast on every findById |
No casts |
And adding a repository of MeetingRoom or of Reservation is now one line, not a class.
- BiblioTech: generic
Result<T>
Result<T>In 06-07 you created Result as a "result object": an alternative to throwing exceptions for expected errors. But it was a Result that could only carry a String, or that carried Object and forced you to cast. Generics to the rescue.
package com.nexussoftware.bibliotech.service;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.function.Function;
/**
* Result of an operation that can fail in an expected way.
* It either holds a value of type T, or it holds an error message.
* Never both, never neither.
*
* Private constructor + static factories: it is impossible to build
* an incoherent Result.
*/
public final class Result<T> {
private final T value;
private final String error;
private Result(T value, String error) {
this.value = value;
this.error = error;
}
/**
* Static generic method: it declares its own <U>, because a
* static method CANNOT use the class's T (section 7).
*/
public static <U> Result<U> success(U value) {
return new Result<>(Objects.requireNonNull(value, "value"), null);
}
public static <U> Result<U> failure(String error) {
return new Result<>(null, Objects.requireNonNull(error, "error"));
}
public boolean isSuccess() { return error == null; }
public boolean isFailure() { return error != null; }
public T value() {
if (isFailure()) {
throw new NoSuchElementException("The result is a failure: " + error);
}
return value;
}
public String error() {
if (isSuccess()) {
throw new NoSuchElementException("The result is a success");
}
return error;
}
public T orDefault(T alternative) {
return isSuccess() ? value : alternative;
}
/**
* Transforms the value on success; propagates the error otherwise.
* Function<? super T, ? extends R> by PECS:
* the function CONSUMES T -> ? super T
* the function PRODUCES R -> ? extends R
*/
public <R> Result<R> map(Function<? super T, ? extends R> transformation) {
if (isFailure()) {
return Result.failure(error); // the error travels untouched
}
return Result.success(transformation.apply(value));
}
/** Chains operations that can themselves fail, without nesting Result<Result<R>>. */
public <R> Result<R> flatMap(Function<? super T, Result<R>> next) {
return isFailure() ? Result.failure(error) : next.apply(value);
}
@Override
public String toString() {
return isSuccess() ? "Success[" + value + "]" : "Failure[" + error + "]";
}
}Usage in the loans service:
package com.nexussoftware.bibliotech.service;
import com.nexussoftware.bibliotech.domain.*;
public class LoanManager {
private final Repository<Material> catalog;
private final Repository<Loan> loans;
public LoanManager(Repository<Material> catalog, Repository<Loan> loans) {
this.catalog = catalog;
this.loans = loans;
}
/** The return type SAYS this can fail, and with what value it succeeds. */
public Result<Loan> lend(String isbn, String employee) {
Material material = catalog.findById(isbn).orElse(null);
if (material == null) {
return Result.failure("There is no material with ISBN " + isbn);
}
if (material.isOnLoan()) {
return Result.failure("The material '" + material.getTitle() + "' is already on loan");
}
material.markOnLoan(employee);
Loan loan = new Loan(nextReference(), isbn, employee);
loans.save(loan);
return Result.success(loan);
}
private String nextReference() {
return String.format("LN-2026-%04d", loans.size() + 1);
}
}And the key point, in the presentation layer:
Result<Loan> result = manager.lend("978-0000000001", "Nuria Vidal");
if (result.isSuccess()) {
System.out.println("Loan registered: " + result.value().getId());
} else {
System.out.println("Could not lend: " + result.error());
}
// Chaining with no intermediate checks
String receipt = manager.lend("978-0000000002", "Diego Alonso")
.map(Loan::getId)
.map(id -> "Receipt no. " + id)
.orDefault("No receipt: the loan could not be registered");
System.out.println(receipt);The return type documents the contract. Result<Loan> says, without reading a single line of the implementation: this can fail in an expected way, and when it goes well it gives you a Loan. Compare it with Loan lend(...) throws BiblioTechException (which forces a try/catch for a normal case) or with Loan lend(...) returning null (which says nothing and produces NullPointerException).
In 10-04 you will see Optional<T>, which is the particular case of this pattern when the error carries no information: there either is a value or there is not.
Common Mistakes and Tips
1. Confusing List<Object> with List<?>. The first accepts anything and allows adding; the second is of an unknown type and only allows reading as Object and adding null. If your method only reads, use List<?> or List<? extends X>, never List<Object>, because the latter does not accept a List<String>.
2. Using raw types "to make it compile". Every raw type switches off generic checking in one area of the code. Always compile with -Xlint:unchecked -Xlint:rawtypes and fix the warnings.
3. Expecting generics to exist at run time. No new T(), no instanceof List<String>, no new T[10], no overloads that differ only in the parameter. When you need the real type, pass a Class<T> or a Supplier<T>.
4. Believing that List<Book> is a List<Material>. It is not. If your method only reads from the list, declare List<? extends Material> and it will work with both.
5. Putting wildcards in the return type. public List<? extends Material> list() forces whoever uses it to drag wildcards through all their code. Return List<Material>.
6. Silencing warnings with @SuppressWarnings on the whole class. Minimum scope, always with a comment justifying why the operation is safe. If you cannot justify it, the warning is a bug.
7. Forgetting PECS when writing public APIs. A Comparator<T> instead of Comparator<? super T> forces you to duplicate comparators. A Consumer<T> instead of Consumer<? super T> prevents reusing a generic consumer.
8. Mixing arrays and generics. List<Book>[] cannot be created directly and array covariance fights against generic invariance. Use List<List<Book>>.
Tip 1: start concrete and generalise afterwards. Write a Repository of Material first, make it work, and only when you need the second type turn it into Repository<T>. Generalising before you have two real cases usually produces the wrong abstractions.
Tip 2: use the loosest bound that serves you. <T extends Identifiable> instead of <T extends Material> if you only call getId(). Every extra restriction closes doors for the users of your API.
Tip 3: read the JDK's signatures. <R> Stream<R> map(Function<? super T, ? extends R> mapper) is no longer noise: it is "transform each T into an R with a function that accepts T or any supertype and produces R or any subtype". Understanding them is 80% of mastering generics.
Tip 4: when the compiler says CAP#1, think wildcards. That identifier is the captured type of a ?. It almost always means you have chosen wrongly between extends and super.
Exercises
Exercise 1: Generic Cache<K, V> with a size limit
Write a Cache<K, V> class for BiblioTech that:
- Stores key-value pairs with a fixed maximum capacity.
- When the capacity is exceeded, removes the least recently used entry (LRU).
- Offers
Optional<V> get(K key),void put(K key, V value),int size()andList<K> keysByAge(). - Has a method
V getOrCompute(K key, Function<? super K, ? extends V> computation)that returns the cached value or computes it and stores it. - Keeps statistics of hits and misses.
Hint: a LinkedHashMap with the three-argument constructor and removeEldestEntry overridden does the LRU for you.
Exercise 2: PECS in a statistics aggregator
Write a utility class Aggregator with these static methods, choosing the wildcards correctly:
sum(Collection<? ...> numbers)→ returns adoublewith the sum of a collection of any numeric type.copyMatching(Collection<? ...> source, Collection<? ...> target, Predicate<? ...> criterion)→ copies from the source to the target the elements that satisfy the criterion.maxBy(Collection<? ...> elements, Comparator<? ...> order)→ returns the maximum, orOptional.empty()if it is empty.
Then write a main that shows, with BiblioTech's classes, that you can pass a List<Book> as the source and a List<Material> as the target, and a Comparator<Material> to sort books.
Exercise 3: ChainableValidator<T> with Result<T>
Build a generic validator for BiblioTech:
ChainableValidator<T>accumulates rules: each rule is aPredicate<? super T>with its error message.- A method
withRule(Predicate<? super T> rule, String message)that returns the validator itself (chainable). - A method
Result<T> validate(T object)that returns success with the object if it passes every rule, or failure with all the messages of the rules it broke, separated by;. - A static method
of(Class<T> type)that creates the validator (use the type token for the message).
Use it to validate a Book: ISBN not null and 17 characters long, title not empty, title shorter than 200 characters.
Solutions
Solution 1
package com.nexussoftware.bibliotech.service;
import java.util.*;
import java.util.function.Function;
/**
* Generic LRU cache with a maximum capacity.
*
* K and V are independent: you can have a Cache<String, Card>
* or a Cache<Integer, List<Loan>> with the same class.
*/
public class Cache<K, V> {
private final int capacity;
private final LinkedHashMap<K, V> map;
private long hits = 0;
private long misses = 0;
public Cache(int capacity) {
if (capacity <= 0) {
throw new IllegalArgumentException("The capacity must be positive: " + capacity);
}
this.capacity = capacity;
// Third argument true = ACCESS order (not insertion order): that is the LRU.
// 0.75f is the usual load factor (picks up 05-05).
this.map = new LinkedHashMap<>(capacity, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
// When it returns true, LinkedHashMap removes the oldest entry.
return size() > Cache.this.capacity;
}
};
}
/** Returns Optional instead of null: absence is explicit (see 10-04). */
public Optional<V> get(K key) {
V value = map.get(key); // get() reorders because it is access order
if (value != null) {
hits++;
} else {
misses++;
}
return Optional.ofNullable(value);
}
public void put(K key, V value) {
Objects.requireNonNull(key, "key");
Objects.requireNonNull(value, "value");
map.put(key, value);
}
/**
* PECS applied: the function CONSUMES the key (? super K)
* and PRODUCES the value (? extends V).
* That way it accepts a Function<Object, Card> for a Cache<String, Card>.
*/
public V getOrCompute(K key, Function<? super K, ? extends V> computation) {
V existing = map.get(key);
if (existing != null) {
hits++;
return existing;
}
misses++;
V computed = computation.apply(key);
map.put(key, computed);
return computed;
}
public int size() {
return map.size();
}
/** From the least recently used to the most recent. */
public List<K> keysByAge() {
return new ArrayList<>(map.keySet());
}
public double hitRate() {
long total = hits + misses;
return total == 0 ? 0.0 : (double) hits / total;
}
@Override
public String toString() {
return String.format("Cache[%d/%d entries, %d hits, %d misses, %.0f%% hit rate]",
map.size(), capacity, hits, misses, hitRate() * 100);
}
}Test:
public class CacheTest {
public static void main(String[] args) {
Cache<String, Card> cards = new Cache<>(3);
// The computation simulates an expensive lookup (reading the CSV, or a network call)
Function<String, Card> expensiveLookup = isbn -> {
System.out.println(" [COMPUTING card for " + isbn + "]");
return new Card(isbn, "Title of " + isbn);
};
System.out.println("1) " + cards.getOrCompute("978-0000000001", expensiveLookup));
System.out.println("2) " + cards.getOrCompute("978-0000000002", expensiveLookup));
System.out.println("3) " + cards.getOrCompute("978-0000000001", expensiveLookup)); // hit
System.out.println("4) " + cards.getOrCompute("978-0000000003", expensiveLookup));
System.out.println("Keys before overflow: " + cards.keysByAge());
// This fourth key evicts the least recently used one,
// which is ...0002 (...0001 was refreshed in step 3).
System.out.println("5) " + cards.getOrCompute("978-0000000004", expensiveLookup));
System.out.println("Keys after: " + cards.keysByAge());
System.out.println(cards);
}
} [COMPUTING card for 978-0000000001]
1) Card[978-0000000001, Title of 978-0000000001]
[COMPUTING card for 978-0000000002]
2) Card[978-0000000002, Title of 978-0000000002]
3) Card[978-0000000001, Title of 978-0000000001]
[COMPUTING card for 978-0000000003]
4) Card[978-0000000003, Title of 978-0000000003]
Keys before overflow: [978-0000000002, 978-0000000001, 978-0000000003]
[COMPUTING card for 978-0000000004]
5) Card[978-0000000004, Title of 978-0000000004]
Keys after: [978-0000000001, 978-0000000003, 978-0000000004]
Cache[3/3 entries, 1 hits, 4 misses, 20% hit rate]Comments. Three things this exercise makes clear.
The anonymous class that extends LinkedHashMap uses the outer class's K and V. This works because it is an inner class (not static), and inner classes can indeed refer to the type parameters of the class that contains them (04-03). Notice also Cache.this.capacity: inside the anonymous class, plain capacity would not exist.
Access order is what makes the LRU. With accessOrder = true, every get() moves the entry to the end. That is why on overflow ...0002 is evicted and not ...0001, even though ...0001 was inserted earlier: it was accessed in step 3.
getOrCompute is the correct cache pattern. Without it, whoever uses the cache writes if (cache.get(k).isEmpty()) { ... }, with two lookups and a race condition if there are several threads. Compare it with module 5's Map.computeIfAbsent: it is the same idea.
Solution 2
package com.nexussoftware.bibliotech.service;
import java.util.*;
import java.util.function.Predicate;
public final class Aggregator {
private Aggregator() { } // utility class: not instantiated
/**
* PRODUCES numbers that we read -> ? extends Number.
* That way it accepts List<Integer>, List<Double>, List<Long>...
* With Collection<Number> it would NOT accept a List<Integer> (invariance).
*/
public static double sum(Collection<? extends Number> numbers) {
double total = 0;
for (Number n : numbers) {
total += n.doubleValue();
}
return total;
}
/**
* source PRODUCES -> ? extends T
* target CONSUMES -> ? super T
* criterion CONSUMES -> Predicate<? super T>
*/
public static <T> int copyMatching(Collection<? extends T> source,
Collection<? super T> target,
Predicate<? super T> criterion) {
int copied = 0;
for (T element : source) {
if (criterion.test(element)) {
target.add(element);
copied++;
}
}
return copied;
}
/**
* elements PRODUCES -> ? extends T
* order CONSUMES -> Comparator<? super T>
* The return has NO wildcard: a clean Optional<T>.
*/
public static <T> Optional<T> maxBy(Collection<? extends T> elements,
Comparator<? super T> order) {
Iterator<? extends T> it = elements.iterator();
if (!it.hasNext()) {
return Optional.empty();
}
T largest = it.next();
while (it.hasNext()) {
T candidate = it.next();
if (order.compare(candidate, largest) > 0) {
largest = candidate;
}
}
return Optional.of(largest);
}
}Demonstration:
public class AggregatorTest {
public static void main(String[] args) {
// 1) sum accepts any collection of subtypes of Number
List<Integer> pages = List.of(412, 395, 448);
List<Double> fines = List.of(1.50, 0.75, 3.20);
System.out.printf("Total pages: %.0f%n", Aggregator.sum(pages));
System.out.printf("Total fines: %.2f EUR%n", Aggregator.sum(fines));
// 2) source List<Book>, target List<Material>: two DIFFERENT types
List<Book> books = new ArrayList<>(List.of(
new Book("Effective Java", "978-0000000001", 412, true),
new Book("Design Patterns", "978-0000000002", 395, false),
new Book("Refactoring", "978-0000000003", 448, true)));
List<Material> onLoan = new ArrayList<>();
int copied = Aggregator.copyMatching(books, onLoan, Book::isOnLoan);
System.out.println("Copied into the materials list: " + copied);
onLoan.forEach(m -> System.out.println(" " + m.getTitle()));
// 3) Comparator<Material> sorting a List<Book>: thanks to the ? super T
Comparator<Material> byTitle = Comparator.comparing(Material::getTitle);
Aggregator.maxBy(books, byTitle)
.ifPresent(b -> System.out.println("Last alphabetically: " + b.getTitle()));
// And the maximum returns Book, not Material: the type is preserved
Optional<Book> last = Aggregator.maxBy(books, byTitle);
System.out.println("The static type is Book: " + last.map(Book::getIsbn).orElse("-"));
}
}Total pages: 1255
Total fines: 5.45 EUR
Copied into the materials list: 2
Effective Java
Refactoring
Last alphabetically: Refactoring
The static type is Book: 978-0000000003Comments.
Without ? extends Number, sum(pages) would not compile. A List<Integer> is not a List<Number> because of section 10's invariance. This is the shortest example of why wildcards exist.
The target can be of a supertype. copyMatching(books, onLoan, ...) copies Book into a List<Material>, which is exactly what one expects to be able to do and which without ? super T would be impossible.
The return preserves the most specific type. maxBy(books, byTitle) returns Optional<Book>, not Optional<Material>, because T is inferred from the collection. This is what you lose if you put the wildcard in the return.
Solution 3
package com.nexussoftware.bibliotech.service;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.Predicate;
/**
* Generic, chainable validator.
* It accumulates rules and returns a Result<T> with ALL the failures,
* not just the first one: whoever fills in a form is grateful to see them together.
*/
public final class ChainableValidator<T> {
/** Rule-message pair. A generic record (04-07 + section 3 of this lesson). */
private record Rule<T>(Predicate<? super T> condition, String message) { }
private final String typeName;
private final List<Rule<T>> rules = new ArrayList<>();
private ChainableValidator(String typeName) {
this.typeName = typeName;
}
/**
* Type token (section 19): the Class<T> provides the readable name
* and also pins T down without your having to write it.
*/
public static <T> ChainableValidator<T> of(Class<T> type) {
return new ChainableValidator<>(type.getSimpleName());
}
/**
* Returns this so it can be chained.
* Predicate<? super T> by PECS: the rule CONSUMES the object,
* so a Predicate<Object> or a Predicate<Material> will also do.
*/
public ChainableValidator<T> withRule(Predicate<? super T> condition, String message) {
rules.add(new Rule<>(Objects.requireNonNull(condition, "condition"),
Objects.requireNonNull(message, "message")));
return this;
}
public Result<T> validate(T object) {
if (object == null) {
return Result.failure("The " + typeName + " is null");
}
List<String> failures = new ArrayList<>();
for (Rule<T> rule : rules) {
if (!rule.condition().test(object)) {
failures.add(rule.message());
}
}
if (failures.isEmpty()) {
return Result.success(object);
}
return Result.failure("Invalid " + typeName + ": " + String.join("; ", failures));
}
public int ruleCount() {
return rules.size();
}
}Usage:
public class ValidatorTest {
public static void main(String[] args) {
ChainableValidator<Book> validator = ChainableValidator.of(Book.class)
.withRule(b -> b.getIsbn() != null, "the ISBN is mandatory")
.withRule(b -> b.getIsbn() != null
&& b.getIsbn().length() == 17, "the ISBN must be 17 characters long")
.withRule(b -> b.getTitle() != null
&& !b.getTitle().isBlank(), "the title cannot be empty")
.withRule(b -> b.getTitle() == null
|| b.getTitle().length() < 200, "the title exceeds 200 characters");
System.out.println("Rules configured: " + validator.ruleCount());
List<Book> candidates = List.of(
new Book("Effective Java", "978-0000000001"),
new Book("Design Patterns", "978-1"),
new Book("", "978-0000000003"));
for (Book candidate : candidates) {
Result<Book> result = validator.validate(candidate);
// Chaining with map: it only runs if there was a success
String line = result
.map(Book::getTitle)
.map(t -> "ACCEPTED: " + t)
.orDefault("REJECTED: " + (result.isFailure() ? result.error() : ""));
System.out.println(line);
}
// The validator is reusable with any Predicate<Material>
Predicate<Material> hasTitle = m -> m.getTitle() != null;
ChainableValidator<Book> other = ChainableValidator.of(Book.class)
.withRule(hasTitle, "needs a title"); // Predicate<Material> in a Book validator
System.out.println(other.validate(new Book("Refactoring", "978-0000000003")));
}
}Rules configured: 4
ACCEPTED: Effective Java
REJECTED: Invalid Book: the ISBN must be 17 characters long
REJECTED: Invalid Book: the title cannot be empty
Success[Book[Refactoring, 978-0000000003]]Comments.
The private, generic record Rule<T> shows that records are parameterised too, and that you can have nested generic types. Since it is private and static (records implicitly are), it has to declare its own <T>: it cannot use the outer class's.
Predicate<? super T> is not decorative. The last line of main passes a Predicate<Material> to a ChainableValidator<Book>. With a plain Predicate<T> that would not compile, and you would have to duplicate the common predicates for every subtype of Material.
Accumulating all the failures instead of stopping at the first is a design decision, not a detail. A validator that stops forces the user to fix one error, resubmit, discover the next one, fix it, resubmit. In 11-02 you will see that Spring's validation does exactly this and for the same reason.
And notice that the generic Result<T> has appeared here with no effort at all. The validator knows nothing about loans or books: it returns a Result<T> of whatever it validates. That is the gain from having generalised.
Conclusion
You have settled the oldest debt of the course: you now know exactly what that <T> means.
You know what problem they solve: before Java 5, a container was of Object, every read demanded a cast and every type error reached production as a ClassCastException. With generics the same error reaches your editor at compile time, the casts disappear, the API documents itself and the IDE can help you.
You write your own generic classes (Box<T>, Pair<A, B>), you know the conventions (T, E, K, V, R), you use the diamond <> and you know that in a static method you have to declare a new type parameter because the class's belongs to the instance. You write generic interfaces and you know that when implementing them you can pin the type down (implements Serialiser<Book>) or propagate it (class Decorator<T> implements Serialiser<T>). You write generic methods with static <T> void ... and you let inference do the work.
You constrain with bounds: <T extends Comparable<T>> so you can call compareTo, <T extends Material> so you can call getTitle, and multiple bounds with & with the class always first.
And you understand the hard part. Generics are invariant —List<String> is not a List<Object>— and you know exactly why: allowing it would let an Integer be put into a list of String and would bring back the problem of 1999. You have seen the contrast with covariant arrays, which do allow it and pay for it with ArrayStoreException at run time. Wildcards recover the flexibility: <?> for what does not matter, <? extends X> for reading, <? super X> for writing. And PECS —Producer Extends, Consumer Super— is the rule that decides which one, with the compile errors and the CAP#1 that show up when you get it wrong. Now the JDK's signatures (Comparator<? super T>, Function<? super T, ? extends R>) read themselves.
You know what the compiler really does: type erasure. It replaces every parameter with its bound, inserts the casts and generates bridge methods. And out of that come all the limitations you now recognise instantly: there is no new T(), no instanceof List<String>, no overloads that differ only in the generic, no generic arrays, and List<Book>.class does not exist. Raw types are only there for compatibility and switch off checking for everything they touch; @SuppressWarnings("unchecked") is legitimate with minimum scope and a written justification. And the type token Class<T> is the clean route to recovering at run time what erasure took away, with type.cast() doing the checked cast without warnings.
BiblioTech has changed visibly. ConcurrentCatalog and SafeLoanRegistry —two hundred and fifty almost identical lines— have disappeared in favour of a single Repository<T extends Identifiable> that is instantiated in one line for materials, loans, rooms or reservations, with PECS properly applied in find, listSorted and dumpInto. And module 6's Result is now a generic Result<T> with map and flatMap, whose return type documents the contract: this can fail, and when it goes well it gives you a Loan.
But there is something generics cannot do. Generics tell things to the compiler, and only to the compiler: which types fit where. They are no use for saying "this field must be exported to the CSV in the third column", or "this operation has to be recorded in the audit", or "this method has been deprecated since version 3". That kind of information —metadata about the code, aimed not only at the compiler but also at build tools and at the frameworks that run— needs a different mechanism.
That mechanism is annotations, and it is the next lesson. You will see the JDK's standard ones and what each really does, you will learn to create your own with @interface, and above all you will understand the meta-annotations —starting with @Retention, which decides whether your annotation will reach run time alive or die in the compiler—. BiblioTech will define @CsvField to mark which fields are exported and @Auditable to mark which operations are recorded. And then one question will remain open, to be answered by 10-03: an annotation does nothing on its own; somebody has to read it. That somebody is reflection.
Java Programming Course
Module 1: Introduction to Java
- Introduction to Java
- Setting Up the Development Environment
- Basic Syntax and Structure
- Variables and Data Types
- Operators
- Console Input and Output
- Your First Complete Program: BiblioTech
Module 2: Control Flow
- Conditional Statements
- Loops
- Switch Statements
- Break and Continue
- Debugging and Execution Traces
- Project: The BiblioTech Interactive Menu
Module 3: Object-Oriented Programming
- Introduction to OOP
- Classes and Objects
- Methods
- Constructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- The Object Class: equals, hashCode and toString
Module 4: Advanced Object-Oriented Programming
- Interfaces
- Abstract Classes
- Inner Classes
- Anonymous Classes
- Lambda Expressions
- Functional Interfaces and Method References
- Enums and Records
Module 5: Data Structures and Collections
- Arrays
- The Collections Framework
- ArrayList
- LinkedList
- HashMap
- HashSet
- Queue and Deque
- Stack
- Sorting and Searching Collections
Module 6: Exception Handling
- Introduction to Exceptions
- The Try-Catch Block
- Throw and Throws
- Custom Exceptions
- The Finally Block
- Try-with-resources and AutoCloseable
- Error Handling Strategies and Logging
Module 7: File Input/Output
- Reading Files
- Writing Files
- File Streams
- BufferedReader and BufferedWriter
- Serialization
- The NIO.2 API: Path and Files
- Interchange Formats: CSV and Properties
Module 8: Multithreading and Concurrency
- Introduction to Multithreading
- Creating Threads
- Thread Lifecycle
- Synchronization
- Concurrency Utilities
- Concurrent Collections and Atomic Variables
- Asynchronous Tasks with CompletableFuture
Module 9: Networking
- Introduction to Networking
- Sockets
- ServerSocket
- DatagramSocket and DatagramPacket
- URL and HttpURLConnection
- The Modern HTTP Client
Module 10: Advanced Topics
- Generics
- Annotations
- Reflection
- Java 8 Features: Streams and Optional
- Dates and Times with java.time
- Java 9 and Beyond
- Memory, Garbage Collection and Performance
Module 11: Java Frameworks and Libraries
- Introduction to Java Frameworks
- Spring Framework
- Hibernate
- JUnit
- Maven
- Advanced Testing with Mockito
- Essential Ecosystem Libraries
