Lab 12: Generics

Objective

Write generic classes and methods, use bounded type parameters, understand wildcards (? extends, ? super), and apply the PECS principle (Producer Extends, Consumer Super).

Background

Generics enable type-safe, reusable code — a single Stack<T> works for String, Integer, or any type, with compile-time type checking. Without generics you'd cast everywhere and discover ClassCastException at runtime. Understanding generics deeply unlocks the Collections framework, Streams, Optional, and every modern Java library.

Time

40 minutes

Prerequisites

  • Lab 06 (OOP)

  • Lab 08 (Interfaces)

  • Lab 09 (Collections)

Tools

  • Java 21 (Eclipse Temurin)

  • Docker image: innozverse-java:latest


Lab Instructions

Step 1: Generic Classes

💡 Type parameters (<T>, <A, B>) are replaced at compile time with actual types. The diamond <> lets the compiler infer the type from context. Generics exist only at compile time — at runtime all Box<String> and Box<Integer> are just Box (type erasure).

📸 Verified Output:


Step 2: Generic Methods

💡 <T extends Comparable<T>> is a bounded type parameter — T must implement Comparable<T>. This lets you call compareTo() inside the method. Without the bound, the compiler doesn't know T has any methods. Bounds enable generic algorithms that work on any compatible type.

📸 Verified Output:


Step 3: Bounded Type Parameters

💡 Multiple bounds use &: <T extends Number & Comparable<T>>. The class bound (if any) must come first, followed by interface bounds. This lets you call methods from all bounded types within the generic method. It's used in the JDK itself: <T extends Object & Comparable<? super T>> for Collections.max().

📸 Verified Output:


Step 4: Wildcards — ?, ? extends, ? super

💡 PECS — Producer Extends, Consumer Super: If a parameter provides (produces) values for you to read → ? extends T. If a parameter accepts (consumes) values you write → ? super T. Collections.copy(dst, src) uses List<? super T> for dst and List<? extends T> for src — the canonical PECS example.

📸 Verified Output:


Step 5: Generic Data Structures

💡 @SuppressWarnings("unchecked") silences the cast warning from Object[] to T[]. This is necessary because Java can't create T[] directly (new T[n] is illegal — type erasure). The cast is safe here because we only store T instances, but the compiler can't verify it. Use this annotation minimally and only when the cast is provably safe.

📸 Verified Output:


Step 6: Type Inference & var

💡 var doesn't mean dynamic typing — the type is fixed at compile time, just inferred. var x = new ArrayList<String>() is exactly ArrayList<String> x = new ArrayList<String>(). Use var when the type is obvious from the right side; avoid it when it obscures what type you're working with.

📸 Verified Output:


Step 7: Generics with Reflection

💡 Type tokens (Class<T>) are the standard Java pattern to preserve generic type information at runtime (working around erasure). Libraries like Jackson, Gson, Spring, and Hibernate use this pattern extensively. TypeReference<List<String>> in Jackson is a more sophisticated version.

📸 Verified Output:


Step 8: Complete Example — Generic Repository

💡 A generic Repository<T extends Entity> works for Users, Products, Orders — any entity with an ID. This is the Repository pattern used in Spring Data JPA. The <R extends Comparable<R>> on findAllSortedBy ensures you can only sort by comparable fields, catching type errors at compile time.

📸 Verified Output:


Verification

Summary

You've written generic classes, generic methods, bounded type parameters, wildcards with PECS, a generic stack, type inference with var, type tokens for reflection, and a generic repository. Generics are Java's most powerful compile-time safety mechanism — master them and you'll rarely see ClassCastException.

Further Reading

Last updated