Interfaces, Generics and Sealed Types
An interface is a promise about what a type can do, with no statement about what it is. Programming against one is what lets you swap a real implementation for a test one without the caller noticing.
import java.util.*;
public class Interfaces {
interface Repository {
Optional<String> findById(String id); // the promise
default String findOrDefault(String id, String fallback) {
return findById(id).orElse(fallback); // a default method, shared
}
}
static final class InMemory implements Repository {
private final Map<String, String> rows;
InMemory(Map<String, String> rows) { this.rows = rows; }
@Override
public Optional<String> findById(String id) {
return Optional.ofNullable(rows.get(id));
}
}
public static void main(String[] args) {
Repository repository = new InMemory(Map.of("1", "Ada"));
System.out.println(repository.findById("1"));
System.out.println(repository.findById("2"));
System.out.println(repository.findOrDefault("2", "unknown"));
}
}
default methods let an interface carry shared behaviour without forcing every
implementer to write it - and they are how Collection gained stream()
without breaking every existing class.
Generics: one implementation, many types
import java.util.*;
import java.util.function.Function;
public class Generics {
// A generic class: T stands for whatever the caller supplies.
record Box<T>(T value) {
<R> Box<R> map(Function<T, R> f) {
return new Box<>(f.apply(value));
}
}
// A bound gives the body something to work with. Without <T extends
// Comparable<T>>, compareTo would not compile.
static <T extends Comparable<T>> T largest(List<T> values) {
T best = values.getFirst();
for (T value : values) {
if (value.compareTo(best) > 0) best = value;
}
return best;
}
public static void main(String[] args) {
var boxed = new Box<>(42);
System.out.println(boxed);
System.out.println(boxed.map(Object::toString).map(String::length));
System.out.println(largest(List.of(3, 9, 4)));
System.out.println(largest(List.of("pear", "apple", "quince")));
}
}
Java's generics are erased: at runtime a List<String> is just a List.
That is why you cannot write new T[], cannot ask list instanceof List<String>,
and why an unchecked cast compiles with a warning rather than an error.
import java.util.*;
public class Erasure {
public static void main(String[] args) {
List<String> strings = new ArrayList<>();
List<Integer> integers = new ArrayList<>();
// The same class at runtime: the type argument is gone.
System.out.println(strings.getClass() == integers.getClass());
// Which means this compiles, and blows up later rather than here.
List<Object> raw = new ArrayList<>(List.of("text"));
Object smuggled = raw.getFirst();
try {
Integer broken = (Integer) smuggled;
System.out.println(broken);
} catch (ClassCastException e) {
System.out.println("erasure: the cast fails at the use, not the assignment");
}
}
}
Wildcards: what you can read, and what you can write
? extends T is a producer - you can read T out of it, but you cannot add,
because the actual list may be of some narrower type. ? super T is a consumer -
you can add a T, but reads come back as Object.
import java.util.*;
public class Wildcards {
// Reads numbers. Accepts List<Integer>, List<Double>, List<Number>.
static double total(List<? extends Number> values) {
double sum = 0;
for (Number value : values) sum += value.doubleValue();
return sum;
}
// Writes integers. Accepts List<Integer>, List<Number>, List<Object>.
static void addFirstThree(List<? super Integer> target) {
target.add(1);
target.add(2);
target.add(3);
}
public static void main(String[] args) {
System.out.println(total(List.of(1, 2, 3)));
System.out.println(total(List.of(1.5, 2.5)));
List<Number> numbers = new ArrayList<>();
addFirstThree(numbers);
System.out.println(numbers);
}
}
The mnemonic is PECS: Producer Extends, Consumer Super.
Enums are classes, and can carry behaviour
public class Enums {
enum Status {
DRAFT("not visible"),
PUBLISHED("live"),
ARCHIVED("read only");
private final String description;
Status(String description) { this.description = description; }
String description() { return description; }
boolean isVisible() { return this != DRAFT; }
}
public static void main(String[] args) {
for (Status status : Status.values()) {
System.out.println(status + ": " + status.description()
+ (status.isVisible() ? " (visible)" : ""));
}
// A switch over an enum needs no default when every constant is covered,
// and then adding a constant makes this stop compiling.
Status status = Status.PUBLISHED;
String action = switch (status) {
case DRAFT -> "publish it";
case PUBLISHED -> "archive it";
case ARCHIVED -> "leave it";
};
System.out.println(action);
}
}
Sealed types: a closed set the compiler knows
sealed names exactly which types may implement an interface. The compiler then
knows the complete list, which makes a switch over it exhaustive - the subject
of the Level 3 lesson on patterns.
public class Sealed {
sealed interface Payment permits Card, Transfer, Cash { }
record Card(String last4) implements Payment { }
record Transfer(String reference) implements Payment { }
record Cash() implements Payment { }
// No default branch, and no way to forget one: adding a fourth kind of
// Payment makes this method stop compiling until it is handled.
static String describe(Payment payment) {
return switch (payment) {
case Card c -> "card ending " + c.last4();
case Transfer t -> "transfer " + t.reference();
case Cash ignored -> "cash";
};
}
public static void main(String[] args) {
for (Payment payment : new Payment[]{new Card("4242"), new Transfer("ABC"), new Cash()}) {
System.out.println(describe(payment));
}
}
}
What to take away
- An interface is a promise about behaviour;
defaultmethods let it carry some. - A bound (
T extends Comparable<T>) is what lets a generic body do anything. - Generics are erased: no
new T[], noinstanceof List<String>, casts fail late. - PECS -
? extendsto read from,? superto write to. - An enum is a class with a fixed set of instances, and can carry state and methods.
sealedcloses the set of subtypes, which is what makes aswitchexhaustive.