Interfaces, Generics and Sealed Types

Article · 14:00

An interface is a promise about what a type can do, with no statement about what it is, and programming against one is what lets a test swap the implementation out. Default methods, bounds, erasure and what it costs, PECS, and sealed hierarchies.

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; default methods let it carry some.
  • A bound (T extends Comparable<T>) is what lets a generic body do anything.
  • Generics are erased: no new T[], no instanceof List<String>, casts fail late.
  • PECS - ? extends to read from, ? super to write to.
  • An enum is a class with a fixed set of instances, and can carry state and methods.
  • sealed closes the set of subtypes, which is what makes a switch exhaustive.