Java · lesson 23 of 66
Beginner
17- 1 Introduction to Java
- 2 Setting Up Java
- 3 Data Types in Java
- 4 Operators in Java
- 5 Variables and Operators in Java
- 6 Control Flow in Java
- 7 Arrays in Java
- 8 Functions (Methods) in Java
- 9 Strings in Java
- 10 Methods in Java
- 11 Java OOP — Complete Guide
- 12 Introduction to Object-Oriented Programming in Java
- 13 Encapsulation in Java
- 14 Inheritance in Java
- 15 Polymorphism in Java
- 17 Abstraction in Java
- 18 Error Types in Java
Intermediate
38- 16 Regular Expressions in Java
- 19 Interfaces in Java — A Deep Dive
- 20 Abstract Classes in Java
- 21 Method Overloading in Java
- 22 Composition vs Inheritance in Java
- 23 Generics in Java
- 27 Exception Handling in Java
- 28 Java Streams API
- 29 Collections Framework in Java
- 30 Optional in Java
- 31 File Handling in Java
- 32 Records in Java
- 33 Java 8 Features
- 34 Sealed Classes and Interfaces in Java
- 35 Multithreading in Java
- 37 JDBC — Database Access in Java
- 39 Maven — Java Build and Dependency Management
- 42 Gradle — Modern Java Build Tool
- 44 Unit Testing with JUnit 5 and Mockito
- 45 Logging in Java with SLF4J and Logback
- 49 Spring Boot — Introduction
- 50 Dependency Injection in Spring Boot
- 51 Java Project Ideas
- 52 Building REST APIs with Spring Boot
- 53 Validation in Spring Boot
- 54 Spring Data JPA — Database Access
- 55 Exception Handling in Spring Boot
- 56 Spring Security — Authentication and Authorization
- 57 JWT Authentication in Spring Boot
- 58 Dockerizing Spring Boot Applications
- 59 Java Enums: Beyond Simple Constants
- 60 Inner and Anonymous Classes in Java
- 61 Java Generics: Bounded Types, Wildcards, and Generic Methods
- 62 Java Date/Time API: LocalDate, LocalDateTime, and Duration
- 63 Java NIO.2: Paths, Files, and WatchService
- 64 Java Annotations: Custom Types and Meta-Information
- 65 Java Reflection: Dynamic Type Inspection and Method Invocation
- 66 Java Localization: ResourceBundle and Locale Support
Advanced
11Generics in Java
Deep dive into Java generics — generic methods, bounded type parameters, wildcards (? extends, ? super), type erasure, and the PECS principle.
By SkillByExample · Updated
Generics let you write a class or method once and have the compiler check it for every type it is
used with. This lesson covers the essentials — generic classes, generic methods, bounds, wildcards
and erasure; Bounded Types and Wildcards goes further. Every example is a
single file run with the JDK 21 source launcher (java File.java).
Why generics: raw types fail late
Before Java 5 a List held Object, so the compiler couldn’t stop the wrong type going in.
import java.util.ArrayList;
import java.util.List;
public class RawList {
@SuppressWarnings({"rawtypes", "unchecked"})
public static void main(String[] args) {
List names = new ArrayList();
names.add("Alice");
names.add(42); // compiles: anything goes
for (Object o : names) {
String name = (String) o;
System.out.println(name.toUpperCase());
}
}
}
$ java RawList.java
ALICE
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
at RawList.main(RawList.java:11)
The bug was adding 42, but the crash happens later, at the cast. With List<String> the same
mistake is a compile error on the add line itself, and reads need no cast.
Generic classes
Type parameters go in angle brackets after the class name and can be used for fields, parameters and return types.
public class PairDemo {
public static void main(String[] args) {
Pair<String, Integer> nameAge = new Pair<>("Alice", 30);
String name = nameAge.first();
int age = nameAge.second(); // auto-unboxed
System.out.println(name + " is " + age);
Pair<Integer, String> swapped = nameAge.swap();
System.out.println(swapped + " first is a " + swapped.first().getClass().getSimpleName());
}
}
class Pair<A, B> {
private final A first;
private final B second;
Pair(A first, B second) { this.first = first; this.second = second; }
A first() { return first; }
B second() { return second; }
Pair<B, A> swap() { return new Pair<>(second, first); }
public String toString() { return "(" + first + ", " + second + ")"; }
}
$ java PairDemo.java
Alice is 30
(30, Alice) first is a Integer
The <> “diamond” lets the compiler infer the type arguments from the variable’s type. swap()
returns Pair<B, A>, so the compiler knows swapped.first() is an Integer. Type arguments must
be reference types: Pair<String, int> doesn’t compile, which is why Integer is used and
auto-unboxing converts it to int.
Generic methods
A generic method declares its own type parameter before the return type; the compiler infers it from the arguments.
import java.util.Arrays;
public class GenericMethods {
static <T> T firstNonNull(T a, T b) {
return a != null ? a : b;
}
static <T> void swap(T[] arr, int i, int j) {
T tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
public static void main(String[] args) {
System.out.println(firstNonNull(null, "default"));
System.out.println(firstNonNull("value", "default"));
String none = firstNonNull(null, null);
System.out.println(none);
Integer[] nums = {1, 2, 3, 4, 5};
swap(nums, 0, 4);
System.out.println(Arrays.toString(nums));
String s = GenericMethods.<String>firstNonNull(null, "explicit");
System.out.println(s);
}
}
$ java GenericMethods.java
default
value
null
[5, 2, 3, 4, 1]
explicit
firstNonNull(null, null) returns b, which is null — generics add type checks, not null
checks. swap works on Integer[] but not int[], because T can’t be a primitive.
GenericMethods.<String>firstNonNull(...) shows the rarely needed explicit form.
Bounded type parameters
<T extends Bound> restricts T and makes the bound’s methods available inside the method.
import java.util.List;
public class Bounds {
static <T extends Number> double sum(List<T> list) {
double total = 0;
for (T n : list) total += n.doubleValue();
return total;
}
static <T extends Comparable<T>> T clamp(T val, T min, T max) {
if (val.compareTo(min) < 0) return min;
if (val.compareTo(max) > 0) return max;
return val;
}
public static void main(String[] args) {
System.out.println(sum(List.of(1, 2, 3)) + " " + sum(List.of(1.5, 2.5)));
System.out.println(clamp(15, 1, 10) + " " + clamp(5, 1, 10) + " " + clamp(-3, 1, 10));
System.out.println(clamp("kiwi", "banana", "cherry"));
System.out.println(sum(List.of("a", "b")));
}
}
$ java Bounds.java
Bounds.java:20: error: method sum in class Bounds cannot be applied to given types;
System.out.println(sum(List.of("a", "b")));
^
required: List<T>
found: List<String>
reason: inference variable E has incompatible bounds
upper bounds: Number,Object
lower bounds: String
where T,E are type-variables:
T extends Number declared in method <T>sum(List<T>)
E extends Object declared in method <E>of(E,E)
1 error
error: compilation failed
String isn’t a Number, so the call is rejected before anything runs. Remove the last line and
the program prints 6.0 4.0, 10 5 1 and cherry — clamp works for any Comparable, and
"kiwi" sorts after "cherry", so it is clamped down to the maximum.
Wildcards in one example
Generic types are invariant: a List<Integer> is not a List<Number>. Wildcards widen what a
parameter accepts — ? extends T for lists you read from, ? super T for lists you write to.
import java.util.ArrayList;
import java.util.List;
public class Wildcards {
static void printAll(List<?> list) { System.out.println("any list: " + list); }
static double total(List<? extends Number> nums) { return nums.stream().mapToDouble(Number::doubleValue).sum(); }
static void fill(List<? super Integer> sink) { for (int i = 1; i <= 3; i++) sink.add(i); }
static <T> void copy(List<? super T> dest, List<? extends T> src) {
for (T item : src) dest.add(item);
}
public static void main(String[] args) {
printAll(List.of("a", "b"));
System.out.println(total(List.of(1, 2, 3)) + " " + total(List.of(0.5, 0.25)));
List<Number> numbers = new ArrayList<>();
fill(numbers);
copy(numbers, List.of(9.5));
System.out.println(numbers);
}
}
$ java Wildcards.java
any list: [a, b]
6.0 0.75
[1, 2, 3, 9.5]
copy reads from src (a producer, extends) and writes into dest (a consumer, super) —
the PECS rule. A List<Number> can receive Integers from fill and a Double from copy.
Type erasure
The compiler checks generic types and then erases them; at runtime List<String> is just
List. A raw reference can therefore sneak the wrong type in, and the failure appears where the
compiler inserted a hidden cast.
import java.util.ArrayList;
import java.util.List;
public class Erasure {
@SuppressWarnings({"rawtypes", "unchecked"})
public static void main(String[] args) {
List<String> strings = new ArrayList<>();
List raw = strings; // same object, generics switched off
raw.add(42); // no error: at runtime it's just a List
System.out.println("size " + strings.size() + ", class " + strings.getClass().getName());
Object o = strings.get(0);
System.out.println("as Object: " + o);
String s = strings.get(0);
}
}
$ java Erasure.java
size 1, class java.util.ArrayList
as Object: 42
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
at Erasure.main(Erasure.java:13)
The class prints as plain ArrayList — no <String> exists at runtime. Reading the element as
Object works; assigning it to a String fails because the compiler inserted a (String) cast
there. This is “heap pollution”, and it only happens when raw types or unchecked casts bypass the
compiler — which is why their warnings matter.
Comparable and Comparator
Comparable<T> gives a class its natural order; Comparator<T> builds other orders, and
generics keep both type-safe.
import java.util.*;
public class StudentSort {
record Student(String name, double gpa) implements Comparable<Student> {
public int compareTo(Student other) { return Double.compare(other.gpa, gpa); } // highest first
}
public static void main(String[] args) {
List<Student> students = new ArrayList<>(List.of(
new Student("Carol", 3.9), new Student("Bob", 3.5),
new Student("Alice", 3.9), new Student("Dan", 3.5)));
Collections.sort(students);
System.out.println("natural: " + names(students));
students.sort(Comparator.comparingDouble(Student::gpa).reversed().thenComparing(Student::name));
System.out.println("gpa, name: " + names(students));
students.sort(Comparator.comparing(Student::name));
System.out.println("name: " + names(students));
}
static List<String> names(List<Student> s) { return s.stream().map(Student::name).toList(); }
}
$ java StudentSort.java
natural: [Carol, Alice, Bob, Dan]
gpa, name: [Alice, Carol, Bob, Dan]
name: [Alice, Bob, Carol, Dan]
With ties on GPA, the natural order keeps Carol before Alice: Java’s object sort is stable, so
equal elements stay in their original order. thenComparing(Student::name) breaks the tie
alphabetically, so Alice moves ahead.
Practice
- Write a generic
Box<T>withmap(Function<T, R>)returning aBox<R>, and turn aBox<String>into aBox<Integer>holding its length.
Solution
import java.util.function.Function;
public class BoxMap {
public static void main(String[] args) {
Box<String> word = new Box<>("generics");
Box<Integer> length = word.map(String::length);
System.out.println(word + " -> " + length);
}
}
class Box<T> {
private final T value;
Box(T value) { this.value = value; }
<R> Box<R> map(Function<T, R> f) { return new Box<>(f.apply(value)); }
public String toString() { return "Box(" + value + ")"; }
}
$ java BoxMap.java
Box(generics) -> Box(8)
map declares its own <R>, separate from the class’s <T>, so one box can produce a box of a
different type.
- Write
<T extends Comparable<T>> T maxOf(List<T> list)that throwsIllegalArgumentExceptionfor an empty list.
Solution
import java.util.List;
public class MaxOf {
static <T extends Comparable<T>> T maxOf(List<T> list) {
if (list.isEmpty()) throw new IllegalArgumentException("empty list");
T best = list.get(0);
for (T t : list) if (t.compareTo(best) > 0) best = t;
return best;
}
public static void main(String[] args) {
System.out.println(maxOf(List.of(4, 9, 2)));
System.out.println(maxOf(List.of("pear", "apple")));
System.out.println(maxOf(List.<Integer>of()));
}
}
$ java MaxOf.java
9
pear
Exception in thread "main" java.lang.IllegalArgumentException: empty list
at MaxOf.maxOf(MaxOf.java:5)
at MaxOf.main(MaxOf.java:14)
List.<Integer>of() gives the empty list a type, so the call still type-checks.
- Write a
TypedList<T>that stores aClass<T>and rejects wrongly typed elements ataddtime, even when called through a raw reference.
Solution
import java.util.ArrayList;
import java.util.List;
public class TypedListDemo {
@SuppressWarnings({"rawtypes", "unchecked"})
public static void main(String[] args) {
TypedList<String> names = new TypedList<>(String.class);
names.add("Alice");
TypedList raw = names;
raw.add(42);
}
}
class TypedList<T> {
private final Class<T> type;
private final List<T> items = new ArrayList<>();
TypedList(Class<T> type) { this.type = type; }
void add(T item) { items.add(type.cast(item)); }
}
$ java TypedListDemo.java
Exception in thread "main" java.lang.ClassCastException: Cannot cast java.lang.Integer to java.lang.String
at java.base/java.lang.Class.cast(Class.java:4069)
at TypedList.add(TypedListDemo.java:18)
at TypedListDemo.main(TypedListDemo.java:10)
type.cast checks at runtime what erasure removed, so the bad element fails on insert instead of
on a later read. Collections.checkedList does the same for any list.
- Sort words by length, then alphabetically for equal lengths, using
Comparatormethods only.
Solution
import java.util.*;
public class LengthThenAlpha {
public static void main(String[] args) {
List<String> words = new ArrayList<>(List.of("pear", "fig", "apple", "kiwi", "date"));
words.sort(Comparator.comparingInt(String::length).thenComparing(Comparator.naturalOrder()));
System.out.println(words);
}
}
$ java LengthThenAlpha.java
[fig, date, kiwi, pear, apple]
comparingInt avoids boxing the lengths, and thenComparing only runs when the lengths tie.