Java · lesson 26 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 and OOP in Java
Learn how Java generics and OOP work together — generic classes, bounded type parameters, wildcards, generic methods, and type-safe collection design.
By SkillByExample · Updated
Generics let a class or interface be written once and used safely with many types; OOP lets those
generic types form hierarchies. Together they give you type-safe containers, repositories and
result types with no casts. Each example is a single file run with the JDK 21 source launcher
(java File.java).
Why generics: the Object-based box
Before generics, a reusable container stored Object, and every read needed a cast that could
fail at runtime.
public class RawBox {
public static void main(String[] args) {
ObjectBox box = new ObjectBox();
box.set("hello");
String s = (String) box.get();
System.out.println(s.toUpperCase());
box.set(42); // nothing stops this
String t = (String) box.get();
System.out.println(t);
}
}
class ObjectBox {
private Object value;
void set(Object v) { value = v; }
Object get() { return value; }
}
$ java RawBox.java
HELLO
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 RawBox.main(RawBox.java:9)
The mistake is on line 8 but the crash is on line 9 — or, in a real program, much further away.
A generic Box<T> moves the check to compile time:
public class GenericBox {
public static void main(String[] args) {
Box<String> box = new Box<>();
box.set("hello");
System.out.println(box.get().toUpperCase()); // no cast
box.set(42);
}
}
class Box<T> {
private T value;
void set(T v) { value = v; }
T get() { return value; }
}
$ java GenericBox.java
GenericBox.java:6: error: method set in class Box<T> cannot be applied to given types;
box.set(42);
^
required: String
found: int
reason: argument mismatch; int cannot be converted to String
where T is a type-variable:
T extends Object declared in class Box
1 error
error: compilation failed
A generic result type
Result<T> holds either a value of type T or an error. Static factory methods declare their own
<T>, and map introduces a second type U for the transformed value.
import java.util.function.Function;
public class ResultDemo {
public static void main(String[] args) {
Result<Integer> ok = parse("42");
Result<String> described = ok.map(n -> "number " + n);
System.out.println(described);
System.out.println(parse("bad").map(n -> n * 2));
}
static Result<Integer> parse(String s) {
try { return Result.ok(Integer.parseInt(s)); }
catch (NumberFormatException e) { return Result.fail("not an integer: " + s); }
}
}
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 <T> Result<T> ok(T value) { return new Result<>(value, null); }
static <T> Result<T> fail(String error) { return new Result<>(null, error); }
<U> Result<U> map(Function<? super T, ? extends U> f) {
return error == null ? ok(f.apply(value)) : fail(error);
}
public String toString() { return error == null ? "ok(" + value + ")" : "fail(" + error + ")"; }
}
$ java ResultDemo.java
ok(number 42)
fail(not an integer: bad)
Result<Integer> became Result<String> through map with no casts. On the failure path the
lambda never runs, so n * 2 is never evaluated on a missing value. The ? super T/? extends U
wildcards on map let callers pass more general functions, the same way java.util.Optional.map does.
Generic interface, abstract base, concrete subclass
A generic interface defines a contract; a generic abstract class implements the shared plumbing; a subclass fixes the type arguments and adds domain methods.
import java.util.*;
public class RepositoryDemo {
public static void main(String[] args) {
UserRepository repo = new UserRepository();
repo.save(new User(1L, "Alice", "alice@example.com"));
repo.save(new User(2L, "Bob", "bob@example.com"));
repo.save(new User(1L, "Alice", "alice@new.example")); // same id: replaces
System.out.println(repo.count() + " users");
System.out.println(repo.findById(1L));
System.out.println(repo.findByEmail("BOB@example.com"));
System.out.println(repo.findById(9L));
}
}
record User(Long id, String name, String email) {}
interface Repository<T, ID> {
Optional<T> findById(ID id);
T save(T entity);
int count();
}
abstract class InMemoryRepository<T, ID> implements Repository<T, ID> {
protected final Map<ID, T> storage = new LinkedHashMap<>();
protected abstract ID idOf(T entity);
public Optional<T> findById(ID id) { return Optional.ofNullable(storage.get(id)); }
public T save(T entity) { storage.put(idOf(entity), entity); return entity; }
public int count() { return storage.size(); }
}
class UserRepository extends InMemoryRepository<User, Long> {
protected Long idOf(User u) { return u.id(); }
Optional<User> findByEmail(String email) {
return storage.values().stream().filter(u -> u.email().equalsIgnoreCase(email)).findFirst();
}
}
$ java RepositoryDemo.java
2 users
Optional[User[id=1, name=Alice, email=alice@new.example]]
Optional[User[id=2, name=Bob, email=bob@example.com]]
Optional.empty
InMemoryRepository never mentions User; UserRepository supplies <User, Long> and the one
method that knows how to get an id. Inside UserRepository, storage is a Map<Long, User>, so
u.email() needs no cast.
Implementing Comparable
A class that passes itself as the type argument to Comparable gets a type-safe compareTo, and
every sorted collection can then use it.
import java.util.*;
public class Versions {
public static void main(String[] args) {
List<Version> vs = new ArrayList<>(List.of(Version.parse("1.10.0"), Version.parse("1.2.3"), Version.parse("1.2.10")));
Collections.sort(vs);
System.out.println(vs);
System.out.println(new TreeSet<>(vs).last());
List<String> asText = new ArrayList<>(List.of("1.10.0", "1.2.3", "1.2.10"));
Collections.sort(asText);
System.out.println(asText);
}
}
record Version(int major, int minor, int patch) implements Comparable<Version> {
static Version parse(String s) {
String[] p = s.split("\\.");
return new Version(Integer.parseInt(p[0]), Integer.parseInt(p[1]), Integer.parseInt(p[2]));
}
public int compareTo(Version o) {
return Comparator.comparingInt(Version::major).thenComparingInt(Version::minor)
.thenComparingInt(Version::patch).compare(this, o);
}
public String toString() { return major + "." + minor + "." + patch; }
}
$ java Versions.java
[1.2.3, 1.2.10, 1.10.0]
1.10.0
[1.10.0, 1.2.10, 1.2.3]
Sorted as Versions, 1.2.10 comes after 1.2.3; sorted as strings, "1.10.0" comes first because
'1' < '2' character by character. compareTo(Version o) takes a Version, not an Object,
because of the <Version> type argument.
Wildcards across a class hierarchy
List<Dog> is not a List<Animal>. Wildcards let one method accept lists of related types:
? extends to read (a producer), ? super to write (a consumer).
import java.util.*;
public class Shelter {
static double totalWeight(List<? extends Animal> animals) {
double total = 0;
for (Animal a : animals) total += a.weight();
return total;
}
static void addPuppies(List<? super Dog> dest, int n) {
for (int i = 1; i <= n; i++) dest.add(new Dog("Puppy" + i, 2.0));
}
public static void main(String[] args) {
List<Dog> dogs = new ArrayList<>(List.of(new Dog("Rex", 30), new Dog("Buddy", 12)));
List<Animal> animals = new ArrayList<>(List.of(new Cat("Whiskers", 4)));
addPuppies(dogs, 2);
addPuppies(animals, 1);
System.out.println(totalWeight(dogs) + " kg of dogs, " + dogs.size() + " dogs");
System.out.println(totalWeight(animals) + " kg of animals: " + animals);
}
}
abstract class Animal {
private final String name; private final double weight;
Animal(String name, double weight) { this.name = name; this.weight = weight; }
double weight() { return weight; }
public String toString() { return getClass().getSimpleName() + " " + name; }
}
class Dog extends Animal { Dog(String n, double w) { super(n, w); } }
class Cat extends Animal { Cat(String n, double w) { super(n, w); } }
$ java Shelter.java
46.0 kg of dogs, 4 dogs
6.0 kg of animals: [Cat Whiskers, Dog Puppy1]
totalWeight accepts both lists because each holds “some subtype of Animal”; addPuppies
accepts both because each can hold a Dog. Declared as List<Animal>, totalWeight(dogs) would
not compile.
Working around erasure with a Class token
Type arguments are erased at runtime, so new T() and instanceof T are impossible. Passing a
Class<T> object gives the code a real runtime type to work with.
import java.util.*;
public class TypedBag {
public static void main(String[] args) throws Exception {
Bag<StringBuilder> bag = new Bag<>(StringBuilder.class);
StringBuilder sb = bag.create();
sb.append("made by reflection");
System.out.println(sb);
List<Object> mixed = List.of("a", 1, new StringBuilder("b"), 2.5, new StringBuilder("c"));
System.out.println(bag.filter(mixed));
}
}
class Bag<T> {
private final Class<T> type;
Bag(Class<T> type) { this.type = type; }
T create() throws ReflectiveOperationException {
return type.getDeclaredConstructor().newInstance();
}
List<T> filter(List<?> items) {
List<T> out = new ArrayList<>();
for (Object o : items) if (type.isInstance(o)) out.add(type.cast(o));
return out;
}
}
$ java TypedBag.java
made by reflection
[b, c]
type.isInstance and type.cast do at runtime what instanceof T and (T) cannot. The token is
an ordinary object, so erasure doesn’t touch it.
Summary
| Feature | Purpose |
|---|---|
class Box<T> | reusable, type-safe container |
static <T> Result<T> ok(T) | factory that infers its type |
| generic interface + abstract base | shared plumbing, concrete types at the leaf |
implements Comparable<Self> | natural ordering for sorting and sorted collections |
? extends / ? super | accept related types for reading / writing |
Class<T> token | runtime type for creation and checks |
Practice
- Make
Result.mapcatch an exception thrown by the mapper and return a failure carrying its message. Test withn -> n / 0.
Solution
import java.util.function.Function;
public class SafeMap {
public static void main(String[] args) {
System.out.println(Result.ok(10).map(n -> n / 2));
System.out.println(Result.ok(10).map(n -> n / 0));
}
}
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 <T> Result<T> ok(T value) { return new Result<>(value, null); }
static <T> Result<T> fail(String error) { return new Result<>(null, error); }
<U> Result<U> map(Function<? super T, ? extends U> f) {
if (error != null) return fail(error);
try { return ok(f.apply(value)); }
catch (RuntimeException e) { return fail(e.getClass().getSimpleName() + ": " + e.getMessage()); }
}
public String toString() { return error == null ? "ok(" + value + ")" : "fail(" + error + ")"; }
}
$ java SafeMap.java
ok(5)
fail(ArithmeticException: / by zero)
The exception becomes data, so a chain of map calls never throws.
- Add a
ProductRepository extends InMemoryRepository<Product, String>keyed by SKU, with afindCheaperThan(double)method.
Solution
import java.util.*;
public class Products {
public static void main(String[] args) {
ProductRepository repo = new ProductRepository();
repo.save(new Product("A-1", 9.99));
repo.save(new Product("B-2", 24.50));
repo.save(new Product("C-3", 4.25));
System.out.println(repo.findCheaperThan(10));
System.out.println(repo.findById("B-2"));
}
}
record Product(String sku, double price) {}
abstract class InMemoryRepository<T, ID> {
protected final Map<ID, T> storage = new LinkedHashMap<>();
protected abstract ID idOf(T entity);
Optional<T> findById(ID id) { return Optional.ofNullable(storage.get(id)); }
T save(T entity) { storage.put(idOf(entity), entity); return entity; }
}
class ProductRepository extends InMemoryRepository<Product, String> {
protected String idOf(Product p) { return p.sku(); }
List<Product> findCheaperThan(double max) {
return storage.values().stream().filter(p -> p.price() < max).toList();
}
}
$ java Products.java
[Product[sku=A-1, price=9.99], Product[sku=C-3, price=4.25]]
Optional[Product[sku=B-2, price=24.5]]
The base class was reused unchanged with a different entity and a String key.
- Change
totalWeight’s parameter toList<Animal>and call it withList<Dog>. What does the compiler say?
Solution
import java.util.List;
public class NoWildcard {
static double totalWeight(List<Animal> animals) {
return animals.stream().mapToDouble(Animal::weight).sum();
}
public static void main(String[] args) {
List<Dog> dogs = List.of(new Dog(30), new Dog(12));
System.out.println(totalWeight(dogs));
}
}
class Animal { private final double w; Animal(double w) { this.w = w; } double weight() { return w; } }
class Dog extends Animal { Dog(double w) { super(w); } }
$ java NoWildcard.java
NoWildcard.java:10: error: method totalWeight in class NoWildcard cannot be applied to given types;
System.out.println(totalWeight(dogs));
^
required: List<Animal>
found: List<Dog>
reason: argument mismatch; List<Dog> cannot be converted to List<Animal>
1 error
error: compilation failed
Generics are invariant; List<? extends Animal> is what expresses “a list of some kind of animal”.
- Write
invertMulti(Map<K, V>)returningMap<V, List<K>>so keys that share a value are all kept. Test with{a=1, b=2, c=1}.
Solution
import java.util.*;
public class InvertMulti {
static <K, V> Map<V, List<K>> invertMulti(Map<K, V> original) {
Map<V, List<K>> out = new TreeMap<>();
original.forEach((k, v) -> out.computeIfAbsent(v, x -> new ArrayList<>()).add(k));
return out;
}
public static void main(String[] args) {
Map<String, Integer> m = new LinkedHashMap<>();
m.put("a", 1); m.put("b", 2); m.put("c", 1);
System.out.println(invertMulti(m));
}
}
$ java InvertMulti.java
{1=[a, c], 2=[b]}
A plain Map<V, K> would keep only one of a and c; collecting into lists keeps both.